| … | … | @@ -0,0 +1,96 @@ |
| 1 | // Step 2a MIGRATION proof: PointerSubstrate.backfill() converts a files-only repo (record BYTES on |
| 2 | // disk, NO pointers in the autobase) into the pointer design by writing one {$oid,size} pointer per |
| 3 | // existing record file. This is the guard that stops the wj62-wipe: without it, the first sign() on a |
| 4 | // migrated repo reads an EMPTY pointer set and materializes an empty repo. Proves: backfill writes a |
| 5 | // pointer per file; it is idempotent (a second run writes nothing); it never clobbers a pointer that |
| 6 | // already converged (a peer's newer put or a delete tombstone); it skips reserved/control keys. |
| 7 | // |
| 8 | // Uses a Map-backed mock statebase (the substrate contract is set/get/del/list) + a real |
| 9 | // OverlayRepoStore (no overlay → oid is null, which is fine here: the UNIT under test is the |
| 10 | // pointer/skip bookkeeping, not byte distribution — that is proven in the hpp integration test). |
| 11 | |
| 12 | import test from 'brittle' |
| 13 | import { mkdtempSync, rmSync } from 'node:fs' |
| 14 | import { join } from 'node:path' |
| 15 | import { tmpdir } from 'node:os' |
| 16 | import { OverlayRepoStore } from '../lib/overlay-repo-store.js' |
| 17 | import { PointerSubstrate } from '../lib/pointer-substrate.js' |
| 18 | |
| 19 | const tmp = (t) => { const d = mkdtempSync(join(tmpdir(), 'ptr-bf-')); t.teardown(() => rmSync(d, { recursive: true, force: true })); return d } |
| 20 | |
| 21 | // Minimal in-memory statebase honoring the substrate contract PointerSubstrate depends on. |
| 22 | function mockStatebase () { |
| 23 | const m = new Map() |
| 24 | return { |
| 25 | _m: m, |
| 26 | async set (k, v) { m.set(k, v) }, |
| 27 | async get (k) { return m.has(k) ? m.get(k) : null }, |
| 28 | async del (k) { m.delete(k) }, |
| 29 | async list (prefix = '') { const o = {}; for (const [k, v] of m) if (k.startsWith(prefix)) o[k] = v; return o } |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | const post = (text) => ({ $type: 'app.bsky.feed.post', text, createdAt: '2026-07-19T00:00:00.000Z' }) |
| 34 | |
| 35 | test('backfill: writes one pointer per files-only record, then listRecords sees the full set', async (t) => { |
| 36 | const store = await new OverlayRepoStore(tmp(t)).ready() |
| 37 | // Simulate a files-only (OverlayRepoSubstrate) history: bytes on disk, NOTHING in the statebase. |
| 38 | await store.put('app.bsky.feed.post', '1', post('one')) |
| 39 | await store.put('app.bsky.feed.post', '2', post('two')) |
| 40 | await store.put('app.bsky.graph.follow', 'f1', { $type: 'app.bsky.graph.follow', subject: 'did:plc:x', createdAt: post('').createdAt }) |
| 41 | |
| 42 | const sb = mockStatebase() |
| 43 | const sub = new PointerSubstrate(sb, store) |
| 44 | |
| 45 | // BEFORE: the pointer set (what sign() reads via listRecords) is EMPTY — the wipe hazard. |
| 46 | t.is(Object.keys(await sub.list()).length, 0, 'pre-migration: pointer set is empty (would wipe)') |
| 47 | |
| 48 | const r = await sub.backfill() |
| 49 | t.alike(r, { written: 3, skipped: 0, total: 3 }, 'backfill wrote a pointer for each of the 3 files') |
| 50 | |
| 51 | // AFTER: every record now has a {$oid,size} pointer in the statebase, and the set resolves byte-exact. |
| 52 | const ptr = await sb.get('app.bsky.feed.post/1') |
| 53 | t.ok(ptr && 'size' in ptr && '$oid' in ptr, 'a {$oid,size} pointer now exists in the statebase') |
| 54 | const resolved = await sub.list() |
| 55 | t.is(Object.keys(resolved).length, 3, 'listRecords now returns the full converged set (3)') |
| 56 | t.alike(resolved['app.bsky.feed.post/1'], post('one'), 'pointer resolves to the byte-exact record') |
| 57 | }) |
| 58 | |
| 59 | test('backfill: idempotent — a second run writes nothing', async (t) => { |
| 60 | const store = await new OverlayRepoStore(tmp(t)).ready() |
| 61 | await store.put('app.bsky.feed.post', '1', post('one')) |
| 62 | await store.put('app.bsky.feed.post', '2', post('two')) |
| 63 | const sb = mockStatebase() |
| 64 | const sub = new PointerSubstrate(sb, store) |
| 65 | |
| 66 | t.alike(await sub.backfill(), { written: 2, skipped: 0, total: 2 }, 'first run migrates both') |
| 67 | t.alike(await sub.backfill(), { written: 0, skipped: 2, total: 2 }, 'second run is a no-op (all skipped)') |
| 68 | }) |
| 69 | |
| 70 | test('backfill: never clobbers a pointer that already converged (peer put OR delete tombstone)', async (t) => { |
| 71 | const store = await new OverlayRepoStore(tmp(t)).ready() |
| 72 | await store.put('app.bsky.feed.post', '1', post('local-stale')) |
| 73 | await store.put('app.bsky.feed.post', '2', post('two')) |
| 74 | const sb = mockStatebase() |
| 75 | const sub = new PointerSubstrate(sb, store) |
| 76 | |
| 77 | // A peer already converged a NEWER pointer for record 1 (e.g. an edit) into the shared autobase. |
| 78 | const peerPointer = { $oid: 'peer-newer-oid', size: 999 } |
| 79 | await sb.set('app.bsky.feed.post/1', peerPointer) |
| 80 | |
| 81 | const r = await sub.backfill() |
| 82 | t.alike(r, { written: 1, skipped: 1, total: 2 }, 'only the un-pointed record is migrated; the converged one is skipped') |
| 83 | t.alike(await sb.get('app.bsky.feed.post/1'), peerPointer, 'the peer pointer is untouched (no resurrection/clobber)') |
| 84 | }) |
| 85 | |
| 86 | test('backfill: skips reserved/control keys if any leak into the store', async (t) => { |
| 87 | const store = await new OverlayRepoStore(tmp(t)).ready() |
| 88 | await store.put('app.bsky.feed.post', '1', post('one')) |
| 89 | const sb = mockStatebase() |
| 90 | const sub = new PointerSubstrate(sb, store) |
| 91 | // A reserved key can never legitimately be a record file, but assert the guard regardless: only the |
| 92 | // real record is migrated, a __-collection key is never written into the pointer set. |
| 93 | const r = await sub.backfill() |
| 94 | t.is(r.written, 1, 'only the real record was migrated') |
| 95 | t.absent(await sb.get('__prefs__'), 'no reserved key entered the pointer set') |
| 96 | }) |