@repo/datadata/persistence
Offline persistence for the client: the adapter interface for caching documents and pending writes, and an in-memory implementation.
Functions
createInMemoryPersistence
export declare function createInMemoryPersistence(): InMemoryPersistence;A reference ClientPersistenceAdapter over a plain in-memory store. Useful for tests (create a second client over the same adapter to model a reload, or InMemoryPersistence.spawnSibling a second instance to model a concurrently-live tab) and as the executable specification of the adapter contract. Records are deep-cloned on the way in and out, so a "rehydrated" client never shares object identity with the client that journaled — same isolation a real serializing store provides.
resolveCachedDocumentConflict
export declare function resolveCachedDocumentConflict(existing: PersistedCachedDocument | undefined, incoming: PersistedCachedDocument): PersistedCachedDocument | null;The document cache's multi-writer conflict policy, shared by every adapter (the executable spec lives here so the IndexedDB and in-memory stores cannot drift). Both sides always hold server-authoritative state, so every outcome is benign; the policy only has to keep the freshest of the two:
- No existing record, or an existing record of an OLDER format version → the incoming record wins. - An existing record of a NEWER format version → it is kept. A newer build wrote it, on a page that shares the store, and this build cannot read it (it may carry unsent Yjs content that build alone can replay), so an older build's put never downgrades it. - Different outcomes (init vs deleted vs notfound) → the record OBSERVED later wins (observedAt, or cachedAt for a record written without it — the observing session's clock: all sessions share one machine, and the client never stamps a record earlier than one of the same document the session wrote or read, so a clock step backwards does not reorder them). Outcome changes are server-observed transitions (a delete, a restore, an eviction), but two tabs' racing puts are not ordered by the store, so a stale init observed BEFORE a sibling's fresher deleted must not resurrect content that was evicted — even when it is written after it: an offline tab re-caches its copy without a new observation. There is no cross-outcome server version to compare (deleted/notfound carry no sequence, and a restore keeps the sequence), so observation time is the tiebreak; an equal one goes to the negative outcome over an init, and otherwise to the incoming record. - Both DELETED, of the same generation → the record observed later wins, but its retiredYjsIds is the UNION of both: the lanes an abandoned write left behind while the document was deleted are knowledge one session has and the other may not, and the tombstone is where it waits for the restore. Of different generations, the later observation wins whole. A tombstone rewritten to name a retired lane keeps its observation. - Both init, of DIFFERENT generations → the incoming record wins, whole. A purge released the id and a create reused it, so the two are copies of different incarnations: their sequences are unrelated and their Yjs snapshots must never union. Neither side can know which generation the server holds now — the latest put is kept, and a copy of the wrong one is replaced by its next subscribe's answer (whose generation it will not match). - Both init → last-writer-wins on sequence, the JSON version. - Both init at the SAME sequence → merge, never replace: the JSON is identical (that is what sequence versions), the further conformedSchemaSequence and the later observation are kept, but each session's yjsDocs is re-encoded from its LIVE Y.Doc and so carries that session's local unsent edits. The Yjs snapshots merge per yjsId via Y.mergeUpdates (CRDT-idempotent — merging identical states is a no-op), so no session's ahead-content is dropped. The Yjs lane deliberately has NO version of its own to break the tie with: preferring a record by one would replace the other wholesale and discard its unsent edits.
Why the union is safe — i.e. why a stale sibling cannot resurrect a deleted yjsId, even though a deleted yjsId is an ABSENT key. Sub-document membership is a function of the JSON: a Y.Doc lives exactly as long as some yjsRef field names it, and the write boundary reaps the orphans (references.allium WriteBoundaryCollectsOrphanedYjsDocs). Clients cannot delete a Y.Doc directly — the server drops any client-sent delete action. So EVERY deletion rides a JSON-changing write and advances sequence, and two records at the same sequence agree on the id-set of the server's lanes, up to ids one of them has not lazily created yet.
The exception is a lane a PENDING write created: the record's JSON does not reference it, and the record carries the creator's content for a reload to replay the journaled write with. A sibling tab mirroring the write carries the lane forward to newer sequences, and cannot tell when the write committed and a later write reaped the lane — so it can name the lane at a sequence where the server holds no such sub-document. The tab that learns it (the writer, when its acknowledgement shows the write committed and its current base no longer references the lane) lists the lane in retiredYjsIds, and the union keeps every retired lane out, from both sides and from every later put at the same sequence: the list itself is a union too. A put at a newer sequence replaces the record, list and all; the writer and every sibling that saw the list keep carrying it.
The localYjsAheadSince marker (the writer's Yjs dirty-episode stamp) follows the record it rides on outcome transitions and cross-sequence wins — a sequence-advancing, server-authoritative put replaces the record marker and all. At EQUAL sequence the merged blob is the union of both sides' content, so the marker must survive the union whenever either side's unconfirmed content does:
- Both sides marked → keep the EARLIEST stamp (two unconfirmed episodes; the older one dates the merged claim). - Only the incoming record marked → keep its stamp (fresh unconfirmed content joins the union), unless the unmarked existing snapshots verifiably CONTAIN everything the incoming ones do: an unmarked record holds only confirmed content, so the union is all confirmed. This is the late put of a session that stamped before it learned of a retirement — the retired lane was all it claimed, and the filter took it out. - Only the EXISTING record marked → a settled writer must NOT blindly clear a marker another session's content put there: its own lane being settled says nothing about content it never held. The marker clears only when the incoming snapshots verifiably CONTAIN everything the existing ones do (per-yjsId state-vector dominance, retired lanes left out of both sides) — then the settled writer vouches for the whole union, which is exactly the shape a session's post-heal re-cache produces. This relies on writers ADOPTING the marker when they merge a marked sibling record into their live docs (the client does), so a no-marker put never comes from a session rendering unconfirmed content it absorbed off the cache lane.
Returns the record to store, or null to keep the existing one. Adapters must apply the resolution ATOMICALLY (read + resolve + write in one transaction).
Interfaces
ClientPersistenceAdapter
export interface ClientPersistenceAdapterStorage seam for the client's durable offline write queue. The client journals write-through (fire-and-forget, best-effort) and loads once at construction; absent an adapter the client keeps today's memory-only behavior.
Contract: calls must apply in invocation order per docId (IndexedDB transactions satisfy this naturally). Durability is best-effort by nature — browsers may evict storage — and the degraded mode of a lost store is exactly the memory-only behavior, never corruption.
The client checks every record an adapter hands back against the shapes above before using it. One that fails is left unread in the store: a cached record's document cold-loads, and a write-queue record's pending writes are dropped together. When the meta record fails, the whole write queue is left unread.
adoptOrphanedWrites?(): Promise<PersistedClientState | null>;Adopt writes orphaned by a DEAD sibling session on the same namespace and return them (with the store's merged meta), or null when there is nothing to adopt. Adoption must be crash-safe in the store itself (the orphaned records are re-stamped to this session before this resolves), and must never return a LIVE session's writes — double-replay of a dead session's writes is safe by construction (event-id dedup, CRDT idempotency, create reconciliation, guard misses), stealing a live session's queue is not.
Optional: an adapter without sibling sessions (or without a way to tell dead from alive) simply omits it, and the client runs no adoption sweep — orphans then wait for the next boot's load() to pick them up.
Presence documents never appear in adopted state by construction — the client never journals them (putDocumentWrites is never called for one), so there is nothing presence-shaped in the store to adopt. The client's merge still filters presence docIds defensively.
clear(): Promise<void>;Wipe the whole namespace — the logout/embargo escape hatch.
deleteCachedDocument?(docId: string): Promise<void>;Drop one document's cached record (eviction, or local teardown).
deleteDocumentWrites(docId: string): Promise<void>;Drop one document's record (its last pending write settled).
getCachedDocument?(docId: string): Promise<PersistedCachedDocument | undefined>;Read ONE document's cached record — undefined when the store holds none, or holds one this build cannot read (an older record is migrated or dropped, a newer build's left in place, exactly as loadCachedDocuments does). This is what re-preparing a released document reads: releasing a lease drops the live Y.Docs and the dirty-episode stamp, so unsent Yjs content is left with the record as its only carrier, and the re-subscribe has to read it back before its cursors can name it.
load(): Promise<PersistedClientState | null>;Load the full persisted state. null means an empty/absent store.
loadCachedDocuments?(): Promise<PersistedCachedDocument[]>;Load every cached document record at the current format version. An older record is migrated and written back, or dropped from the store (its document cold-loads) when no migration reaches it; a newer build's record is left in the store and not returned.
readonly ownerId?: string;This session's owner id — the stamp on every journaled record. The client uses it to recognize its own records in write-queue change notifications (an adapter MAY echo the writer's own changes back to it; the in-memory adapter does). An adapter without sibling sessions may omit it.
putCachedDocument?(record: PersistedCachedDocument): Promise<void>;Upsert one cached record under the conflict policy (see above).
putDocumentWrites(record: PersistedDocumentWrites): Promise<void>;Upsert one document's pending writes (never called with an empty list).
putMeta(meta: PersistedMeta): Promise<void>;Replace the namespace meta.
release?(): void;Mark this session dead and drop the adapter's own holdings (locks, channels, connections): its journaled records stay in the store and become adoptable by a successor session on the namespace. Synchronous, idempotent, never throws — it is a teardown primitive. The client calls it from destroy() when it owns the adapter (ClientConfig.ownsPersistence, the default); a host that also calls it is harmless. clear() must keep working after release (the logout escape hatch outlives the session).
Optional: an adapter with no session-scoped holdings and no adoption (nothing distinguishes dead from alive) simply omits it.
watchCachedDocuments?(listener: (record: PersistedCachedDocument) => void): () => void;Subscribe to cached-document records LANDING in the shared store — the post-conflict-resolution record actually stored, not the raw incoming put (a put the existing record wins against notifies nobody: nothing changed). This is what makes offline tab-to-tab work: a sibling session's Yjs flush writes the shared cache, the notification carries the record, and the receiving client CRDT-merges its Yjs snapshots into its live Y.Docs — no server round-trip. Returns an unsubscribe function.
Delivery is best-effort and at-least-once-ish: an adapter MAY echo this session's own puts back to it (the in-memory adapter does; BroadcastChannel naturally doesn't), so receivers must treat records idempotently — the client's Yjs-lane merge is (re-applying own content is a no-op). Deletes are not notified: a receiver holds only server-authoritative state a reconnect re-answers, so there is nothing for it to tear down.
watchDocumentWrites?(listener: (change: SiblingWritesChange) => void): () => void;Subscribe to sibling sessions' write-queue records changing in the shared store — the seam behind the read-only sibling mirror (the JSON twin of watchCachedDocuments, e.g. the IndexedDB adapter's second BroadcastChannel). The change carries the owner's full post-change record (or null for a delete), so receivers never read the store back, and the adapter stamps it with this build's PERSISTENCE_FORMAT_VERSION (see SiblingWritesChange.formatVersion). Returns an unsubscribe function.
Delivery is best-effort. An adapter MAY echo this session's own changes back to it (the in-memory adapter does; BroadcastChannel naturally doesn't) — receivers filter by comparing the change's ownerId against the adapter's. Records received here are MIRROR-ONLY: rendered through the projection, never merged into the receiver's own queue (that is exclusively adoption's job, and only for DEAD owners).
InMemoryPersistence
export interface InMemoryPersistence extends ClientPersistenceAdapterThe in-memory ClientPersistenceAdapter createInMemoryPersistence returns: the whole adapter contract, document cache and sibling sessions included, plus snapshot and sibling hooks for tests.
adoptOrphanedWrites(): Promise<PersistedClientState | null>;Adoption of a dead sibling's queue — see ClientPersistenceAdapter.
cacheSnapshot(): PersistedCachedDocument[];Snapshot of the (namespace-shared) document cache, for assertions.
deleteCachedDocument(docId: string): Promise<void>;Drop one document's cached record; see ClientPersistenceAdapter.deleteCachedDocument.
getCachedDocument(docId: string): Promise<PersistedCachedDocument | undefined>;One document's cached record, or undefined; see ClientPersistenceAdapter.getCachedDocument.
injectOrphanedWrites(state: PersistedClientState): void;Test hook: stage a dead sibling session's state for the next adoptOrphanedWrites() call to adopt — the in-memory stand-in for "another tab journaled these writes and then died" when the dead tab was never a real instance. A released InMemoryPersistence.spawnSibling instance's records are adopted the same way without this hook.
loadCachedDocuments(): Promise<PersistedCachedDocument[]>;Every cached record of the current format version; see ClientPersistenceAdapter.loadCachedDocuments.
readonly ownerId: string;This session's owner id — every journaled record lands in its bucket.
putCachedDocument(record: PersistedCachedDocument): Promise<void>;Upsert one cached record under the conflict policy; see ClientPersistenceAdapter.putCachedDocument.
release(): void;Mark this session dead: its records stay in the store and become adoptable by the surviving siblings (boot load or sweep). Idempotent.
snapshot(): PersistedClientState;Snapshot of THIS session's records, for assertions.
spawnSibling(options?: {
formatVersion?: number;
}): InMemoryPersistence;A NEW session on the same namespace store — the in-memory stand-in for a second tab: its own owner id and journal bucket, the shared document cache, and change notifications flowing between the instances. load() surfaces the other LIVE instances' records as siblings; a InMemoryPersistence.released instance's records become adoptable instead.
formatVersion models a tab running another build: the instance stamps it on the changes it announces (see SiblingWritesChange.formatVersion) and otherwise behaves as this build does. Defaults to this build's PERSISTENCE_FORMAT_VERSION.
watchCachedDocuments(listener: (record: PersistedCachedDocument) => void): () => void;Cache change-notify — see ClientPersistenceAdapter. Notifies EVERY listener on the namespace, including the session that wrote (two clients over one adapter model two tabs on one namespace, and the store cannot tell their puts apart) — the documented echo the client's merge absorbs.
watchDocumentWrites(listener: (change: SiblingWritesChange) => void): () => void;Write-queue change-notify — see ClientPersistenceAdapter. Same echo-to-all convention as the cache watch; the change carries ownerId, so receivers (the client does) filter their own echoes by it.
PersistedCachedDeleted
export interface PersistedCachedDeleted extends PersistedCachedDocumentBaseA cached negative outcome: the document was soft-deleted when last seen.
deletedAt: number;When the document was deleted, in milliseconds since the Unix epoch.
generation: string;The tombstoned document's generation, which a restore of it is aimed at.
outcome: "deleted";Discriminates the cached outcome.
retiredYjsIds?: string[];Sub-documents a write created that was ABANDONED while the document was tombstoned — refused, or dropped with the id by the tombstone itself. The writer has no base to judge them against and no init record to name them on (PersistedCachedInit.retiredYjsIds), and its memory of them dies with the session, so the tombstone carries them across the deletion instead: the restore's init is where they are applied, by whichever session read them from here. They belong to the generation above — a purge that lets the id be reused says nothing about the new incarnation's lanes. Absent when empty.
type: string;The tombstoned document's docType, which types a restore or rename of it.
PersistedCachedInit
export interface PersistedCachedInit extends PersistedCachedDocumentBaseA cached server document — the doc:subscribe cursor set verbatim (sequence + state vectors derived from yjsDocs are exactly what a resubscribe sends to get deltas back), plus the data an offline reload renders. Every yjsDocs value is a FULL Yjs state-as-update (never a diff), re-encoded from the live Y.Doc at persist time — the in-memory record's snapshot goes stale after a doc:patch while the live doc advances, and the live encode is also what captures local unsent Yjs edits (server state + local ahead-content in one blob; the reconnect SV exchange self-heals the difference in both directions).
conformedSchemaSequence?: number;How far data has been carried along its type's schema (DatadataDocument.conformedSchemaSequence), so a reload between a schema commit's two frames still knows the copy is behind the cached schema. Absent when the copy has none.
data: unknown;The document's JSON data as last seen.
generation?: string;The cached copy's generation (DatadataDocument.generation) — the third member of the cursor set: sequence and the state vectors only mean anything for the generation they were read from. Absent when the copy's generation is unknown; such a cursor can never resume.
localYjsAheadSince?: number;Earliest wall-clock time since which the yjsDocs snapshots have carried LOCALLY-AUTHORED, server-unconfirmed Yjs content — the writing session's in-memory dirty-episode stamp at cache time; absent when its Yjs lane was fully settled. This is the one deliberate client-local fact on an otherwise server-truth record: the live re-encode already smuggles unsent edits inside the blob, indistinguishably from server content, and without the marker a reload renders them as saved data — no pending count, no date, a sync status that understates ("showing saved data" instead of "changes pending"). Hydration counts a marked document as pending and dates it via oldestStagedAt until the reconnect state-vector exchange verifiably heals the lane. May briefly OVER-claim (a sibling tab may have pushed the same content already) — erring toward "unsaved changes" is the accepted direction; the heal clears it.
outcome: "init";Discriminates the cached outcome: the document existed.
retiredYjsIds?: string[];Sub-documents a write created that the server has since reaped: the record names none of them, and an equal-sequence merge keeps them out of the union from either side. A record carries a lane its JSON does not reference when a pending write created it (the creator's content, kept for a reload to replay the journaled write with), and a sibling tab carries that lane forward to newer sequences while it mirrors the write. It cannot tell when the write committed and the server then reaped the lane, so its re-caches can name the lane at a sequence where the server holds no such sub-document; this list is how the tab that learned it (the writer, from the write's acknowledgement) makes every copy at that sequence drop it. Absent when empty.
sequence: number;The JSON-lane sequence of the cached copy; the resubscribe cursor.
type: string;The document's docType.
yjsDocs: Record<string, Uint8Array>;Each Yjs sub-document's full state as an encoded update, keyed by yjsId.
PersistedCachedNotFound
export interface PersistedCachedNotFound extends PersistedCachedDocumentBaseA cached negative outcome: the document did not exist when last seen.
outcome: "notfound";Discriminates the cached outcome.
PersistedClientState
export interface PersistedClientStateWhat ClientPersistenceAdapter.load and ClientPersistenceAdapter.adoptOrphanedWrites answer: this session's journaled writes and the namespace meta.
adoptedOwnerIds?: string[];The owner ids whose records were adopted into documents by this call (load-time or sweep adoption). The client uses this to drop its mirror entries for those owners — their writes just became its own, and the store's delete notifications don't echo back to the session that posted them.
documents: PersistedDocumentWrites[];This session's pending writes, one record per document (adopted records included).
meta: PersistedMeta | null;The stored meta, or null when none has been written.
siblings?: PersistedSiblingWrites[];LIVE sibling sessions' records at load time — the boot snapshot of the read-only mirror (change notifications only cover records that change AFTER boot). Absent when the adapter has no sibling sessions or cannot enumerate them.
PersistedCommand
export interface PersistedCommand extends PersistedWriteBaseA journaled pending command: a doc:command the server runs through its registry. It sits in the document's update lane, ordered with the updates by opOrder, and the updates staged behind it are journaled with it — they were authored over its prediction, so they only ever replay after it.
Added without a format-version bump: a store written before commands were journaled simply holds none (its writer cut the list at the first command), so it loads as it is. A bump with no migration step would delete the owner's pending writes, which buys nothing for a kind that only ever appears in stores written since.
args: unknown;The command's arguments, as the server receives them.
docType: string;The docType the command was defined for when it was staged.
guard: GuardMode | undefined;The command's guard from its contract: "sequence" for an exact one, else undefined.
kind: "command";Discriminates the write kind.
lastSentAt: number | null;Wall-clock time of the most recent send, or null while never sent; paces re-sends.
name: string;The command's name in the registry.
patch: Operation[];The prediction the command was staged with: the mutator's result over the copy it was staged on, as a plain diff. What a reader that cannot run the command renders (a sibling session's mirror); the session that replays it runs the command again over the document each read meets.
sentAt: number | null;Wall-clock time the command was FIRST sent, or null while it never has been.
sequence: number;The document sequence the command was staged against.
PersistedCreate
export interface PersistedCreate extends PersistedWriteBaseA journaled pending create.
data: unknown;The created document's initial data.
docType: string;The created document's docType.
kind: "create";Discriminates the write kind.
PersistedDelete
export interface PersistedDelete extends PersistedWriteBaseA journaled pending delete.
kind: "delete";Discriminates the write kind.
PersistedDocumentWrites
export interface PersistedDocumentWritesAll pending writes for one document — the journal's unit of persistence. The client re-snapshots a document's whole write list on every stage / restamp / ack / rollback, so the record is always internally consistent (no partial per-entry updates to reconcile on load).
docId: string;The document the writes target.
persistedAt: number;Wall-clock time of the last journal write for this document. Feeds the app-level retention policy (persistedWriteMaxAgeMs): a record older than the app's limit is evicted on boot. This can only TIGHTEN the protocol's safety bounds — the replay horizon on sentAt still governs regardless.
writes: PersistedWrite[];The document's pending writes; replay orders them by opOrder, not by position.
PersistedMeta
export interface PersistedMetaNamespace-wide bookkeeping stored beside the write journal.
abandonedReplayWriteIds: string[];The event ids of writes abandoned past the replay horizon whose late doc:error may still arrive — persisted so a pre-reload surfaced write whose error lands post-reload is recognized as stale instead of marking a healthy document errored.
formatVersion: number;The PERSISTENCE_FORMAT_VERSION the store was written with; another version drops the store.
PersistedRename
export interface PersistedRename extends PersistedWriteBaseA journaled pending rename.
kind: "rename";Discriminates the write kind.
name: string | null;The new index-level name; null clears it.
PersistedRestore
export interface PersistedRestore extends PersistedWriteBaseA journaled pending restore.
kind: "restore";Discriminates the write kind.
PersistedSiblingWrites
export interface PersistedSiblingWrites extends PersistedDocumentWritesA LIVE sibling session's pending writes, as surfaced to this session for the read-only mirror: the sibling's record verbatim plus the owner id that keys it. Mirror-only by contract — never replayed, never restamped, never counted as this session's own pending writes; the one ownership handoff is adoption, which happens when the owner is DEAD (never for records surfaced here).
ownerId: string;The sibling session that owns the record.
PersistedUpdate
export interface PersistedUpdate extends PersistedWriteBaseA journaled pending update.
createdYjsIds?: readonly string[];Sub-documents this write brought into being (OptimisticUpdateEntry.createdYjsIds). Journaled because the lane outlives the session that opened it: its content rides the shared cache record, and the session that replays the write — this one after a reload, or the one that adopts it — is the only one that can learn the write failed and take the lane out of every copy.
Absent when the write created none — and, deliberately, when the record was journaled before this field existed: such a write replays as it did before, its refusal reaping nothing and the record's claim standing until a sequence-advancing re-cache. Required plus a format-version bump would remove that second meaning, the way v2's stagedAt removed the age-unknown branch, but the two bumps do not cost the same: a foreign write-queue version DELETES the owner's journaled writes, where the document cache's only cold-loads. That would discard pending UNSENT user writes to buy one replay's worth of accuracy on a claim that heals itself. Nor can the set be recovered from the patch, which cannot tell a lane the callback opened from a reference to one the server already holds.
guard: GuardMode | undefined;The update's concurrency guard; undefined when it has none.
kind: "update";Discriminates the write kind.
lastSentAt: number | null;Wall-clock time of the most recent send, or null while never sent; paces re-sends.
patch: Operation[];The edit as JSON Patch operations, recorded moves included.
plainPatch?: Operation[];The same edit as the plain diff, while patch carries moves (OptimisticUpdateEntry.plainPatch). Absent when patch is the plain diff.
sentAt: number | null;Wall-clock time the update was FIRST sent, or null while it never has been. Bounds replay: a sent update older than the replay horizon is surfaced instead of re-sent.
sequence: number;The document sequence the update was authored against.
SiblingWritesChange
export interface SiblingWritesChangeOne write-queue change-notification: a sibling session's journal record for docId was upserted (record holds the full post-change list) or deleted (record is null — its last pending write settled or rolled back). Unlike the document cache's notify, deletes ARE notified: a mirror entry must clear when its journal record clears, or the receiver would render writes the author already resolved.
docId: string;The document whose record changed.
formatVersion: number;The PERSISTENCE_FORMAT_VERSION of the build that announced the change, which an adapter stamps on every change it posts. A receiver applies only a change of its own version: one of another version, or one with no version at all (a build from before changes were stamped), names a session whose records this build cannot read or settle, so the receiver removes what it mirrors for that owner and document instead of applying it.
ownerId: string;The sibling session whose record changed; compare with the adapter's own ownerId to skip echoes.
record: PersistedDocumentWrites | null;The full record after the change, or null when it was deleted.
Types
PersistedCachedDocument
export type PersistedCachedDocument = PersistedCachedInit | PersistedCachedNotFound | PersistedCachedDeleted;One persisted read-cache record. Negative outcomes are cached too — an offline reload should render "deleted"/"not found" truthfully instead of spinning on a document the server already answered about.
PersistedWrite
export type PersistedWrite = PersistedCreate | PersistedUpdate | PersistedCommand | PersistedDelete | PersistedRename | PersistedRestore;One journaled optimistic write, discriminated by kind. Each carries the exact wire event with its already-minted event id, so a replay after a reload is deduplicated by the server like a reconnect replay.
Variables
DOCUMENT_CACHE_FORMAT_VERSION
DOCUMENT_CACHE_FORMAT_VERSION = 8Version stamp of the persisted DOCUMENT-CACHE format, independent of the write queue's (the two stores evolve separately). Stamped on every cached record. Bumped with a step in the cache's migration table where a default can carry an older record forward; a loaded record of an older version is migrated through those steps and written back, one no step reaches is dropped (cold-load that document), and a newer build's is left unread.
v3: init records may carry localYjsAheadSince — the writer's Yjs dirty-episode stamp, making unsent Yjs edits visible (counted and dated) after a reload.
v4: init records of stored documents carry generation. Dropped v3 records cold-load their documents, so a cached cursor always names its generation.
v5: deleted records carry the tombstone's type and generation, and cached sys:trash entries carry generation. Dropped v4 records cold-load, so a restore authored against a cached tombstone or trash listing is typed and always names its generation.
v6: init records may carry retiredYjsIds — sub-documents a write created that the server has since reaped, which an equal-sequence merge keeps out of the record. Dropped v5 records cold-load, so no record from before the rule can carry such a lane back.
v7: init records may carry unprovenYjsIds — the retirements that are a GUESS rather than evidence.
v8: unprovenYjsIds is gone. A release hears out the writes it already sent, so every retirement is evidence again. Dropped v7 records cold-load, so no record carries the field.
PERSISTENCE_FORMAT_VERSION
PERSISTENCE_FORMAT_VERSION = 2Version stamp of the persisted write-queue format. Bumped on any incompatible change to the persisted shapes below, with a step in the write queue's migration table that carries a record of the previous version forward. A loaded store of an older version is migrated through those steps; one no step reaches is dropped (cold start), and a newer build's is left unread.
v2: every write carries a required stagedAt (the wall-clock staging time behind "unsent changes from Tuesday") — required rather than optional so no consumer carries an age-unknown branch, at the cost of dropping v1 stores.