OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room / lib / repo-base.js

119 lines · javascript ref:
// 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 }