| … | … | @@ -16,6 +16,12 @@ |
| 16 | 16 | |
| 17 | 17 | const SEP = '/' |
| 18 | 18 | |
| 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. |
| 22 | const TOMBSTONE = { $deleted: true } |
| 23 | const isTombstone = (v) => !!(v && typeof v === 'object' && v.$deleted === true) |
| 24 | |
| 19 | 25 | function assertSegment (name, v) { |
| 20 | 26 | if (typeof v !== 'string' || v.length === 0) throw new Error('RepoBase: ' + name + ' must be a non-empty string') |
| 21 | 27 | if (v.includes(SEP)) throw new Error('RepoBase: ' + name + ' must not contain "' + SEP + '" (got ' + JSON.stringify(v) + ')') |
| … | … | @@ -59,15 +65,23 @@ export class RepoBase { |
| 59 | 65 | return { collection, rkey } |
| 60 | 66 | } |
| 61 | 67 | |
| 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. |
| 62 | 76 | async deleteRecord (collection, rkey) { |
| 63 | | await this.sb.del(recordKey(collection, rkey)) |
| 77 | await this.sb.set(recordKey(collection, rkey), { ...TOMBSTONE }) |
| 64 | 78 | return { collection, rkey } |
| 65 | 79 | } |
| 66 | 80 | |
| 67 | 81 | // ── reads over the linearized view ──────────────────────────── |
| 68 | 82 | async getRecord (collection, rkey) { |
| 69 | 83 | 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 |
| 71 | 85 | } |
| 72 | 86 | |
| 73 | 87 | // List the repo's records (optionally scoped to one collection) as [{collection, rkey, record}], |
| … | … | @@ -83,6 +97,7 @@ export class RepoBase { |
| 83 | 97 | for (const key of Object.keys(map)) { |
| 84 | 98 | const split = splitKey(key) |
| 85 | 99 | if (!split) continue |
| 100 | if (isTombstone(map[key])) continue // deleted — present as a tombstone, absent as a record |
| 86 | 101 | out.push({ collection: split.collection, rkey: split.rkey, record: map[key] }) |
| 87 | 102 | } |
| 88 | 103 | return out |
| … | … | @@ -100,4 +115,4 @@ export class RepoBase { |
| 100 | 115 | } |
| 101 | 116 | } |
| 102 | 117 | |
| 103 | | export { recordKey, splitKey } |
| 118 | export { recordKey, splitKey, TOMBSTONE, isTombstone } |