OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / services / schema-guard.js

75 lines · javascript ref:
/**
 * In-band schema-version guard (#52 Phase 4.4 hardening) — Keet's pattern.
 *
 * Keet stamps the schema/ABI version IN the replicated oplog and a reader that
 * sees ops newer than it supports INTERRUPTs (unsupportedABI) rather than
 * mis-reading/serving stale data. Our schema version was a per-store FILE
 * (schema-version.json) — purely local, so an under-versioned MIRROR had no
 * in-band signal that the origin moved to a newer schema and would keep
 * serving stale data. This puts the version in the replicated OrgEventLog so
 * every node learns the canonical schema version by replication, and a node
 * running OLDER code than the declared version can refuse to serve.
 *
 * Wire-safe: reuses the existing OrgEventLog entry — a NEW `op` value
 * (SCHEMA_VERSION_OP) with the version embedded in the `name` string. No new
 * struct field, so old decoders read it cleanly and ignore it (op != 2 and no
 * orgBootstrapKey → OrgPuller skips it). Protects FUTURE migrations: once all
 * nodes run vN code, a later vN+1 origin's marker makes the vN nodes detect
 * they're behind. (The already-deployed pre-guard code can't check it — the
 * current node→bare cutover still relies on coordinated rollout.)
 */

export const SCHEMA_VERSION_OP = 3 // 0=created, 2=deleted, 3=schema-version marker
const PREFIX = '@schema-version:'

export function isSchemaVersionEntry (e) {
  return !!(e && e.op === SCHEMA_VERSION_OP && typeof e.name === 'string' && e.name.startsWith(PREFIX))
}

/** @returns {number|null} the version a marker entry declares, or null. */
export function parseSchemaVersionEntry (e) {
  if (!isSchemaVersionEntry(e)) return null
  const v = parseInt(e.name.slice(PREFIX.length), 10)
  return Number.isFinite(v) ? v : null
}

/**
 * Highest schema version declared in the replicated log (0 if none yet).
 * @param {OrgEventLog} orgEventLog
 */
export async function readDeclaredSchemaVersion (orgEventLog) {
  let max = 0
  for await (const e of orgEventLog.entries({ live: false, start: 0 })) {
    const v = parseSchemaVersionEntry(e)
    if (v !== null && v > max) max = v
  }
  return max
}

/**
 * Origin-only: stamp the canonical schema version into the replicated log.
 * Idempotent — no-op if the log already declares >= version. A mirror's log
 * is read-only (not writable) so this is a no-op there.
 * @returns {Promise<boolean>} true if a marker was appended.
 */
export async function announceSchemaVersion (orgEventLog, version) {
  if (!orgEventLog || !orgEventLog.writable) return false
  const current = await readDeclaredSchemaVersion(orgEventLog)
  if (current >= version) return false
  await orgEventLog.append({ op: SCHEMA_VERSION_OP, name: PREFIX + version })
  return true
}

/**
 * Is this node behind the replicated schema? `behind` ⇒ the log declares a
 * version newer than this code supports → the node must NOT serve stale data
 * (Keet's interrupt). Point-in-time: on a mirror the marker may not have
 * replicated yet — pair with the live check in OrgPuller for ops that arrive
 * while running.
 * @returns {Promise<{declared:number, supported:number, behind:boolean}>}
 */
export async function checkSchemaGuard (orgEventLog, supported) {
  const declared = await readDeclaredSchemaVersion(orgEventLog)
  return { declared, supported, behind: declared > supported }
}