OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room commits / 389ae16

repo-base: delete writes a TOMBSTONE instead of removing the key
Absence is ambiguous in a replicated log — "deleted" and "not replicated to me yet" look identical — and the signer's forward-only gate has to tell them apart. Reading absence as deletion lets a device whose view is merely lagging publish a delete for a record that is actually live: a content regression straight to the relay. Proven offline before this change: two co-owners, election removed, B lagging on one record it had never replicated -> B emitted delete:app.bsky.feed.post/3c and the log forked to three frames. With the tombstone, B skips ("pointer not replicated"). deleteRecord now sets { $deleted:true }; getRecord/listRecords treat a tombstone as absent, so every consumer sees a deleted record as gone exactly as before. The marker lives in the record namespace (not a reserved __ key) so it replicates and linearizes like the record it replaces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
389ae16c6a6fa772583116cc46df9b6973068648
by plankatron <fleeky@gmail.com> · 2026-07-24T22:59:45.000Z

Changed files (1) +18 -3

lib/repo-base.js +18 -3
……@@ -16,6 +16,12 @@
1616
1717const SEP = '/'
1818
19// A deleted record's durable marker. Kept in the record namespace (not a reserved "__" key) so it
20// replicates and linearizes exactly like the record it replaces, and so a peer can distinguish
21// "deleted" from "not yet replicated". See deleteRecord below.
22const TOMBSTONE = { $deleted: true }
23const isTombstone = (v) => !!(v && typeof v === 'object' && v.$deleted === true)
24
1925function assertSegment (name, v) {
2026 if (typeof v !== 'string' || v.length === 0) throw new Error('RepoBase: ' + name + ' must be a non-empty string')
2127 if (v.includes(SEP)) throw new Error('RepoBase: ' + name + ' must not contain "' + SEP + '" (got ' + JSON.stringify(v) + ')')
……@@ -59,15 +65,23 @@ export class RepoBase {
5965 return { collection, rkey }
6066 }
6167
68 // Deletion writes an explicit TOMBSTONE rather than removing the key. Absence of a pointer is
69 // AMBIGUOUS in a replicated log — "deleted" and "never replicated to me" look identical — and the
70 // signer's forward-only gate has to tell them apart: reading absence as "deleted" makes a device
71 // whose view is merely lagging publish a delete for a record that is actually live (a content
72 // regression to the relay; proven offline in the 0d probe). A tombstone makes deletion POSITIVE
73 // evidence, so absence can safely mean "I don't know yet" and the gate fails safe.
74 // The tombstone is a record-layer convention: reads below treat it as absent, so every consumer
75 // (listRecords/snapshot/getRecord) sees a deleted record as gone, exactly as before.
6276 async deleteRecord (collection, rkey) {
63 await this.sb.del(recordKey(collection, rkey))
77 await this.sb.set(recordKey(collection, rkey), { ...TOMBSTONE })
6478 return { collection, rkey }
6579 }
6680
6781 // ── reads over the linearized view ────────────────────────────
6882 async getRecord (collection, rkey) {
6983 const v = await this.sb.get(recordKey(collection, rkey))
70 return v == null ? null : v
84 return (v == null || isTombstone(v)) ? null : v // a tombstoned record reads as absent
7185 }
7286
7387 // List the repo's records (optionally scoped to one collection) as [{collection, rkey, record}],
……@@ -83,6 +97,7 @@ export class RepoBase {
8397 for (const key of Object.keys(map)) {
8498 const split = splitKey(key)
8599 if (!split) continue
100 if (isTombstone(map[key])) continue // deleted — present as a tombstone, absent as a record
86101 out.push({ collection: split.collection, rkey: split.rkey, record: map[key] })
87102 }
88103 return out
……@@ -100,4 +115,4 @@ export class RepoBase {
100115 }
101116}
102117
103export { recordKey, splitKey }
118export { recordKey, splitKey, TOMBSTONE, isTombstone }