Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ based on the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format.

`modctl` adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## unreleased

### Changes

- Start including the nexus cache in export bundles.

## v0.6.0 - 2026-04-03

This is another pre-release that adds new features.
Expand Down
42 changes: 39 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,6 @@ Version schema from day 1.
A separate SQLite database at `$XDG_CACHE_HOME/modctl/nexus_cache.db` stores
cached Nexus API responses. This is intentionally separate from the main DB:
- It is safe to delete (will be repopulated on next `mods nexus check-updates`)
- It is excluded from export/import bundles
- It uses a simple internal version number; if the schema version does not
match the expected version the cache is blown away and recreated

Expand Down Expand Up @@ -274,6 +273,7 @@ Rationale:
A single file (tar + zstd) containing:
- `manifest.json` - bundle metadata (see below)
- `modctl.db` - database snapshot
- `nexus_cache.db` - nexus cache database snapshot
- `archives/<fan2>/<fullhash>` - referenced archive blobs
- `backups/<fan2>/<fullhash>` - referenced backup blobs

Expand All @@ -294,6 +294,8 @@ Import verifies integrity and schema compatibility.
to verify integrity on import
- `counts`: `{ "archives": N, "backups": N }`
- `game` (game-scoped only): `{ "store_id", "store_game_id", "display_name" }`
- `cache_sha256`: sha256 of the nexus cache database snapshot as it appears
in the bundle, used to verify integrity on import

#### Full export

Expand Down Expand Up @@ -1763,6 +1765,10 @@ This is only appropriate when restoring to the same machine where game
directories are still intact. `--force` and `--same-machine` are independent
and can be combined.

The nexus cache database is copied into place after the main database.
Its schema version is checked independently; if newer than the running
binary the import is refused.

**Game-scoped import** inserts rows into the existing database with fresh
IDs assigned by SQLite. A remapping table tracks old→new IDs for each table
so foreign key references are correctly updated as rows are inserted in
Expand All @@ -1773,6 +1779,10 @@ first (cascading via FK). Orphaned blobs from the deleted install are left
for `gc` to clean up. `--same-machine` is not valid for game-scoped imports
and will produce an error.

Cache rows for the imported game's mod pages are merged into the existing
cache database via `INSERT OR REPLACE`. Existing cache entries for other
games are not affected.

**Importing a game from a full bundle**: passing `--game store_id:store_game_id`
with a full bundle routes the import through the game-scoped import path,
extracting only the relevant data for that game. modctl prints an
Expand Down Expand Up @@ -1809,6 +1819,8 @@ Checks performed:
- `export_format_version` is supported (hard error if newer)
- `PRAGMA quick_check` on the bundle database
- `PRAGMA foreign_key_check` on the bundle database
- `cache_sha256` in the manifest matches the actual sha256 of `nexus_cache.db`
- `PRAGMA quick_check` on the cache database
- Every blob file in the bundle hashes correctly against its filename
- Every blob referenced in the bundle database has a corresponding file
- Every blob file in the bundle has a corresponding database row
Expand Down Expand Up @@ -1870,5 +1882,29 @@ complete integrity check.

If the extracted version has a `nexus_file_id` and the mod page has
`nexus_mod_id` and `nexus_game_domain` recorded, the Nexus URL and IDs
are printed after extraction. The Nexus cache is not consulted since it
is not included in bundles.
are printed after extraction.

### Nexus cache: included in export bundles

The nexus cache database is included in both full and game-scoped export
bundles. Although it is safe to delete locally, it contains useful data
(update chains, file metadata) that would otherwise require API calls to
rebuild on the destination machine.

For full exports, `VACUUM INTO` is used to produce a clean snapshot,
mirroring the approach used for the main database.

For game-scoped exports, a fresh cache database is created with migrations
applied, then populated with only the rows for the exported game's mod pages:
all rows in `nexus_mod_info`, `nexus_file_info`, and `nexus_file_updates`
where `(nexus_game_domain, nexus_mod_id)` matches a mod page belonging to
the exported game install.

On full import, the cache database is copied into place directly. On
game-scoped import, rows are merged into the existing cache database using
`INSERT OR REPLACE` — safe because all cache tables use Nexus-native primary
keys with no local ID remapping required.

`fetched_at` timestamps are preserved verbatim on import. The TTL logic in
`check-updates` will naturally treat stale entries as cold and refresh them
on the next run.
2 changes: 2 additions & 0 deletions cmd/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package cmd
import (
"fmt"
"os"
"path/filepath"
"time"

"github.com/charmbracelet/lipgloss"
Expand Down Expand Up @@ -93,6 +94,7 @@ Examples:
ModctlVersion: rootCmd.Version,
SkipInventory: exportSkipInventory,
NoVerify: exportNoVerify,
CacheDBPath: filepath.Join(viper.GetString("cache_dir"), "nexus_cache.db"),
}

date := time.Now().Format("20060102")
Expand Down
2 changes: 2 additions & 0 deletions cmd/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"context"
"errors"
"fmt"
"path/filepath"

"github.com/charmbracelet/lipgloss"
"github.com/mfinelli/modctl/dbq"
Expand Down Expand Up @@ -139,6 +140,7 @@ Use --dry-run to preview what would be imported without making any changes.`,
SkipInventory: importSkipInventory,
SameMachine: importSameMachine,
Game: importGame,
CacheDBPath: filepath.Join(viper.GetString("cache_dir"), "nexus_cache.db"),
}

// Warn if bundle modctl version is newer
Expand Down
52 changes: 52 additions & 0 deletions cmd/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (

"github.com/charmbracelet/lipgloss"
"github.com/mfinelli/modctl/dbq"
"github.com/mfinelli/modctl/internal"
"github.com/mfinelli/modctl/internal/blobstore"
"github.com/mfinelli/modctl/internal/restore"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -139,6 +140,26 @@ Exits non-zero if any integrity issues are found. Version warnings
}
fmt.Println()

// nexus cache integrity check
if bundle.Manifest.NexusCacheSha256 != "" {
fmt.Println(boldStyle.Render("Nexus Cache Integrity"))
cachePath := filepath.Join(bundle.BundleDir, "nexus_cache.db")
cacheIssues := checkBundleCacheDB(ctx, cachePath)
if len(cacheIssues) == 0 {
fmt.Println(okStyle.Render(" ✓ quick_check OK"))
} else {
for _, iss := range cacheIssues {
fmt.Println(errStyle.Render(" ✗ " + iss))
issues = append(issues, iss)
}
}
fmt.Println()
} else {
fmt.Println(boldStyle.Render("Nexus Cache Integrity"))
fmt.Println(subtleStyle.Render(" (not present in bundle)"))
fmt.Println()
}

// blob checks
fmt.Println(boldStyle.Render("Blob Integrity"))
blobIssues := checkBundleBlobs(ctx, bundle, bq)
Expand Down Expand Up @@ -349,3 +370,34 @@ func checkBundleBlobs(ctx context.Context, bundle *restore.Bundle, bq *dbq.Queri

return issues
}

// checkBundleCacheDB runs quick_check on the nexus cache database.
func checkBundleCacheDB(ctx context.Context, cachePath string) []string {
cacheDB, err := sql.Open("sqlite3", cachePath+internal.DB_PRAGMAS+"&mode=ro")
if err != nil {
return []string{"open nexus cache db: " + err.Error()}
}
defer cacheDB.Close()

rows, err := cacheDB.QueryContext(ctx, "PRAGMA quick_check;")
if err != nil {
return []string{"quick_check failed: " + err.Error()}
}
defer rows.Close()

var issues []string
for rows.Next() {
var result string
if err := rows.Scan(&result); err != nil {
issues = append(issues, "quick_check scan error: "+err.Error())
continue
}
if result != "ok" {
issues = append(issues, "quick_check: "+result)
}
}
if err := rows.Err(); err != nil {
issues = append(issues, "quick_check rows error: "+err.Error())
}
return issues
}
7 changes: 5 additions & 2 deletions docs/04_import-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ A bundle is a zstd-compressed tar archive containing:

- a snapshot of the modctl database
- all referenced mod archives, backup, and override blobs
- a snapshot of the Nexus API cache for the exported game(s)
- a manifest with metadata and integrity checksums

Everything modctl needs to restore your setup is self-contained in the bundle.
The Nexus API cache is not included; it is safe to discard and will be
repopulated automatically when needed.
The Nexus cache snapshot preserves update chain data and file metadata so the
destination machine does not need to make API calls to rebuild it. If the cache
was not present on disk at export time it is simply omitted; `modctl
check-updates` will repopulate it on the next run.

## Exporting

Expand Down
42 changes: 36 additions & 6 deletions docs/07_internals/04_exports.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,18 @@ A bundle contains the following files:
```
manifest.json
modctl.db
nexus_cache.db
archives/<xx>/<sha256>
backups/<xx>/<sha256>
overrides/<xx>/<sha256>
```

`manifest.json` contains metadata about the bundle. `modctl.db` is a snapshot
of the modctl database at the time of export. The `archives/` and `backups/`
directories mirror the layout of the local blob stores, containing only the
blobs referenced by the exported data.
of the modctl database at the time of export. `nexus_cache.db` is a snapshot
of the Nexus API cache, included when it was present on disk at export time.
The `archives/`, `backups/`, and `overrides/` directories mirror the layout of
the local blob stores, containing only the blobs referenced by the exported
data.

Note that game-scoped bundles never include a `backups/` directory. Backup
blobs describe on-disk state on the source machine and have no meaning on the
Expand All @@ -41,6 +44,9 @@ destination, so they are excluded from game-scoped exports.
- `schema_version`: the database schema version of the snapshot.
- `db_sha256`: the SHA-256 hash of `modctl.db` as it appears in the bundle,
used to verify integrity on import.
- `nexus_cache_sha256`: the SHA-256 hash of `nexus_cache.db` as it appears in
the bundle. Absent or empty if the Nexus cache was not present on disk at
export time.
- `counts`: the number of archive and backup blobs included.
- `game`: for game-scoped bundles only: the store ID, store game ID, and
display name of the exported game.
Expand All @@ -59,9 +65,33 @@ The applied profile state is intentionally cleared in game-scoped bundles
since the destination machine will have its own game installation. Operation
history is also not included in game-scoped bundles.

The applied profile state is intentionally cleared in game-scoped bundles
since the destination machine will have its own game installation. Operation
history is also not included in game-scoped bundles.
## Nexus cache snapshot

The Nexus API cache is included in bundles when it was present on disk at
export time. It contains cached mod page metadata, file info, and update chain
data, which is what modctl uses to detect available updates without making
additional API calls.

For full exports the cache snapshot is produced using `VACUUM INTO`, mirroring
the approach used for the main database.

For game-scoped exports a fresh cache database is created and populated with
only the rows relevant to the exported game's mod pages. No other games' cache
data is included.

On full import the cache database is copied into place directly. On
game-scoped import the rows are merged into the existing cache database using
`INSERT OR REPLACE`, so cache data for other games already on the destination
machine is not affected.

The `fetched_at` timestamps in the cache are preserved verbatim. The TTL logic
in `modctl mods nexus check-updates` will naturally treat stale entries as cold
and refresh them on the next run.

If `nexus_cache_sha256` is absent or empty in the manifest, the cache was not
included in the bundle. This can happen with bundles produced by older versions
of modctl, or when the cache database was not present on disk at export time.
In both cases import silently skips the cache restore step.

## Blob verification

Expand Down
3 changes: 3 additions & 0 deletions internal/exporter/exporter.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ type Options struct {
OutputPath string
// NoVerify skips rehashing blobs before export
NoVerify bool
// CacheDBPath is the full path to the nexus_cache.db
CacheDBPath string
}

const (
Expand Down Expand Up @@ -77,6 +79,7 @@ type Manifest struct {
ModctlVersion string `json:"modctl_version"`
SchemaVersion int64 `json:"schema_version"`
DBSha256 string `json:"db_sha256"`
NexusCacheSha256 string `json:"nexus_cache_sha256"`
Counts ManifestCounts `json:"counts"`
Game *ManifestGame `json:"game,omitempty"`
}
Expand Down
54 changes: 54 additions & 0 deletions internal/exporter/full.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (

"github.com/klauspost/compress/zstd"
"github.com/mfinelli/modctl/dbq"
"github.com/mfinelli/modctl/internal"
"github.com/mfinelli/modctl/internal/blobstore"
)

Expand Down Expand Up @@ -97,6 +98,15 @@ func Full(
}
defer os.Remove(dbPath)

// 1b. Snapshot the nexus cache database
cacheDBPath, cacheSha256, err := snapshotCacheDB(ctx, opts.CacheDBPath)
if err != nil {
return fmt.Errorf("snapshot nexus cache: %w", err)
}
if cacheDBPath != "" {
defer os.Remove(cacheDBPath)
}

// 2. Get schema version
schemaVersion, err := currentSchemaVersion(ctx, db)
if err != nil {
Expand Down Expand Up @@ -125,6 +135,7 @@ func Full(
ModctlVersion: opts.ModctlVersion,
SchemaVersion: schemaVersion,
DBSha256: dbSha256,
NexusCacheSha256: cacheSha256,
Counts: ManifestCounts{
Archives: len(archiveBlobs),
Backups: len(backupBlobs),
Expand All @@ -140,6 +151,13 @@ func Full(
return fmt.Errorf("write database: %w", err)
}

// 5b. Write nexus cache snapshot if present
if cacheDBPath != "" {
if err := writeFileToTar(tw, cacheDBPath, "nexus_cache.db"); err != nil {
return fmt.Errorf("write nexus cache: %w", err)
}
}

// 6. Write archive blobs
var skipped []string
for _, b := range archiveBlobs {
Expand Down Expand Up @@ -217,3 +235,39 @@ func snapshotDB(ctx context.Context, db *sql.DB) (path string, sha256hex string,

return tmpPath, sha, nil
}

// snapshotCacheDB creates a consistent snapshot of the nexus cache database.
// Returns the path to the temp file and its sha256. If the cache DB does not
// exist yet, returns empty strings and no error.
func snapshotCacheDB(ctx context.Context, cacheDBPath string) (path string, sha256hex string, err error) {
if _, err := os.Stat(cacheDBPath); os.IsNotExist(err) {
return "", "", nil
}

tmp, err := os.CreateTemp("", "modctl-export-cache-*.sqlite")
if err != nil {
return "", "", fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmp.Name()
tmp.Close()

cacheDB, err := sql.Open("sqlite3", cacheDBPath+internal.DB_PRAGMAS)
if err != nil {
os.Remove(tmpPath)
return "", "", fmt.Errorf("open cache db: %w", err)
}
defer cacheDB.Close()

if _, err := cacheDB.ExecContext(ctx, "VACUUM INTO ?", tmpPath); err != nil {
os.Remove(tmpPath)
return "", "", fmt.Errorf("vacuum cache db: %w", err)
}

sha, err := hashFile(tmpPath)
if err != nil {
os.Remove(tmpPath)
return "", "", fmt.Errorf("hash cache snapshot: %w", err)
}

return tmpPath, sha, nil
}
Loading
Loading