From ca90ba21aa45a5a29f2594f9d830a016fdcdb8d5 Mon Sep 17 00:00:00 2001 From: Martin Bertschler Date: Sun, 21 Jun 2026 02:03:51 +0200 Subject: [PATCH] =?UTF-8?q?store:=20guard=20v13=E2=86=92v14=20backfill=20a?= =?UTF-8?q?gainst=20same-hash-different-size,=20broaden=20migration=20corp?= =?UTF-8?q?us?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12b: refuse the v13→v14 contents seed when two files rows share a blake3 with differing size_bytes — corruption (or a stat/hash TOCTOU) that the seed would otherwise silently coalesce to the earliest observation's size. A BLAKE3 digest is over the bytes, so this shape is impossible from honest indexing; turning it into a loud pre-migration failure lets the operator recover from the pre-migration snapshot. The existing v2→v3 fixture seeded one hash at two differing sizes for convenience; give the two distinct files distinct hashes so the data is physically valid. 12c: add migration-corpus fixtures for previously untested legacy shapes — same-hash-different-size refusal (with a same-size negative control), an orphaned files→runs FK caught by the v13→v14 foreign_key_check, a duplicate live row caught when the rebuild recreates uniq_files_live_per_path, a populated v16 remote_objects table driven through the v16→v17 rebuild, and status_changed_run_id backfill assertions on the migrated v13 rows. 12d: add an index test pinning hashFile's stat-after-hash contract (size pairs with the hashed bytes) and one showing an append between index runs supersedes to a row whose size matches its hash. Refs #113 --- index/index_test.go | 72 +++++++++ store/migrations.go | 33 +++++ store/store_test.go | 351 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 453 insertions(+), 3 deletions(-) diff --git a/index/index_test.go b/index/index_test.go index e409af5..28e59d1 100644 --- a/index/index_test.go +++ b/index/index_test.go @@ -666,6 +666,78 @@ func TestReindexModifiedFilePreservesOldHash(t *testing.T) { } } +// TestHashFileSizePairsWithDigest pins the stat-after-hash contract: the +// size hashFile returns is the count of bytes that produced the digest, read +// from the open handle's fstat rather than a path stat that could observe a +// different inode state. A regression to a pre-hash path stat would let the +// digest and the size describe different bytes; here they must agree. +func TestHashFileSizePairsWithDigest(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "doc.txt") + content := "the quick brown fox" + writeFile(t, path, content) + + got, err := hashFile(path, make([]byte, hashReadBufferSize)) + if err != nil { + t.Fatalf("hashFile: %v", err) + } + if got.sizeBytes != int64(len(content)) { + t.Fatalf("sizeBytes = %d, want %d (size of the hashed bytes)", got.sizeBytes, len(content)) + } + if !bytes.Equal(got.digest, blake3Of(t, content)) { + t.Fatalf("digest does not match BLAKE3 of the %d hashed bytes", len(content)) + } +} + +// TestReindexAfterAppendSupersedesToConsistentRow exercises the residual the +// stat-after-hash fix documents: a file that grows between index runs cannot +// leave a row whose size disagrees with its hash. The grown file mints a new +// (digest, size) pair that supersedes the prior row, and each live row's size +// matches the content its hash covers. +func TestReindexAfterAppendSupersedesToConsistentRow(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "doc.txt") + writeFile(t, path, "abc") + + s := setupStore(t) + ctx := context.Background() + if _, err := Index(ctx, s, root, Options{}); err != nil { + t.Fatalf("first Index: %v", err) + } + absRoot, _ := filepath.Abs(root) + vol := volumeFor(t, s, absRoot) + first, err := s.GetByPath(ctx, vol.ID, "doc.txt") + if err != nil { + t.Fatalf("GetByPath after first: %v", err) + } + if first.SizeBytes != 3 || !bytes.Equal(first.Blake3, blake3Of(t, "abc")) { + t.Fatalf("first row = (size=%d), want size=3 matching BLAKE3(abc)", first.SizeBytes) + } + + writeFile(t, path, "abcdef") + if _, err := Index(ctx, s, root, Options{}); err != nil { + t.Fatalf("second Index: %v", err) + } + + history, err := s.ListHistoryByPath(ctx, vol.ID, "doc.txt") + if err != nil { + t.Fatalf("ListHistoryByPath: %v", err) + } + if len(history) != 2 { + t.Fatalf("history = %d rows, want 2", len(history)) + } + for _, row := range history { + want := blake3Of(t, "abc") + wantSize := int64(3) + if row.Status == store.StatusPresent { + want, wantSize = blake3Of(t, "abcdef"), 6 + } + if row.SizeBytes != wantSize || !bytes.Equal(row.Blake3, want) { + t.Fatalf("row (status=%s) size=%d does not match its hash", row.Status, row.SizeBytes) + } + } +} + func TestDryRunDoesNotRecordRun(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "a.txt"), "hello") diff --git a/store/migrations.go b/store/migrations.go index ce1f49a..82316d4 100644 --- a/store/migrations.go +++ b/store/migrations.go @@ -1442,7 +1442,17 @@ func migrateV13ToV14(ctx context.Context, db *sql.DB) error { // row per distinct blake3 in the old files table. The seed row per hash // is chosen by (first_seen_run_id, rowid) ascending so the backfill is // deterministic when several rows share the earliest run. +// +// The size guard runs first: one contents row per blake3 can carry one +// size, so two v13 rows sharing a hash with differing sizes (reachable +// only via prior corruption or an indexer stat/hash TOCTOU) would force +// the seed to silently keep the earliest observation's size and discard +// the rest. Refusing turns that into a loud pre-migration failure the +// operator can investigate against the pre-migration snapshot. func createAndSeedContentsV14(ctx context.Context, tx *sql.Tx) error { + if err := refuseSameHashDifferentSizeV14(ctx, tx); err != nil { + return err + } stmts := []string{ // origin_run_id is in the origin node's run space (NULL together // with origin_node_id means "introduced locally"), so it is @@ -1476,6 +1486,29 @@ func createAndSeedContentsV14(ctx context.Context, tx *sql.Tx) error { return nil } +// refuseSameHashDifferentSizeV14 aborts the migration if any blake3 in the +// old files table appears with more than one size_bytes. A BLAKE3 digest is +// over the bytes, so differing sizes for one hash is impossible from honest +// indexing — it signals prior corruption or a stat/hash TOCTOU. Coalescing +// it to one size would erase the disagreement instead of surfacing it. +func refuseSameHashDifferentSizeV14(ctx context.Context, tx *sql.Tx) error { + var conflicts int + if err := tx.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM ( + SELECT blake3 FROM files + GROUP BY blake3 + HAVING COUNT(DISTINCT size_bytes) > 1 + )`).Scan(&conflicts); err != nil { + return fmt.Errorf("check same-hash-different-size: %w", err) + } + if conflicts > 0 { + return fmt.Errorf("refusing v13→v14: %d blake3 hash(es) in files carry differing size_bytes; "+ + "the index is corrupt and one contents row cannot represent both sizes — "+ + "restore from the pre-migration snapshot and re-index", conflicts) + } + return nil +} + // rebuildFilesV14 stages the reshaped files table, copies every old row // with its blake3 resolved to the freshly seeded contents id, and swaps // the new table into place. blake3↔content_id is one-to-one, so the PK diff --git a/store/store_test.go b/store/store_test.go index c765b37..6eefdd0 100644 --- a/store/store_test.go +++ b/store/store_test.go @@ -757,12 +757,14 @@ func TestMigrateV2ToV3(t *testing.T) { t.Fatalf("v2 DDL %q: %v", q, err) } } - d := digest(0x77) + // Distinct content gets distinct hashes: a BLAKE3 digest is over the + // bytes, so two files of different size cannot share one hash (and the + // v13→v14 guard rejects a DB where they do). if _, err := rawDB.Exec( `INSERT INTO files (volume_id, path, blake3, size_bytes, mtime_ns, status, last_seen_at_ns, indexed_at_ns) VALUES (?, ?, ?, ?, ?, 'present', ?, ?), (?, ?, ?, ?, ?, 'present', ?, ?)`, - 1, "a.txt", d, 5, 10, 100, 100, - 2, "clip.mp4", d, 99, 20, 200, 200, + 1, "a.txt", digest(0x77), 5, 10, 100, 100, + 2, "clip.mp4", digest(0x88), 99, 20, 200, 200, ); err != nil { t.Fatalf("seed files: %v", err) } @@ -3837,6 +3839,41 @@ func TestMigrateV13ContentsSplit(t *testing.T) { assertSchemaGuardsAfterSplit(t, s) assertRunsOffloadCheck(t, s) assertDestinationStoreAfterMigration(t, s) + assertStatusChangedBackfill(t, s) +} + +// assertStatusChangedBackfill checks the v17→v18 status_changed_run_id +// backfill on the migrated legacy rows: a present row stamps its +// first_seen_run_id (re-flips to present weren't recorded pre-v18), while a +// superseded/missing row stamps its last_seen_run_id (the closest recorded +// coordinate to its status flip). No live API exposes the column, so the +// assertion reads it directly against the v13 fixture's known statuses. +func assertStatusChangedBackfill(t *testing.T, s *Store) { + t.Helper() + ctx := context.Background() + + cases := []struct { + name string + status string + want int64 + }{ + {"a.txt", "present", 1}, // first_seen=1 + {"b.txt", "present", 3}, // first_seen=3 (folder 2 'sub') + {"c.txt", "present", 2}, // live c.txt: first_seen=2 + {"c.txt", "superseded", 1}, // superseded predecessor: last_seen=1 + {"d.txt", "missing", 3}, // missing row: last_seen=3 + } + for _, c := range cases { + var got sql.NullInt64 + if err := s.db.QueryRowContext(ctx, + `SELECT status_changed_run_id FROM files WHERE name = ? AND status = ?`, + c.name, c.status).Scan(&got); err != nil { + t.Fatalf("status_changed_run_id for %s/%s: %v", c.name, c.status, err) + } + if !got.Valid || got.Int64 != c.want { + t.Fatalf("%s/%s status_changed_run_id = %+v, want %d", c.name, c.status, got, c.want) + } + } } // assertContentsBackfill checks the distinct-hash → contents mapping: @@ -4215,3 +4252,311 @@ func assertContentsTriggersAbort(t *testing.T, s *Store) { t.Fatalf("contents rows = %d after refused DELETE, want 2", count) } } + +// v13CoreDDL is the v13 table set the corrupt-shape fixtures below seed +// against. It omits the data and the uniq_files_live_per_path index so each +// fixture can choose whether to install the index — a legacy DB missing it +// is exactly the shape that lets a duplicate live row exist. +func v13CoreDDL() []string { + return []string{ + `CREATE TABLE schema_version (version INTEGER NOT NULL PRIMARY KEY)`, + `CREATE TABLE volumes (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, path TEXT NOT NULL)`, + `CREATE TABLE nodes (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, endpoint TEXT, public_key_fingerprint TEXT)`, + `CREATE TABLE runs ( + id INTEGER PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('index','sync','restore','audit')), + volume_id INTEGER REFERENCES volumes(id), + destination TEXT, + started_at_ns INTEGER NOT NULL, + ended_at_ns INTEGER, + status TEXT NOT NULL CHECK (status IN ('running','success','failed','partial')), + error TEXT, + file_count INTEGER NOT NULL DEFAULT 0, + peer_node_id INTEGER REFERENCES nodes(id), + correlated_run_id INTEGER, + shallow INTEGER CHECK (shallow IS NULL OR shallow IN (0, 1)), + CHECK ( + (kind IN ('index','audit') AND destination IS NULL) OR + (kind IN ('sync','restore') AND destination IS NOT NULL AND destination != '') + ) + )`, + `CREATE TABLE folders ( + id INTEGER PRIMARY KEY, + volume_id INTEGER NOT NULL REFERENCES volumes(id), + parent_id INTEGER REFERENCES folders(id), + path TEXT NOT NULL, + shallow_blake3 BLOB, + deep_blake3 BLOB, + last_changed_run_id INTEGER REFERENCES runs(id), + file_count INTEGER NOT NULL DEFAULT 0, + cumulative_size INTEGER NOT NULL DEFAULT 0, + UNIQUE (volume_id, path) + )`, + `CREATE TABLE files ( + folder_id INTEGER NOT NULL REFERENCES folders(id), + name TEXT NOT NULL, + blake3 BLOB NOT NULL CHECK (length(blake3) = 32), + size_bytes INTEGER NOT NULL, + mtime_ns INTEGER NOT NULL, + status TEXT NOT NULL CHECK (status IN ('present','missing','superseded')), + first_seen_run_id INTEGER NOT NULL REFERENCES runs(id), + last_seen_run_id INTEGER NOT NULL REFERENCES runs(id), + indexed_at_ns INTEGER NOT NULL, + source_node_id INTEGER REFERENCES nodes(id), + source_run_id INTEGER REFERENCES runs(id), + PRIMARY KEY (folder_id, name, blake3) + )`, + `INSERT INTO schema_version (version) VALUES (13)`, + `INSERT INTO volumes (id, name, path) VALUES (1, 'photos', '/photos')`, + `INSERT INTO nodes (id, name) VALUES (1, 'self')`, + `INSERT INTO runs (id, kind, volume_id, destination, started_at_ns, status) + VALUES (1, 'index', 1, NULL, 100, 'success')`, + `INSERT INTO folders (id, volume_id, parent_id, path) VALUES (1, 1, NULL, '')`, + } +} + +// migrateRawFixture writes the fixture DDL to a fresh DB through a raw +// connection (so FK enforcement stays off and pathological rows are +// insertable), then opens it through the real migration path and returns the +// Open error for the caller to assert on. +func migrateRawFixture(t *testing.T, ddl []string) error { + t.Helper() + dsn := filepath.Join(t.TempDir(), "test.db") + rawDB, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatalf("raw sql.Open: %v", err) + } + for _, q := range ddl { + if _, err := rawDB.Exec(q); err != nil { + rawDB.Close() + t.Fatalf("fixture DDL %q: %v", q, err) + } + } + rawDB.Close() + + s, err := OpenWithOptions(dsn, OpenOptions{NodeName: "self", DisablePreMigrationBackup: true}) + if err == nil { + s.Close() + } + return err +} + +// TestMigrateV13SameHashDifferentSizeRefused asserts the v13→v14 backfill +// guard: two v13 files rows sharing a blake3 with differing size_bytes (only +// reachable via prior corruption or a stat/hash TOCTOU) make the migration +// refuse loudly instead of silently coalescing to the earliest size. +func TestMigrateV13SameHashDifferentSizeRefused(t *testing.T) { + ddl := append(v13CoreDDL(), + `INSERT INTO files (folder_id, name, blake3, size_bytes, mtime_ns, status, first_seen_run_id, last_seen_run_id, indexed_at_ns) VALUES + (1, 'a.txt', X'`+strings.Repeat("11", 32)+`', 10, 1, 'present', 1, 1, 1), + (1, 'b.txt', X'`+strings.Repeat("11", 32)+`', 20, 2, 'present', 1, 1, 2)`, + ) + err := migrateRawFixture(t, ddl) + if err == nil { + t.Fatal("migration accepted same-hash-different-size files, want refusal") + } + if !strings.Contains(err.Error(), "differing size_bytes") { + t.Fatalf("error = %v, want same-hash-different-size refusal", err) + } +} + +// TestMigrateV13SameHashSameSizeAccepted is the negative control for the +// guard: the same hash at two paths with the *same* size is valid (a +// duplicate file) and must migrate cleanly into one contents row. +func TestMigrateV13SameHashSameSizeAccepted(t *testing.T) { + ddl := append(v13CoreDDL(), + `INSERT INTO files (folder_id, name, blake3, size_bytes, mtime_ns, status, first_seen_run_id, last_seen_run_id, indexed_at_ns) VALUES + (1, 'a.txt', X'`+strings.Repeat("11", 32)+`', 10, 1, 'present', 1, 1, 1), + (1, 'b.txt', X'`+strings.Repeat("11", 32)+`', 10, 2, 'present', 1, 1, 2)`, + ) + if err := migrateRawFixture(t, ddl); err != nil { + t.Fatalf("migration refused a valid same-hash-same-size duplicate: %v", err) + } +} + +// TestMigrateV13OrphanedFKRolledBack asserts the v13→v14 rebuild's +// foreign_key_check catches a files row referencing a non-existent run and +// rolls the migration back loudly rather than landing a dangling reference. +func TestMigrateV13OrphanedFKRolledBack(t *testing.T) { + ddl := append(v13CoreDDL(), + `INSERT INTO files (folder_id, name, blake3, size_bytes, mtime_ns, status, first_seen_run_id, last_seen_run_id, indexed_at_ns) VALUES + (1, 'a.txt', X'`+strings.Repeat("11", 32)+`', 10, 1, 'present', 1, 999, 1)`, + ) + err := migrateRawFixture(t, ddl) + if err == nil { + t.Fatal("migration accepted an orphaned files→runs FK, want rollback") + } + if !strings.Contains(err.Error(), "dangling FK") { + t.Fatalf("error = %v, want dangling FK refusal", err) + } +} + +// TestMigrateV13DuplicateLiveRowRolledBack asserts that a legacy DB missing +// uniq_files_live_per_path with two live rows at one path fails loudly when +// the v13→v14 rebuild recreates the partial unique index, rather than +// migrating two live rows into the reshaped table. +func TestMigrateV13DuplicateLiveRowRolledBack(t *testing.T) { + ddl := append(v13CoreDDL(), + `INSERT INTO files (folder_id, name, blake3, size_bytes, mtime_ns, status, first_seen_run_id, last_seen_run_id, indexed_at_ns) VALUES + (1, 'a.txt', X'`+strings.Repeat("11", 32)+`', 10, 1, 'present', 1, 1, 1), + (1, 'a.txt', X'`+strings.Repeat("22", 32)+`', 20, 2, 'present', 1, 1, 2)`, + ) + err := migrateRawFixture(t, ddl) + if err == nil { + t.Fatal("migration accepted two live rows at one path, want rollback") + } + if !strings.Contains(err.Error(), "UNIQUE") { + t.Fatalf("error = %v, want UNIQUE violation on uniq_files_live_per_path", err) + } +} + +// v16Fixture returns a populated v16 database whose remote_objects table +// carries the pre-v17 NOT NULL (checksum_algo, checksum) shape. It exists so +// the v16→v17 rebuild — which actually rewrites remote_objects to relax those +// columns to nullable — is exercised against real rows; the v18 fixture seeds +// remote_objects but starts after that rebuild. +func v16Fixture() []string { + return []string{ + `CREATE TABLE schema_version (version INTEGER NOT NULL PRIMARY KEY)`, + `CREATE TABLE volumes (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, path TEXT NOT NULL)`, + `CREATE TABLE nodes (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, endpoint TEXT, public_key_fingerprint TEXT)`, + `CREATE TABLE runs ( + id INTEGER PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('index','sync','restore','audit','offload')), + volume_id INTEGER REFERENCES volumes(id), + destination TEXT, + started_at_ns INTEGER NOT NULL, + ended_at_ns INTEGER, + status TEXT NOT NULL CHECK (status IN ('running','success','failed','partial')), + error TEXT, + file_count INTEGER NOT NULL DEFAULT 0, + peer_node_id INTEGER REFERENCES nodes(id), + correlated_run_id INTEGER, + shallow INTEGER CHECK (shallow IS NULL OR shallow IN (0, 1)), + CHECK ( + (kind IN ('index','audit','offload') AND destination IS NULL) OR + (kind IN ('sync','restore') AND destination IS NOT NULL AND destination != '') + ) + )`, + `CREATE TABLE folders ( + id INTEGER PRIMARY KEY, + volume_id INTEGER NOT NULL REFERENCES volumes(id), + parent_id INTEGER REFERENCES folders(id), + path TEXT NOT NULL, + shallow_blake3 BLOB, + deep_blake3 BLOB, + last_changed_run_id INTEGER REFERENCES runs(id), + file_count INTEGER NOT NULL DEFAULT 0, + cumulative_size INTEGER NOT NULL DEFAULT 0, + UNIQUE (volume_id, path) + )`, + `CREATE TABLE contents ( + id INTEGER PRIMARY KEY, + blake3 BLOB NOT NULL UNIQUE CHECK (length(blake3) = 32), + size_bytes INTEGER NOT NULL, + origin_node_id INTEGER REFERENCES nodes(id), + origin_run_id INTEGER + )`, + `CREATE TABLE files ( + folder_id INTEGER NOT NULL REFERENCES folders(id), + name TEXT NOT NULL, + content_id INTEGER NOT NULL REFERENCES contents(id), + mtime_ns INTEGER NOT NULL, + status TEXT NOT NULL CHECK (status IN ('present','missing','superseded','offloaded')), + first_seen_run_id INTEGER NOT NULL REFERENCES runs(id), + last_seen_run_id INTEGER NOT NULL REFERENCES runs(id), + indexed_at_ns INTEGER NOT NULL, + PRIMARY KEY (folder_id, name, content_id) + )`, + `CREATE UNIQUE INDEX uniq_files_live_per_path ON files(folder_id, name) WHERE status != 'superseded'`, + `CREATE TABLE destination_run_ids ( + volume_id INTEGER NOT NULL REFERENCES volumes(id), + destination TEXT NOT NULL, + origin_node_id INTEGER NOT NULL REFERENCES nodes(id), + origin_run_id INTEGER NOT NULL, + updated_at_ns INTEGER NOT NULL, + PRIMARY KEY (volume_id, destination, origin_node_id) + )`, + `CREATE TABLE destination_run_ids_history ( + id INTEGER PRIMARY KEY, + volume_id INTEGER NOT NULL, + destination TEXT NOT NULL, + origin_node_id INTEGER NOT NULL, + origin_run_id INTEGER NOT NULL, + at_ns INTEGER NOT NULL + )`, + `CREATE TABLE remote_objects ( + content_id INTEGER NOT NULL REFERENCES contents(id), + destination TEXT NOT NULL, + uploaded_run_id INTEGER NOT NULL REFERENCES runs(id), + checksum_algo TEXT NOT NULL, + checksum TEXT NOT NULL, + verified_at_ns INTEGER, + PRIMARY KEY (content_id, destination) + )`, + `INSERT INTO schema_version (version) VALUES (16)`, + `INSERT INTO volumes (id, name, path) VALUES (1, 'photos', '/photos')`, + `INSERT INTO nodes (id, name) VALUES (1, 'self')`, + `INSERT INTO runs (id, kind, volume_id, destination, started_at_ns, status) + VALUES (1, 'index', 1, NULL, 100, 'success'), + (2, 'sync', 1, 'bucket', 200, 'success')`, + `INSERT INTO folders (id, volume_id, parent_id, path) VALUES (1, 1, NULL, '')`, + `INSERT INTO contents (id, blake3, size_bytes) VALUES + (1, X'` + strings.Repeat("11", 32) + `', 10), + (2, X'` + strings.Repeat("22", 32) + `', 20)`, + `INSERT INTO files (folder_id, name, content_id, mtime_ns, status, first_seen_run_id, last_seen_run_id, indexed_at_ns) VALUES + (1, 'a.txt', 1, 1, 'present', 1, 1, 1), + (1, 'b.txt', 2, 2, 'present', 1, 1, 2)`, + `INSERT INTO remote_objects (content_id, destination, uploaded_run_id, checksum_algo, checksum, verified_at_ns) VALUES + (1, 'bucket', 2, 'blake3', 'deadbeef', 150), + (2, 'bucket', 2, 'blake3', 'cafebabe', 150)`, + } +} + +// TestMigrateV16RemoteObjectsRebuild drives a populated v16 database through +// the v17 remote_objects rebuild and confirms the rows survive the table +// rewrite with their fingerprints intact, and that the relaxed v17 shape now +// accepts an upload record with a pending (NULL,NULL) fingerprint. +func TestMigrateV16RemoteObjectsRebuild(t *testing.T) { + dsn := filepath.Join(t.TempDir(), "test.db") + rawDB, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatalf("raw sql.Open: %v", err) + } + for _, q := range v16Fixture() { + if _, err := rawDB.Exec(q); err != nil { + rawDB.Close() + t.Fatalf("v16 DDL %q: %v", q, err) + } + } + rawDB.Close() + + s, err := OpenWithOptions(dsn, OpenOptions{NodeName: "self"}) + if err != nil { + t.Fatalf("Open (migrates v16→v%d): %v", SchemaVersion, err) + } + defer s.Close() + ctx := context.Background() + + var rows int + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM remote_objects`).Scan(&rows); err != nil { + t.Fatalf("count remote_objects: %v", err) + } + if rows != 2 { + t.Fatalf("remote_objects rows = %d after rebuild, want 2 (none lost)", rows) + } + var algo, checksum string + if err := s.db.QueryRowContext(ctx, + `SELECT checksum_algo, checksum FROM remote_objects WHERE content_id = 1 AND destination = 'bucket'`). + Scan(&algo, &checksum); err != nil { + t.Fatalf("remote_objects row: %v", err) + } + if algo != "blake3" || checksum != "deadbeef" { + t.Fatalf("fingerprint = (%q,%q), want (blake3,deadbeef)", algo, checksum) + } + if _, err := s.db.ExecContext(ctx, + `INSERT INTO remote_objects (content_id, destination, uploaded_run_id, checksum_algo, checksum) + VALUES (2, 'bucket2', 2, NULL, NULL)`); err != nil { + t.Fatalf("v17 relaxed shape rejected a pending-fingerprint upload: %v", err) + } +}