// RepoBase — the repo-ops intent layer of atproto-base.
//
// atproto-base reskins keet-daemon's StateBaseV3 (the hyper-tps per-room gamestate autobase):
// the members are the N EQUAL attested devices of ONE DID, they write repo-op INTENTS into the
// shared autobase, and the linearized view IS the DID's repo record set. A lease elects the
// single active signer that materializes this view into one canonical signed atproto commit
// chain (see lib/signer.js — later slice). This file owns ONLY the repo-ops convention over the
// generic KV intent-log; membership/admission, the lease, and signing are separate modules.
//
// The substrate is INJECTED (a StateBaseV3 instance, or any KV log exposing the same async
// interface): { set(key,value), del(key), get(key)->value|null, list(prefix)->{key:value} }.
// Injection keeps this layer runtime-agnostic and unit-testable without the (Bare) StateBaseV3.
//
// KV → repo mapping: key = "<collection>/<rkey>", value = the record object. atproto collections
// are NSIDs (dot-separated, no "/") and rkeys forbid "/", so the FIRST "/" cleanly separates them.
const SEP = '/'
// A deleted record's durable marker. Kept in the record namespace (not a reserved "__" key) so it
// replicates and linearizes exactly like the record it replaces, and so a peer can distinguish
// "deleted" from "not yet replicated". See deleteRecord below.
const TOMBSTONE = { $deleted: true }
const isTombstone = (v) => !!(v && typeof v === 'object' && v.$deleted === true)
function assertSegment (name, v) {
if (typeof v !== 'string' || v.length === 0) throw new Error('RepoBase: ' + name + ' must be a non-empty string')
if (v.includes(SEP)) throw new Error('RepoBase: ' + name + ' must not contain "' + SEP + '" (got ' + JSON.stringify(v) + ')')
}
function recordKey (collection, rkey) {
assertSegment('collection', collection)
assertSegment('rkey', rkey)
return collection + SEP + rkey
}
// Split a stored key back into { collection, rkey } on the FIRST separator. Reserved keys are
// NEVER repo records: single-segment keys (e.g. __lease__) AND any key whose collection starts
// with "__" (e.g. __commit__/<rev> — the firehose log). atproto collections are NSIDs (reverse-DNS)
// so they never start with "__"; this keeps the shared StateBase's control/firehose data out of
// the signed repo (it must never be materialized into a commit).
function splitKey (key) {
const i = key.indexOf(SEP)
if (i < 0) return null
const collection = key.slice(0, i)
if (collection.startsWith('__')) return null // reserved namespace (control/firehose), not a record
return { collection, rkey: key.slice(i + 1) }
}
export class RepoBase {
// statebase: an async KV intent-log (StateBaseV3 or compatible). did: the repo's did:plc.
constructor (statebase, { did = null } = {}) {
if (!statebase || typeof statebase.set !== 'function' || typeof statebase.list !== 'function') {
throw new Error('RepoBase: a statebase with async set/del/get/list is required')
}
this.sb = statebase
this.did = did
}
// ── repo-op intents (create/put/delete a record) ──────────────
// create + put are the same intent at this layer (last-writer-wins on the key), matching the
// KV log; the atproto create-vs-update distinction is enforced when the signer materializes.
async putRecord (collection, rkey, record) {
if (record == null || typeof record !== 'object') throw new Error('RepoBase.putRecord: record must be an object')
await this.sb.set(recordKey(collection, rkey), record)
return { collection, rkey }
}
// Deletion writes an explicit TOMBSTONE rather than removing the key. Absence of a pointer is
// AMBIGUOUS in a replicated log — "deleted" and "never replicated to me" look identical — and the
// signer's forward-only gate has to tell them apart: reading absence as "deleted" makes a device
// whose view is merely lagging publish a delete for a record that is actually live (a content
// regression to the relay; proven offline in the 0d probe). A tombstone makes deletion POSITIVE
// evidence, so absence can safely mean "I don't know yet" and the gate fails safe.
// The tombstone is a record-layer convention: reads below treat it as absent, so every consumer
// (listRecords/snapshot/getRecord) sees a deleted record as gone, exactly as before.
async deleteRecord (collection, rkey) {
await this.sb.set(recordKey(collection, rkey), { ...TOMBSTONE })
return { collection, rkey }
}
// ── reads over the linearized view ────────────────────────────
async getRecord (collection, rkey) {
const v = await this.sb.get(recordKey(collection, rkey))
return (v == null || isTombstone(v)) ? null : v // a tombstoned record reads as absent
}
// List the repo's records (optionally scoped to one collection) as [{collection, rkey, record}],
// skipping any reserved/non-record keys the underlying StateBase may hold (e.g. __members__).
//
// { now:true } (Phase-2 #6) reads the non-blocking autoUpdate snapshot (sb.listNow) instead of the
// blocking sb.list()→base.update(), so liveness-critical callers (device.state()/get-state) never
// hang on an un-converged replica. Falls back to list() if the substrate has no listNow.
async listRecords (collection = null, { now = false } = {}) {
const prefix = collection ? (assertSegment('collection', collection), collection + SEP) : ''
const map = (now && typeof this.sb.listNow === 'function') ? await this.sb.listNow(prefix) : await this.sb.list(prefix)
const out = []
for (const key of Object.keys(map)) {
const split = splitKey(key)
if (!split) continue
if (isTombstone(map[key])) continue // deleted — present as a tombstone, absent as a record
out.push({ collection: split.collection, rkey: split.rkey, record: map[key] })
}
return out
}
// The record set as a nested {collection: {rkey: record}} — the shape the signer walks to
// rebuild the MST and materialize the canonical commit.
async snapshot () {
const records = await this.listRecords()
const byCollection = {}
for (const { collection, rkey, record } of records) {
;(byCollection[collection] || (byCollection[collection] = {}))[rkey] = record
}
return byCollection
}
}
export { recordKey, splitKey, TOMBSTONE, isTombstone }