@repo/datadata/client
Creating a datadata client, the connection to a server that reads, subscribes to and optimistically writes documents.
Classes
UnsettledDocumentsError
export declare class UnsettledDocumentsError extends ErrorThe rejection an ABORTED settle drain — waitForSettled, and the close() that drains through it — settles with, naming the documents that were still outstanding when the signal fired. The abort itself is the cause (the signal's reason, e.g. AbortSignal.timeout()'s TimeoutError), so a caller that only cares that it timed out reads that; a caller that has to FIX the stall reads documents.
It exists because the signal's own reason cannot answer the only question a wedged drain raises — which document wedged it. The client knows precisely that at the moment it gives up (it walks the same per-document state settledness is decided from), and this carries it out rather than leaving the host to re-derive it from sys:client-docs-status.
A drain aborted with nothing outstanding — the pre-aborted signal, which rejects before settledness is even consulted — rejects with the plain abort reason instead: there is no culprit to name.
constructor(documents: readonly UnsettledDocument[], cause: Error);Constructs a new instance of the UnsettledDocumentsError class
readonly documents: readonly UnsettledDocument[];Every document still outstanding when the signal fired (the message names at most five).
Functions
createClient
export declare function createClient<S extends SchemaRegistry, C extends CommandRegistry = CommandRegistry>(params: {
schemas: S;
commands?: C;
sendEvent: (event: ClientSentEvent) => void;
createEventId: () => string;
config?: ClientConfig;
logger: Logger;
preloadedEvents?: (DocumentInitEvent | DocumentNotFoundEvent | DocumentDeletedEvent | DocumentErrorEvent)[];
}): DatadataClientFor<S, C>;Create a client for the document types in schemas. The client is transport-free: it hands every outgoing event to sendEvent, and the host feeds it the server's events with DatadataClient.handleEvent and its connection state with DatadataClient.setConnected / DatadataClient.setDisconnected. It starts disconnected, so nothing is sent until the first setConnected.
createEventId mints the id of every client-sent event and must return a unique string on each call. preloadedEvents are server answers the host already holds (for example from a server-side render); they are handled before the client is returned, so those documents read as loaded at once. config tunes the client; see ClientConfig. commands are the app's domain commands; see defineCommands in @repo/datadata/commands. A defineCommands map types the sessions' command calls.
Interfaces
BlobEndpointConfig
export interface BlobEndpointConfigWhere and how the client reaches the blob lane. See ClientConfig.blobs (client/types.ts) for the contract.
fetch?: (url: string, init: RequestInit) => Promise<Response>;The fetch implementation the upload uses (default: globalThis.fetch). An injection seam for hosts without a global fetch and for tests that drive the real route handlers in-process.
url: string;The folder's blob endpoint, as the HOST mounts it — a bare path or absolute URL. The route shape is the host's own choice, not the library's: @repo/datadata ships the handlers (server/blob-http.ts), never a path, so the Node harness mounts them at /folder/<folderId>/blob while the playground mounts them at /room/<roomId>/blob under its own vocabulary. The endpoint carries no query string or fragment (refused at client creation): the upload URL's query is the route's own intent parameters (kind, docId, docType, path), and the download URL is embedded verbatim in <img src> and links, so identity rides cookies, never the URL.
BlobHandle
export interface BlobHandleThe facts an accepted upload answers — the wire response of the blob upload route. blobId is the server-minted opaque handle the app then commits into a blobRef field through an ordinary document write (the write boundary verifies the handle, so commit only after the upload resolves); the rest are the catalog's immutable facts, echoed so apps that cache them in sibling JSON fields need no follow-up HEAD.
blobId: string;The server-minted blob id: commit it into a blobRef field to reference the blob.
contentType: string;The content type the blob was uploaded with, and is served with.
sha256: string;The SHA-256 of the stored bytes as lowercase hex; also the download's strong ETag.
size: number;The stored body's length in bytes.
BlobUploadIntent
export interface BlobUploadIntentThe document write a blob upload is intended for — named by the uploader at staging so the upload is authorized as THAT write (create or update on docId, a document of docType), the same verdict the eventual handle- committing write will get: the docType's rule, or the sys:access per-document entry for docId where one exists (a per-document entry wins for every kind, create included — a privately minted id is such a case). A hint for authorization and for the type-wide constraint check, not a binding: the handle may still be committed anywhere the write boundary authorizes, and no document existence is checked at staging (so a probe learns nothing).
docId: string;The document the handle is meant to be committed into.
docType: string;That document's docType; its rule authorizes the upload and its blobRef fields constrain it.
kind: "create" | "update";Which write the upload is for: creating docId, or updating it.
path?: readonly (string | number)[];Where in the document the handle is meant to go — object fields / record keys as strings, array indices as non-negative integers. Optional narrowing for the staging-time constraint check: with it, only the blobRef leaves at that path (every union variant's, since staging sees no data) are candidates instead of every leaf of the type, and a path naming no blobRef leaf is refused outright. The write boundary's exact check is unaffected.
BlobUploadOptions
export interface BlobUploadOptions extends BlobUploadIntentWhat uploadBlob takes besides the bytes: their content type, the BlobUploadIntent the upload is authorized as, and an optional cancellation signal.
contentType: string;The Content-Type the upload declares, stored with the blob and served back on download.
signal?: AbortSignal;Cancels the upload request; the returned promise then rejects as fetch does on abort.
ClientConfig
export interface ClientConfigOptions for createClient. Every field is optional; an omitted field takes the default its comment names.
awaitedWriteTimeoutMs?: number | null;Default timeout (ms) for the awaited operations — createDocumentAndWait, updateDocumentAndWait, deleteDocumentAndWait, restoreDocumentAndWait and getDocumentEvents. If the server does not respond within this much CONNECTED time, the promise rejects instead of hanging forever on a silent-but-live server (e.g. a wedged Durable Object that still answers heartbeats). An awaited WRITE rejects with a WriteRejectedError of category "timeout"; getDocumentEvents (a read) with a ReadRejectedError of the same. For the awaited writes the timer counts only time while connected: it is cleared while offline — a write buffered by offline support still resolves when it flushes on reconnect — and re-armed on reconnect. A per-call timeoutMs overrides this.
Note: on reconnect the write's timer restarts with the FULL duration (elapsed connected time is not carried across a disconnect), so many short disconnects can delay settlement past a single window; pass an AbortSignal for a hard wall-clock bound. getDocumentEvents is a read (not replayed), so it is rejected outright on a disconnect rather than paused — its timeout is a plain wall-clock from send.
Default: 30000. Set null (or 0) to disable, leaving only an AbortSignal or teardown to settle a silent server.
blobs?: BlobEndpointConfig;The blob lane's HTTP endpoint — the folder's …/blob route both hosts mount (docs/object-storage-design.md §4). Blob bytes never ride the WebSocket: uploadBlob POSTs to this URL and blobUrl derives the by-id download URL from it, so configure it alongside the transport's WebSocket URL: same host, a bare path (no query string or fragment — refused at creation). Identity rides the browser's cookies for that origin, never the URL: the derived URLs are embedded in <img src> and links, so anything on their query string ends up in history and referrers. An app whose host reads identity elsewhere decorates the URLs itself, outside the client (the playground's dev-only ?subject= seam does exactly that). Default: none — uploadBlob and blobUrl throw a configuration error.
documentCacheMaxBytes?: number;Cap on the document cache's total estimated size in bytes (Yjs snapshot bytes plus serialized JSON data; only meaningful when ClientConfig.persistence implements the document cache). Applied at boot together with ClientConfig.documentCacheMaxEntries, oldest-first, with the same records pinned.
Default: 64 MiB.
documentCacheMaxEntries?: number;Cap on the number of cached documents (only meaningful when ClientConfig.persistence implements the document cache). Applied at boot, BEFORE hydration: the oldest records by cachedAt are evicted from the store until the cache fits — sys: documents are pinned (schemas and the index are what offline validation and rendering hang off) and never evicted, and so is a document whose record holds Y.Doc edits not yet sent (the record is the only place they live until then). A session can transiently overshoot the cap until its next boot, and pinned records can keep a cache over it across boots: the cap bounds what a boot hydrates of the evictable records, mirroring the write-queue cap's known-queue stance.
Default: 1000.
dynamicSchemas?: boolean;Resolve document validators from synced sys:schema:<docType> documents rather than only the static registry — the same storage-authoritative model the server uses. When enabled the client self-subscribes to sys:index and, reactively, to every sys:schema:* document it lists, so optimistic validation honours the stored schema, and every write names the schema sequence it was authored against. Until the type's schema has synced, a session's createDocument / updateDocument THROWS rather than validate against a static registry shape that may trail the server's, and createDocumentAndWait / updateDocumentAndWait wait for it. Before a plain write, await the session's waitForSchema(type), which asks about the type alone and resolves from the offline cache. A prepared document's available includes that wait, and also asserts the document itself is readable: it rejects for a missing or deleted document, so it is the wait before an update of a document known to exist, not before a create. A type the synced sys:index lists no schema document for falls back to the static registry. A schema that could not be READ (the index or the schema document answered an error) is not a fallback: the waits reject and the writes are refused with a SchemaUnavailableError. Also lets pure-runtime docTypes — those with no static registry entry — validate from storage alone.
Default: false (validate against the static registry only).
localClock?: () => number;The local clock DatadataClient.clock adds its offset to: milliseconds, monotonic, on any origin (the offset absorbs it). Default: performance.timeOrigin + performance.now(). Inject one to test, or to give each client of a one-page demo a skewed clock for the connection clock to correct.
orphanAdoptionIntervalMs?: number | null;How often to sweep the persistence namespace for writes orphaned by a DEAD sibling session (only meaningful when ClientConfig.persistence implements adoptOrphanedWrites, e.g. the IndexedDB adapter). Without the sweep a live tab never adopts a dead tab's queue mid-session — orphans wait for the next boot. Adopted writes merge into the optimistic maps under the boot-rehydration rules (renumbered below this session's writes, live entry wins a key collision) and replay on the same connection when online, or on the next reconnect. Set null to disable the sweep.
Default: 15000.
ownsPersistence?: boolean;Whether destroy() releases the ClientConfig.persistence adapter (its release() — Web Lock, channels, connections; the journal itself always survives). Default true: the common host hands the adapter to exactly one client, and a forgotten release leaves the adapter's lock held, which blocks a successor session from adopting the journal. Pass false only when the host reuses one adapter across successive client instances (it then owns the release itself) — a stale client's teardown must not release an adapter its replacement is still journaling through.
persistedWriteMaxAgeMs?: number;App-level retention bound (ms) for rehydrated queue records (only meaningful with ClientConfig.persistence). A document's persisted record older than this at load time is evicted instead of rehydrated. This can only TIGHTEN the protocol's replay-safety bounds (the sentAt replay horizon still governs replay regardless); it can never extend them.
Default: none (no app-level age bound).
persistedWriteQueueMaxEntries?: number;Cap on the number of persisted pending writes (across all documents; only meaningful with ClientConfig.persistence). At the cap, staging a further write is refused with category "queueFull" — an awaited write rejects its promise with a WriteRejectedError, a fire-and-forget one reports through onWriteError — since refusing new work is safer than silently dropping journaled-but-unsent user writes. The cap is enforced against the KNOWN queue: writes staged in the brief window before the boot-time load resolves are counted against the pre-merge total, so the pending count can transiently exceed the cap by what the load rehydrates.
Default: 1000.
persistence?: ClientPersistenceAdapter;Durable storage for the offline write queue. When set, every pending optimistic lifecycle write (create / update / delete / rename / restore — presence is ephemeral and excluded) is journaled write-through, and a new client over the same adapter rehydrates the queue and replays it under the NORMAL replay-horizon rules — a reload behaves like a reconnect. Writes are fire-and-forget after a reload (promises don't survive); outcomes surface via onWriteError and the sys:client-docs-status counts.
The adapter's namespace MUST be scoped to (folder, principal) — the queue carries document data, so sharing one across principals on an origin leaks data between accounts. Persistence is best-effort (browsers may evict storage); the degraded mode is exactly the memory-only default.
Teardown (destroy / unsubscribe) deliberately does NOT erase the journal — only a write's own outcome (ack, rollback, replay-horizon surfacing) or adapter.clear() does — so writes pending at teardown are adopted by the next client on the namespace instead of dropped.
The client OWNS the adapter by default: destroy() calls its release(), marking the session dead so a successor on the namespace adopts its journal (see ClientConfig.ownsPersistence to keep ownership at the host).
Default: none (memory-only).
presenceGraceMs?: number;How long a remote presence entry survives as stale after a presence resync or a reconnect, before it is dropped. Both events make the remote view unverifiable rather than wrong — peers re-confirm by republishing — so entries are kept (flagged stale: true) for this grace window instead of flickering out and back. A peer that never re-confirms (it disconnected while the server was away) ages out at the deadline.
Default: 10000. Set 0 to drop unconfirmed entries at the next tick.
principal?: Principal;The identity this client's writes run as ON THE SERVER — used locally for optimistic write prediction and capability discovery (can / getCapabilities). A PRE-CONNECT SEED: every client self-subscribes the connection's sys:principal document and ADOPTS the served identity once it syncs, so after connect the server — not this config — is the authority (mis-threaded values are corrected, and host-driven mid-connection changes land automatically). Optional — without it (and before the served identity arrives), prediction passes through (never a false local block) and capabilities answer null (unknown); the server stays authoritative either way. A principal-holding client self-subscribes the folder's sys:access document so its facts stay synced.
storageErrorCircuitThreshold?: number;Circuit-breaker threshold for storageError retries: the maximum number of updates allowed to be retrying concurrently. A transient blip retries a write or two; a SYSTEMIC storage outage fails many writes at once, and retrying them all would amplify load (N writes × ClientConfig.storageErrorRetries re-sends) against an already struggling backend. Once this many updates are concurrently in retry, a further storageError fails fast (immediate rollback) instead of scheduling another retry. The circuit closes on its own as in-flight retries drain (each ack frees a slot).
Default: 20. Set 0 (or negative) to disable the breaker (unbounded concurrent retries).
storageErrorRetries?: number;How many times to automatically re-send a doc:update that the server rejected with a transient storageError, before giving up and rolling the optimistic edit back. A storageError means the write did not commit (the server transaction rolled back), and the re-send carries the original event id, so server idempotency (WritesAreIdempotentByEventId) makes it exactly-once even if a later attempt reveals an earlier one actually landed. Only doc:update is retried — the destructive, idempotency-backed case; create/delete/restore fail terminally as before. Timers are cleared on disconnect (the reconnect replay re-sends instead) and the awaited-write timeout still bounds the total wait.
Default: 3. Set 0 to disable (a storageError rolls back immediately, as before).
storageErrorRetryDelayMs?: number;Delay in ms between transient storageError re-sends (see ClientConfig.storageErrorRetries). Default: 500.
unconfirmedWriteResendMs?: number | null;Re-send bound (ms) for a SENT-but-unconfirmed doc:update on a LIVE connection. A write can go unanswered while the connection stays up: the server failed while handling it, or committed it and failed before the acknowledgement left. The heartbeat sees a healthy connection, no reconnect ever replays the buffer, and a fire-and-forget write would sit optimistic-but-uncommitted forever. A periodic sweep re-sends any update still unconfirmed this long after its last send, under its original event id — server idempotency (WritesAreIdempotentByEventId) re-acks a write that actually committed (only its acknowledgement never left) instead of applying it twice, so the re-send is exactly-once. The sweep runs at this same period, so a stranded write is re-sent within at most twice this bound; it keeps re-sending each period until an ack or error settles the write (a dead socket is the heartbeat's job, not this sweep's). An update older than the replay horizon (MAX_REPLAY_AGE_MS) is no longer safe to re-send (its server dedup record may be pruned) and is dropped with a resync to server truth instead — the same policy the reconnect replay applies.
Awaited updates are swept too (a rescued ack beats a timeout rejection); the awaited- write timeout still bounds their total wait. Creates, deletes, renames and restores are swept the same way. A doc:subscribe still unanswered a period after its send (its answer never sent) is re-sent too, so a missing answer cannot leave a document "subscribing" — and its gap resync blocked — for the life of the connection. Yjs content a connected tab absorbed from a sibling tab through the document cache, and that is still unconfirmed after this period, is forwarded to the server by the absorbing tab (the sibling may have closed before sending it).
Default: 10000. Set null (or 0) to disable, leaving reconnect replay as the only recovery for an unanswered write.
yjsAutoSyncDebounceMs?: number;Auto-sync debounce delay in milliseconds for Y.Doc updates.
When Y.Docs are edited directly (outside updateDocument), changes are automatically synchronized after this delay of inactivity.
- Positive number: Debounce delay in ms (default: 100) - 0: Disable auto-sync (manual flush only via flushYjsUpdates) - -1: Immediate sync with no batching (not recommended for frequent edits)
ConnectionClock
export interface ConnectionClockThe connection's view of the server's clock. Off until ConnectionClock.start: a client that never starts it sends no clock traffic. Started, it pings the server a few times on every connect and once per resync interval after, and keeps the offset of the fastest round trip among its recent samples (NTP's minimum-delay filter: the shortest trip has the least room for asymmetric delay).
ConnectionClock.now is the local monotonic clock plus that offset, so it keeps counting through an outage on the last offset. A corrected offset is slewed in, not stepped: now() runs up to 10% fast or slow until it has caught up, so it never runs backwards. Only the first sync, and a correction over 250 ms, step at once. Read it to agree on time with other clients of the same server: write a server time into a document (waveStartsAt), and every client computes from it on its own frames.
now(): number;Server time in milliseconds since the Unix epoch — the local clock until ConnectionClockStatus.synced. Never runs backwards, except when a correction over 250 ms steps it.
ready(): Promise<ConnectionClockStatus>;Resolves with the status once the clock has synced (at once if it already has), so a server time written into a document is never the local clock in disguise. Rejects if the client is destroyed first. Waits for a ConnectionClock.start, so start the clock to see it settle.
start(options?: ConnectionClockOptions): void;Start sampling (now if connected, else on connect). Calling it again while running changes nothing.
readonly status: ConnectionClockStatus;The current status; replaced by a new object on every change.
stop(): void;Stop sampling. The last offset stays, so ConnectionClock.now keeps reading server time.
subscribe(listener: (status: ConnectionClockStatus) => void): () => void;Called on every status change. Returns the unsubscribe.
ConnectionClockOptions
export interface ConnectionClockOptionsTuning for ConnectionClock.start; every field has a default.
burstSamples?: number;Pings sent back to back on every connect (the first sync, and after each reconnect). Default 5.
resyncIntervalMs?: number | null;One more ping this long after the last answer, while connected. null samples on connect only. Default 60 000.
sampleTimeoutMs?: number;A ping unanswered this long is given up and the next one goes out. Default 5 000.
ConnectionClockStatus
export interface ConnectionClockStatusWhat the clock knows. A new object on every change, so it can be compared by identity.
readonly offsetMs: number;Server time minus local time, in milliseconds: the current estimate. 0 until synced. ConnectionClock.now slews toward it rather than jumping to it.
readonly rttMs: number | null;The round trip of the sample the offset comes from; its error is at most half this. Null until synced.
readonly running: boolean;Between ConnectionClock.start and ConnectionClock.stop.
readonly synced: boolean;At least one ping has been answered since the clock first started: ConnectionClock.now reads server time.
DatadataClient
export interface DatadataClient extends DatadataClientBase<LiveSessionWithoutCommands<ValidatorRegistry>>The schema-agnostic narrowed client — DatadataClientFor without a specific schema map. For holders that can't name the schemas (e.g. a global state atom); editing still goes through client.live.
A concrete DatadataClientFor<S, C> upcasts to this type (assign it to a DatadataClient variable — no cast). In generic code, where S is still a type parameter, TypeScript cannot prove it and the holder erases with an explicit cast, as react-jotai's useSetupDataDataClient does. The obstacle to a plain upcast is openStagingSession's type-checked hostType overload: its parameter is contravariant in the registry (HostType extends keyof Registry, RequireSessionField<TypedDocumentData<Registry, …>>), so a schema-specific client's overload is not assignable to the schema-agnostic one. We therefore declare openStagingSession here WITHOUT that typed overload, keeping only the in-memory and persisted-untyped forms — the static host-field check is a typed-client affordance, so a holder that has discarded its schemas has nothing to check it against. The rest of the surface widens cleanly via the shared base surface.
Its live sessions are LiveSessionWithoutCommands: a holder that has discarded the client's command map has no names or args shapes to check a command against, so it sends none. Commands go through the typed client (DatadataClientFor<S, C>), which a holder restores with an explicit, reviewed cast where it hands the client back (react-jotai's createTypedClientAtom).
openStagingSession(options?: {
onUpstreamAdvance?: UpstreamAdvancePolicy;
subscribeStaged?: boolean | {
includeSchemas?: boolean;
};
presencePublishDebounceMs?: number;
generateId?: () => string;
scopes?: readonly AccessScope[];
}): StagingSession<ValidatorRegistry>;Open an IN-MEMORY staging session: edits are staged in a changeset that lives only in this session (no host document, no per-stage write) until commit() applies them or discard() drops them. onUpstreamAdvance (default autoMerge) decides what happens when a staged document advances upstream. See StagingSessionParams for the other options.
openStagingSession(options: {
hostDocId: string;
field?: string;
subscribeStaged?: boolean | {
includeSchemas?: boolean;
};
presencePublishDebounceMs?: number;
generateId?: () => string;
scopes?: readonly AccessScope[];
}): StagingSession<ValidatorRegistry>;Open a staging session whose changeset is stored in field (default "session") of the host document hostDocId, so it rides ordinary document sync and survives a reload. The session subscribes the host document itself; keep to one session per host. See StagingSessionParams for the options.
DatadataClientInterface
export interface DatadataClientInterface<Registry extends ValidatorRegistry, Commands extends CommandRegistry = CommandRegistry> extends DatadataClientBase<LiveSession<Registry, Commands>>The schema-typed public client — exactly what createClient returns. It has the same surface as DatadataClient plus the full openStagingSession overload set, including the static hostType field check. The concrete client class implements this (at its own Registry), so the class is checked against the surface it exposes rather than the surface being subtracted from the class with Omit.
openStagingSession(options?: {
onUpstreamAdvance?: UpstreamAdvancePolicy;
subscribeStaged?: boolean | {
includeSchemas?: boolean;
};
presencePublishDebounceMs?: number;
generateId?: () => string;
scopes?: readonly AccessScope[];
}): StagingSession<Registry>;Open an IN-MEMORY staging session: edits are staged in a changeset that lives only in this session (no host document, no per-stage write) until commit() applies them or discard() drops them. onUpstreamAdvance (default autoMerge) decides what happens when a staged document advances upstream. See StagingSessionParams for the other options.
openStagingSession<HostType extends keyof Registry & string, Field extends string = typeof DEFAULT_SESSION_FIELD>(options: {
hostType: HostType;
hostDocId: string;
field?: Field;
subscribeStaged?: boolean | {
includeSchemas?: boolean;
};
presencePublishDebounceMs?: number;
generateId?: () => string;
scopes?: readonly AccessScope[];
} & RequireSessionField<TypedDocumentData<Registry, HostType>, Field>): StagingSession<Registry>;Open a staging session whose changeset is stored in field (default "session") of the host document hostDocId, so it rides ordinary document sync and survives a reload. hostType checks at compile time that the host type declares field as a session changeset field. The session subscribes the host document itself; keep to one session per host. See StagingSessionParams for the other options.
openStagingSession(options: {
hostDocId: string;
field?: string;
subscribeStaged?: boolean | {
includeSchemas?: boolean;
};
presencePublishDebounceMs?: number;
generateId?: () => string;
scopes?: readonly AccessScope[];
}): StagingSession<Registry>;Open a staging session hosted in field (default "session") of the document hostDocId, without the compile-time host field check of the hostType form. See StagingSessionParams for the options.
UnsettledDocument
export interface UnsettledDocumentOne document that has NOT settled — the shape behind DatadataClient.getUnsettledDocuments and UnsettledDocumentsError. The three fields are exactly the ones that decide settledness, so they also explain it: subscribed: "subscribing" is a handshake the server never answered, status: "pending" a document it never reported on, and a non-zero optimisticUpdates writes it never acked. subscribed: null is a document nothing subscribes to — it is listed because a lifecycle write (create, delete, rename, restore) on it is still pending.
docId: string;The document's id.
optimisticUpdates: number;How many of its writes are still unacknowledged by the server.
status: DocumentStatus;The document's current status; "pending" means the server has not reported on it yet.
subscribed: SubscriptionState | null;Its subscription state, or null when nothing subscribes to it.
WriteError
export interface WriteErrorA rejected fire-and-forget write, delivered ambiently via DatadataClient.onWriteError. Awaited writes reject their promise with a WriteRejectedError instead and do NOT surface here, so every failure is reported exactly once. The document's own status settles to its truthful lifecycle state (a denied create becomes notFound, a denied update stays available) — the failure is an event, not a document status.
This is an EVENT, not retained state: the client keeps no per-document "last write error" to query later. A consumer that needs one (e.g. a batch importer checking whether a doc's write failed before sending the next batch) either awaits the write — updateDocumentAndWait and friends stage at the call (a dynamic-schema client holds a create or update until its type's schema has synced), so a whole batch can be issued and settled with one Promise.allSettled — or accumulates these reports itself under its own retention policy, the way @repo/datadata-react-jotai's writeErrorsAtom does.
category: DocumentErrorCategory;Why the write was rejected — the same union WriteRejectedError carries. DocumentErrorCategory is exported from @repo/datadata/events, not from @repo/datadata/client: one public home per type.
clientEventId: string | null;The rejected write's event id. null when no sent event is involved: a write refused locally before it was sent (queueFull), unsent Yjs edits discarded, or a server error that names no event.
code?: string;The refusal code of a commandRefused rejection — the mutator's own, or one the library reserves (COMMAND_REFUSAL_CODES in @repo/datadata/commands). Absent for every other category.
docId: string;The document the rejected write targeted.
kind: WriteErrorKind;Which write was rejected; null when the client no longer tracks it.
message: string;A human-readable explanation, for logs and developer-facing display.
Types
ConnectionToken
export type ConnectionToken = object | string | number | symbol;Names one connection of the host's transport, so the client can tell the events of the current connection from those of one it replaced. Any value the host has one of per connection and none of twice: the socket object itself, a counter, a symbol. Compared by identity (Object.is).
DatadataClientFor
export type DatadataClientFor<S extends SchemaRegistry, C extends CommandRegistry = CommandRegistry> = DatadataClientInterface<RegistryFor<S>, C>;The typed client for a schema map and its command map — exactly what createClient returns for them. C types the sessions' command calls; it defaults to the untyped CommandRegistry, for a client given no defineCommands map. See DatadataClient for the shared surface.
WriteErrorCallback
export type WriteErrorCallback = (error: WriteError) => void;A listener for rejected fire-and-forget writes; see DatadataClient.onWriteError.
WriteErrorKind
export type WriteErrorKind = "create" | "update" | "command" | "delete" | "restore" | "rename" | "purge" | null;Which write a WriteError concerns. null when the failing write is no longer tracked and its kind can't be inferred (a late or duplicate error whose optimistic entry has already been cleared).