OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-store commits / 15f8f2b

PointerSubstrate.backfill(): files-only → pointer migration (0.0.8)
Step 2a migration safety. A repo written by the previous files-only backend (OverlayRepoSubstrate) has record BYTES on disk but NO pointers in the shared autobase — so under PointerSubstrate listRecords() reads an empty set. backfill() writes a {$oid,size} pointer per un-pointed record file so the converged set reflects the real records before any sign() runs (closes the wj62-wipe hazard). Idempotent + convergence-safe: skips any key that already has a pointer (a peer's newer put or a delete tombstone — never resurrect/clobber), never deletes. Writable caller only. Proof: test/pointer-substrate-backfill.test.js (11 asserts: per-file pointer, idempotency, no-clobber-converged, skip-reserved). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
15f8f2bfa806bbdb55ed2df1f20435ec7216f7e8
by plankatron <fleeky@gmail.com> · 2026-07-20T19:19:05.000Z

Changed files (3) +131 -2

lib/pointer-substrate.js +33 -0
……@@ -84,4 +84,37 @@ export class PointerSubstrate {
8484 }
8585 return out
8686 }
87
88 // ── Step 2a MIGRATION: files-only → pointers (idempotent) ──────────────────────────────────────
89 // A repo previously written by OverlayRepoSubstrate has its record BYTES on disk as overlay files
90 // but NO pointers in the shared autobase. Under this substrate, listRecords() reads the pointer set
91 // → ZERO, so the first sign() would materialize an EMPTY repo and wipe every record off the relay
92 // (the wj62-wipe hazard). backfill() closes that gap: for every record file that has no pointer yet,
93 // it writes the tiny {$oid,size} pointer into the autobase — so the converged pointer set reflects
94 // the real record set before any sign runs.
95 //
96 // Idempotent + convergence-safe: SKIPS any key that already has a pointer (a peer may have converged
97 // a newer put OR a delete tombstone — never resurrect/clobber it), and never deletes. Re-registering
98 // the byte-identical file via store.put is harmless (the determinism contract: same bytes → same oid)
99 // and yields the oid without trusting stale overlay index state. WRITABLE-ONLY: statebase.set appends
100 // to the autobase, so only an admitted writer (the creator) may run this — the caller gates on
101 // base.writable. Returns { written, skipped, total }.
102 async backfill () {
103 if (typeof this.store.records !== 'function') {
104 throw new Error('PointerSubstrate.backfill: the store must enumerate records() to migrate files → pointers')
105 }
106 let written = 0
107 let skipped = 0
108 const files = await this.store.records() // [{ key, bytes, record }] — the on-disk record files
109 for (const { key, record } of files) {
110 const s = recSplit(key)
111 if (!s) continue // never migrate a reserved/control key into the record pointer set
112 const existing = await this.statebase.get(key)
113 if (existing != null) { skipped++; continue } // pointer already converged (put or tombstone) — leave it
114 const { oid, size } = await this.store.put(s.collection, s.rkey, record)
115 await this.statebase.set(key, { $oid: oid, size })
116 written++
117 }
118 return { written, skipped, total: files.length }
119 }
87120}
package.json +2 -2
……@@ -1,7 +1,7 @@
11{
22 "name": "atproto-store",
3 "version": "0.0.7",
4 "description": "Holepunch-native atproto repo store \u2014 records in a classic Hyperbee (collection/rkey -> DAG-CBOR). The P2P home; the atproto-repo module is the bridge serializer over it.",
3 "version": "0.0.8",
4 "description": "Holepunch-native atproto repo store — records in a classic Hyperbee (collection/rkey -> DAG-CBOR). The P2P home; the atproto-repo module is the bridge serializer over it.",
55 "type": "module",
66 "main": "lib/repo-store.js",
77 "scripts": {
test/pointer-substrate-backfill.test.js +96 -0
……@@ -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
12import test from 'brittle'
13import { mkdtempSync, rmSync } from 'node:fs'
14import { join } from 'node:path'
15import { tmpdir } from 'node:os'
16import { OverlayRepoStore } from '../lib/overlay-repo-store.js'
17import { PointerSubstrate } from '../lib/pointer-substrate.js'
18
19const 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.
22function 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
33const post = (text) => ({ $type: 'app.bsky.feed.post', text, createdAt: '2026-07-19T00:00:00.000Z' })
34
35test('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
59test('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
70test('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
86test('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})