clay.js
  1. ClayJS
  2. Reference
  3. API
  4. Recovery

Recovery

clay.recovery: the local copy of unsaved writing, what a receipt proves, when a save clears it, and where it is kept.

clay.recovery keeps a copy of writing the host has not accepted yet, in this browser, so a reload or a crash has something to offer. It is not a save. A completed write here means this browser kept a copy, and nothing more: the saved file stays the only saved source, and ClayJS never renders a page from these records.

What it covers, and where it lives#

  • One store for the whole page, per document and per mode, in IndexedDB: database clay-recovery, version 1, with the stores records, watermarks and meta.
  • Edit mode only, draft mode included. In view mode clay.recovery is absent and a reader never opens the database.
  • A document is covered only for the channels its adapters register. There is no whole-body observer behind this: ClayJS does not serialize the page after every edit. A page that registers no channel journals nothing.
  • The facade is a queue and a status. The IndexedDB implementation and the identity that scopes it are downloaded only when the first channel registers, so a page that journals nothing downloads neither.
  • No automatic restore. Records are never applied to the page. A page reads them after a reload, compares them with the file it actually loaded, and asks. A recovery copy is a candidate, not a newer version.
  • Not clay.storage, which keys UI state by pathname and falls back to memory silently. Recovery is the one that reports whether it is durable.

A channel#

JavaScript
const readEntry = (el) => ({
  id: el.dataset.id || "",
  date: el.dataset.date || "",
  updated: el.dataset.updated || "",
  body: el.querySelector(".entry-body")?.textContent ?? ""
});

const entries = clay.recovery.register({
  channel: "journal.entries",
  version: 1,
  readSaved(html) {
    const doc = new DOMParser().parseFromString(html, "text/html");
    return new Map([...doc.querySelectorAll("#entries > article.entry")]
      .map((el) => [el.dataset.id, readEntry(el)]));
  },
  describe(record) {
    return { label: record.payload.date || "Undated entry", text: record.payload.body };
  }
});

const staged = entries.stage({
  key: entryId,
  baseEtag,
  base: savedEntryOrNull,
  payload: currentEntryWithTextareaText,
  lineage: null
});
await entries.flush();

register returns synchronously, before anything asynchronous has happened, and the channel’s methods work from that moment: work queues in memory from the first stage.

  • channel and version name the records this adapter owns. Registering the same channel twice in one page throws. A stored record from another version is still listed, with supported: false, and is never migrated or dropped.
  • readSaved(html) receives the exact bytes of a save capture and returns a Map keyed the way stage keys its records. It is how a copy is compared with what the host accepted, and without it no save can ever clear this channel.
  • describe(record) is for review UI: a label and the text to show.
  • stage({ key, kind, baseEtag, base, payload, lineage }) returns { id, revision } immediately and schedules a write. It is not a durable receipt. kind defaults to "text"; a text record is keyed [channel, key, writerId] and keeps one latest revision per key per writer, while any other kind gets an id of its own for every record it stages.
  • A payload is copied when it is staged, and a Uint8Array is copied as bytes. Changing your buffer afterwards does not change the stored record.

Journaled, acknowledged, checkpointed#

Three states, three different proofs, and none of them is the other:

StateWhat proves it
journaledone completed IndexedDB transaction wrote this revision. This browser holds a copy and nothing has been sent anywhere.
acknowledgeda confirmed save whose own capture bytes covered this exact revision. The host has those bytes, so the copy is dropped.
checkpointedthe saved file itself is known to carry an update, established by the adapter that understands the content. The generic store does not decide this.
  • flush() resolves { ok: true, stored: [{ id, revision }] } only after the transaction completed, or { ok: false, code }. A revision that was already covered or already superseded is skipped, not stored, so it does not appear in stored.
  • Writes are debounced to 500 ms after typing stops and forced out at most five seconds after a burst started, so a fast writer is never more than that far behind.
  • Only a confirmed save acknowledges, and only what its own capture covered. A skipped save, a refusal, a conflict, an error and an unknown outcome clear nothing.
  • A covered revision cannot come back. The watermark is written alongside the delete, so a write still queued in memory is refused when it arrives after the save that covered it. A newer revision from the same writer, and every other writer’s copy, survive that acknowledgement.
  • A channel is text unless it registers for bytes. A binary channel adds append({ key, context, update, origin, base }), which journals one update and resolves { ok: true, id, revision } only after the transaction that wrote it completed, where origin is "local" or "dependency" and base carries the episode’s accepted state on its first append.
  • markAcknowledged({ records, authority, epoch, sequence }) resolves { ok: true, acknowledged } after recording that a room holds these revisions, and retires nothing, because an acknowledgement is metadata.
  • checkpoint({ records, proof }) resolves { ok: true, retired } with the revisions a proof covers, or { ok: false, code, retired } when the store could not retire them, and a channel whose registration has no validateCheckpoint retires nothing.
  • compact({ records }) resolves { ok: true, retired } with the batch records merged into their writer’s capsule, and it needs merge on the registration, because the store never decodes the bytes.
  • register takes validateCheckpoint(proof, records) and merge(parts) for a binary channel: the validator runs before the write transaction opens and may only narrow the list it was given.

baseEtag, base, payload and lineage are yours to keep with the record. A missing ETag is allowed: payload comparison is what decides coverage, and neither an ETag nor a timestamp ever makes two different payloads equal.

The page editor’s journal#

The page editor registers one binary channel, page.yjs at version 1, with validateCheckpoint and merge, and it opens the journal before the editor does.

  • Each episode pins its authority and its lineage before input starts: the host, the document id and the principal, plus the generation the host persists where it keeps one, and otherwise the digest of the accepted base. A tab that cannot pin them, and a page with no recovery service, journal nothing.
  • Every local update is appended as it happens, and the provider hands the bytes over ahead of its own stop guards, so typing still reaches this device after an offer was refused or delivery has stopped. An update received from a peer is kept beside the work it owns as a dependency while an episode is open, because a later local insert can be built on it.
  • A room acknowledgement keeps the copy. Being shared is not being saved, and markAcknowledged retires nothing.
  • Only a checkpoint proven to contain the edit removes the copy, and the proof counts deletions. A state vector cannot answer it: inserting abc and then deleting b leaves the state vector where it was while the bytes carry the deletion, so coverage compares bytes and an operation already present adds nothing.
  • The interval between a keystroke and the committed IndexedDB transaction is short and it is visible as Pending. Until a checkpoint is proven, the work is never called saved.
  • On the next boot, a journal written under the same authority, the same principal and the same lineage is replayed into the page automatically, before input starts, and offered to the room as this tab’s own work. A journal from another authority, another principal or another generation, or one whose base the page no longer contains, is not applied: the review names it and offers Recover as a copy, which inserts its text with fresh block ids and records where the copy went.
  • While a composition is active, the block’s visible text is staged on the page.ime channel as text, and the shadow is retired only when a journaled update carries that same text, so text that never reached the page is still offered as text.
  • The indicator says where the typing lives, in these labels: Saving on this device while a transaction has not committed, Stored on this device for what this browser holds, Shared, not yet saved once a room confirmed it, Saved only after a checkpoint covers it, and Can't store changes: download a copy when the device will not take bytes.

Receipts and failures#

CallResolves
stage(spec){ id, revision }, synchronously, not durable
flush(){ ok: true, stored: [{ id, revision }] } or { ok: false, code }
list(){ ok, records, status }, where a record carries supported: false for another stored version
dismiss(id, { revision }){ ok: true, id, revision }, or code: "stale" when a newer revision landed since the review was drawn, "missing" when it is gone
subscribe(fn)an unsubscribe. Each call receives { channel, status, count, revisions }, never payloads
dispose()a best-effort flush. It releases listeners and deletes nothing
clay.recovery.status(){ ok, code, durable, usage, identityMismatch }
clay.recovery.subscribe(fn)an unsubscribe, for the same detail on every channel

dismiss is compare-and-delete: the revision is what stops a review panel drawn over revision 3 from deleting revision 5, which arrived while the person was reading.

A failure is reported as a code, not thrown:

CodeMeaning
unavailableno IndexedDB, or an origin that cannot open one
blockedthe open did not finish: another tab is holding an upgrade
quotathe browser refused the write
over-budgetthis document or this database is at its own limit
unknown-schemaa database version this build does not know. The stored bytes are left alone, and list still offers them
abortedthe transaction ended early
uncloneablea payload the structured clone algorithm refuses
stale, missing, invalida compare-and-delete that lost its race, or a call without a usable key
erroranything else

durable: false is the honest answer after the database opens and before anything is written, and while storage is failing. The text stays in memory either way, so a page must say the local copy could not be updated and offer Copy and Download for what it holds. The status changes once per failure episode, and only a completed transaction puts it back to durable: true.

Storage limits#

  • 32 MiB per document scope and 64 MiB for the whole clay-recovery database on this origin, counting strings as UTF-8, binary payloads by byte length, and the record metadata.
  • An over-budget write is refused and the copy already stored is left exactly as it was. Nothing is evicted for being old or numerous: cleanup is limited to what a save covered or a person dismissed.
  • A binary channel stores the episode’s own base in this store, so the base counts against the budget and a large page leaves less room for the edits on top of it. A refused write is a state the indicator shows as Can't store changes: download a copy, and the copy already stored is left exactly as it was.
  • A binary channel compacts itself: at 64 batch records or 1 MiB of batch bytes, whichever comes first, the batches are merged into the writer’s capsule, outside any transaction and never over a record the store has not confirmed.
  • These are local recovery limits, not the host’s save limits, and a refusal is shown as a refusal, never as a saved copy.

Which document, which tab#

  • The scope is [origin, identity kind, identity, mode]. The identity is the host’s own documentid (or the pre-rename htmlclayid) when the root carries one, read once at load, and otherwise the exact pathname, with no query, no hash and no decoding of two paths into one. Draft mode is always path-scoped, because a visitor’s draft never reaches the host’s file, and it asks the host nothing.
  • Every page load mints a fresh writer id, never from sessionStorage, because a duplicated tab inherits that. Two tabs on one file keep separate copies of the same key and neither overwrites the other.
  • status().identityMismatch reports that the root now names a different document. The records stay where they were: nothing is moved into another document’s scope.
  • A renamed file, a different port and a different origin are different scopes. Recovery follows the document, not the person.

Privacy#

  • Records hold the document’s private text, in this origin’s browser storage, unencrypted. Any script on that origin can read them.
  • Nothing is sent anywhere: recovery makes no request, and in draft mode it asks for no host metadata.
  • Signing out or handing the browser to another account does not remove the copies. A page that offers recovery should say where they live and offer to discard them.
  • A reader never opens the database, so a published page keeps no local copy of anyone’s reading.
  • The page editor’s journal holds the page’s private text as Yjs bytes, in this same browser storage and under these same rules. Browser storage is not an off-device backup: a cleared profile, an evicted origin or another device holds nothing.

What a save may clear#

Coverage is decided from the save’s own capture, before the request goes out:

  • ClayJS hands the recovery service the exact bytes a save is about to send, with that capture’s id. Each channel’s readSaved reads those bytes once per capture, and only when the channel holds a copy that could be covered.
  • A revision is covered only when that map holds an exactly equal payload at the record’s key: every field in the adapter’s order, the body as an exact string, nothing trimmed, normalized or parsed.
  • So a later save does not erase newer typing. Stage A, save A, type B, and the host confirms A: A’s receipt lands and B stays.
  • If an onbeforesave hook changes the bytes, the copy of what the person typed is not covered and remains available.
  • clay:snapshot-ready is not this proof. It fires on a clone taken before authored before-save hooks, document transforms and final serialization, so its content can differ from the bytes the host accepts. When you need to know that a save covered a copy, use the captureId on clay:save-saved.

Conflicts keep their material here#

With sync loaded, the conflict ledger keeps its records in memory: the conflict records themselves, and the root the merge read as this tab’s side. A reload takes all of it away, and replaying those paths afterwards would point at a DOM that is not the one they were recorded against. So the losing side and the pre-apply root are written into this same store, under the channel clay.conflicts at version 1, before the ledger releases them.

  • A stored conflict is not a saved copy, and it is not the live conflict’s replacement: a storage failure leaves the live conflict exactly as it was.
  • Acknowledging one conflict removes nothing else. Dismissing a record from the notice is what removes it, and a record that still holds another conflict keeps its root.
  • The generic Keep, Revert and Download actions stay with the notice. clay.conflicts.registerContext(resolver) adds a name and an open action for a record; it does not take those actions away, and the function it returns is never stored.