import { SDKCollectionClient } from "@rebasepro/types"; import { CollectionClient } from "./collection"; import { OfflineStore, PendingMutation } from "./offline-store"; /** * The SDK's local-first sync engine. * * The design goal is that the network is never in the way of the interface. * That comes from three properties, and everything in this file exists to * serve one of them: * * 1. **A local database, not a response cache.** Rows are stored normalized, * by id, and queries are answered by evaluating them * ({@link ./offline-query}) against those rows. A row written offline * therefore appears in *every* list it belongs to, a row edited in one view * updates in all of them, and `findById` answers for a row only ever seen * inside a `find`. Server responses are merged into this database rather * than replacing it, and a row with unsynced local writes keeps them: the * user's own change never flickers away underneath them. * * 2. **Writes are decided locally.** A write made while offline is applied to * the local database and queued — with the state it replaced, so a server * rejection can be undone — and the call returns immediately. When * connectivity is known to be gone the request is not even attempted, so * an offline write costs nothing instead of a timeout. * * 3. **Reads are reactive.** {@link OfflineManager.observe} emits from the * local database synchronously-ish, revalidates in the background, and * re-emits whenever anything touches the rows it covers — a local write, * a replay landing, a rollback, a realtime event, or another browser tab. * * What it deliberately is not: a full replica. Only rows the app has actually * read or written are local, so a query the cache cannot fully answer is * flagged `partial` rather than silently reported as complete. */ export interface OfflineConfig { /** * Persistence backend. Defaults to IndexedDB in the browser and an * in-memory store elsewhere; pass a custom implementation (e.g. backed by * AsyncStorage in React Native) to persist in other environments. */ store?: OfflineStore; /** * Cached query snapshots kept per collection; the least recently written * are evicted beyond this. Defaults to 50. */ maxCachedQueriesPerCollection?: number; /** * Cached rows kept per collection. Rows with unsynced local writes are * never evicted. Defaults to 5 000. */ maxCachedRowsPerCollection?: number; /** * Ceiling for the exponential retry backoff, in milliseconds. Replay * retries start at one second and double up to this. `0` disables * automatic retries entirely — `client.offline.sync()`, a sign-in, and the * browser's `online` event still trigger one. Defaults to 60 000. */ syncIntervalMs?: number; /** * Keep several tabs of the same app in step over a `BroadcastChannel`: a * write in one appears in the others, and only one of them replays the * shared queue. Defaults to on for the IndexedDB store (a real shared * database) and off for the in-memory one, which no other tab can see. */ crossTab?: boolean; /** * How many times a mutation rejected with a *retryable* status (429, 503, * …) is replayed before it is given up on and rolled back. Network * failures do not count against this: being offline is not an attempt. * Defaults to 5. */ maxRetries?: number; /** * Called when the server *rejects* a queued mutation (a 4xx/5xx that will * not resolve on its own — validation, RLS, a since-deleted row). The * local rows it wrote are rolled back to the state they had before it, and * any later queued writes to the same rows are discarded with it — they * were built on a change that never happened. Each discarded mutation is * reported here. * * Network failures are not errors: those mutations stay queued. */ onSyncError?: (error: Error, mutation: PendingMutation) => void; } /** A snapshot of the engine's state, for a status indicator. */ export interface OfflineStatus { /** False once a request has failed to reach the server, until one does. */ online: boolean; /** True while the queue is being replayed. */ syncing: boolean; /** Local writes not yet accepted by the server. */ pending: number; /** When the queue was last fully drained. */ lastSyncedAt?: number; /** The last replay rejection, if any. */ lastError?: string; } export type { LiveResult, ObserveOptions, RowSnapshotMeta } from "./collection"; /** What `client.offline` exposes to the app. */ export interface OfflineApi { /** Replay the queue now. Resolves with what was flushed and what remains. */ sync(): Promise<{ flushed: number; remaining: number; }>; /** The queued mutations for the current user, oldest first. */ pending(): Promise; /** The current engine state — connectivity, queue depth, last sync. */ status(): OfflineStatus; /** Subscribe to {@link OfflineStatus} changes (for a sync indicator). */ onStatusChange(listener: (status: OfflineStatus) => void): () => void; /** * Drop the current user's queued mutations AND their local rows. * Destructive: queued writes are lost, not replayed. For "discard my * offline changes" flows, not for sign-out (scoping already isolates * users). */ clear(): Promise; /** Subscribe to queue-size changes (for a "pending changes" badge). */ onQueueChange(listener: (count: number) => void): () => void; } /** True when a read failed because there was neither network nor local data. */ export declare function isOfflineError(error: unknown): boolean; type AnyRow = Record; type InnerFactory = (slug: string) => SDKCollectionClient; export declare class OfflineManager { private readonly store; private readonly maxCachedQueries; private readonly maxCachedRows; private readonly maxRetries; private readonly onSyncError?; private readonly createInner; private readonly inners; private readonly connectivity; private scope; /** The local database: normalized rows and query snapshots per collection. */ private collections; /** In-memory mirror of the current scope's queue, in replay order. */ private queue; /** * The mutation currently on the wire, if any. * * `flush` awaits `replay(op)` with `op` still at the head of `queue`, so for * the whole duration of that request the in-flight op is also the queue's * *tail* whenever it is the only entry. Both shortcuts in `enqueue` reach * for the tail, and neither may touch an op the server is already reading: * * - Coalescing an update into it mutates a payload that has already been * serialized and sent, and `drop` then removes the whole entry on ACK — * so the second edit is neither sent nor kept. A silently lost write. * - Cancelling it out against a delete assumes the server never saw the * create. It is seeing it right now, so the row would be created and the * delete never queued — an orphan row nothing will ever remove. * * Guarding on the id rather than on a boolean keeps this correct if the * flush loop ever sends more than one op at a time. */ private inFlightId; private queueLoad?; /** Serializes enqueues so concurrent writes keep the order the app made them. */ private enqueueChain; private flushPromise?; private queueListeners; private statusListeners; private observers; private refreshPending; private revCounter; private disposed; private currentStatus; private readonly channel?; private readonly tabId; readonly api: OfflineApi; constructor(config: OfflineConfig, createInner: InnerFactory); /** * Cache and queue are partitioned per signed-in user: cached rows are * RLS-filtered for the user who fetched them, and queued writes must * replay under the credentials that made them — so neither may ever leak * across a sign-out/sign-in on a shared browser. */ setScope(uid: string | undefined): void; /** * Throw away every local row, for a scope change or an explicit clear. * * The state objects are replaced rather than emptied, so a load still in * flight for the previous user fails its identity check and discards what * it read instead of grafting it onto the new one. The replacements are * marked ready: nothing needs loading until something asks, and observers * have to be told *now* that the rows they are showing are gone. */ private resetCollections; /** Release listeners, timers and the cross-tab channel (client.close()). */ dispose(): void; wrap(slug: string, inner: CollectionClient): CollectionClient; private observe; private observeById; private observersFor; /** Cheap change detection: which rows, in what order, at which revision. */ private signature; private notifyCollection; /** Connectivity came back (or the user changed): re-read everything live. */ private revalidateAll; private collectionState; private ensureCollection; private snapshotFor; private hasLocalAnswer; /** * Answer a query from the local database. * * With a snapshot, the server's own page — its ids, order and total — is * the skeleton, and the local rows fill it in: rows deleted locally drop * out, rows edited locally show the edit, and rows *created* locally join * the first page if they match. Without one, the query is evaluated * outright over every cached row, which is the best that can be done for a * query the server has never answered here. */ private answer; private localFind; private rawLocalRow; private localRow; private setLocalRow; /** * Drop a row and, when the server is the one saying it is gone, remember * that. "I looked it up and it does not exist" is real knowledge: without * it, opening a deleted row while offline would report a missing local * database instead of a missing row. */ private removeLocalRow; private forgetTombstone; /** * Merge server rows into the local database. A row with unsynced local * writes keeps them: the server's copy is the base the queued mutations * are re-applied to, not a replacement for what the user did. * * Rows that came back unchanged keep their identity and revision, so a * refetch that changed nothing does not re-render every live query that * touches them — or rewrite them all to disk. */ private ingest; /** * Fold the queued mutations for one row over a base, newest last. * `afterMutationId` skips everything up to and including that mutation, * which is how a just-replayed write avoids being applied on top of the * server's response to it. */ private applyPendingToRow; private recordSnapshot; /** * A write changed which rows belong in a list, and only the server can say * how — a row it generated is in no cached page, and the totals moved. * Re-run every live query on the collection; queries nobody is watching * are corrected by their next `find`. * * Coalesced per microtask so a burst of writes costs one round trip, and * skipped entirely while offline, where the local database is already the * best answer available. */ private scheduleRefresh; private evictRows; private evictSnapshots; private ensureQueueLoaded; private enqueue; private hasPending; /** Is this row one the server has never been told about? */ private isLocallyCreated; /** How many rows the queue adds to (or removes from) a server-side count. */ private pendingDelta; sync(): Promise<{ flushed: number; remaining: number; }>; private flush; private replay; /** * Take the server's version of a row the client created offline. * * The server may have assigned a different id — a serial column ignores * the id we invented — in which case every local trace of the temporary id * has to move with it, including queued writes that were made against it * before it was ever sent. */ private adoptServerRow; /** * Write a server row over the local one, ignoring the mutation that just * produced it — re-applying that would put the pre-server values back on * top of the server's answer — but keeping every write queued *after* it. * Those are still unsent, and dropping them here would make the row snap * back to the server's version in front of the user, only to change again * when they replay a moment later. */ private ingestReplaced; /** * The server refused a mutation. Put back what it changed, and discard the * queued writes that were built on top of it: an edit to a row whose * creation was rejected can only fail the same way, and applying it would * leave the local database claiming a row the server does not have. * * The cascade stops the moment a later write stops *depending* on the * rejected one. An `update` reads the row it edits, so it is doomed with * it; a `create` overwrites the row outright and a `delete` needs nothing * of it, so both stand on their own and are kept — dropping them would * silently lose writes the server would have accepted. */ private rejectMutation; /** Every row id a mutation writes to. */ private idsOf; private drop; /** Replay uses unwrapped clients: a failure must never re-enqueue itself. */ private innerFor; private withLock; private broadcast; private onBroadcast; /** Re-read one collection from the store, replacing what is in memory. */ private reloadCollection; private reloadQueue; private afterQueueChange; private notifyQueue; private patchStatus; private countKey; private rowKey; private absentKey; private queueKey; private readCache; private writeCache; private deleteCache; }