@rebasepro/client 0.10.0 → 0.10.1-canary.14e53ae

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ /** Prepare a row for the store. */
2
+ export declare function dehydrateRow<T extends Record<string, unknown>>(row: T): Record<string, unknown>;
3
+ /** Restore a row read back from the store. */
4
+ export declare function hydrateRow<T extends Record<string, unknown>>(row: Record<string, unknown>): T;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Whether the network is worth trying, and when to try again after it wasn't.
3
+ *
4
+ * `navigator.onLine` is necessary but not sufficient: it reports the state of
5
+ * the network interface, so it stays `true` behind a captive portal, on a
6
+ * connection that resolves DNS but reaches nothing, and while the API itself
7
+ * is down. This tracks what actually happened to requests as well, so the
8
+ * first failure is the only one an app pays for — everything after it inside
9
+ * the backoff window skips the doomed round trip and answers from the local
10
+ * store immediately, which is the difference between an app that freezes when
11
+ * the wifi drops and one that does not.
12
+ */
13
+ /** The request never reached the server, so nothing was decided by it. */
14
+ export declare function isNetworkError(error: unknown): boolean;
15
+ /** Is this failure worth another attempt later? */
16
+ export declare function isRetryableError(error: unknown): boolean;
17
+ export interface ConnectivityOptions {
18
+ /** First retry delay after a failure. Defaults to 1 000 ms. */
19
+ initialBackoffMs?: number;
20
+ /** Ceiling for the doubling retry delay. Defaults to 60 000 ms. */
21
+ maxBackoffMs?: number;
22
+ /**
23
+ * Let a known-failed connection suppress further attempts until the
24
+ * backoff window opens. On by default — it is what makes a read or write
25
+ * during an outage instant instead of a timeout. Turn it off when nothing
26
+ * will ever wake the client up again (no retry timer, no `online` event),
27
+ * where suppressing attempts would mean never recovering.
28
+ */
29
+ respectBackoff?: boolean;
30
+ /** Injected for tests. */
31
+ now?: () => number;
32
+ /** Injected for tests; must return a handle `clearTimeout` accepts. */
33
+ setTimer?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
34
+ clearTimer?: (handle: ReturnType<typeof setTimeout>) => void;
35
+ }
36
+ export declare class ConnectivityMonitor {
37
+ private state;
38
+ private backoffMs;
39
+ private readonly initialBackoffMs;
40
+ private readonly maxBackoffMs;
41
+ private retryAt;
42
+ private timer?;
43
+ private listeners;
44
+ private readonly respectBackoff;
45
+ private readonly now;
46
+ private readonly setTimer;
47
+ private readonly clearTimer;
48
+ /** Called when the backoff window expires, to drive an automatic retry. */
49
+ onRetryDue?: () => void;
50
+ private readonly handleOnline;
51
+ private readonly handleOffline;
52
+ constructor(options?: ConnectivityOptions);
53
+ /** What the app should be told: are we connected? */
54
+ isOnline(): boolean;
55
+ /**
56
+ * Should this request even be sent? False means "answer from the local
57
+ * store instead" — the request would only burn a timeout to reach the same
58
+ * conclusion the last one already did.
59
+ */
60
+ shouldAttempt(): boolean;
61
+ /** A request reached the server. */
62
+ markSuccess(): void;
63
+ /** A request did not reach the server: we are offline until proven otherwise. */
64
+ markFailure(): void;
65
+ /**
66
+ * Back off and try again later without claiming the connection is gone.
67
+ * This is what a 429 or a 503 deserves — the server answered, so the app
68
+ * is demonstrably online; it just should not hammer.
69
+ */
70
+ deferRetry(): void;
71
+ /** Milliseconds until the next attempt is allowed; 0 when one is allowed now. */
72
+ msUntilRetry(): number;
73
+ onChange(listener: (online: boolean) => void): () => void;
74
+ dispose(): void;
75
+ private scheduleRetry;
76
+ private clearPendingTimer;
77
+ private setState;
78
+ }
@@ -0,0 +1,51 @@
1
+ import { FilterValues, FindResult, LogicalCondition, FilterCondition, OrderByTuple, WhereFilterOp } from "@rebasepro/types";
2
+ import { FindParams } from "./transport";
3
+ /** The server's page size when the caller does not ask for one. */
4
+ export declare const DEFAULT_PAGE_SIZE = 20;
5
+ /**
6
+ * Three-way compare with SQL's type coercion but not its collation. Returns
7
+ * `undefined` when the two values are not ordered relative to each other,
8
+ * which is how NULL propagates through a comparison.
9
+ */
10
+ export declare function compareValues(a: unknown, b: unknown): number | undefined;
11
+ /** Equality with the wire's type erasure allowed for, but never across NULL. */
12
+ export declare function looseEquals(a: unknown, b: unknown): boolean;
13
+ /** Evaluate one canonical operator against one row value. */
14
+ export declare function matchesOperator(rowValue: unknown, op: WhereFilterOp, filterValue: unknown): boolean;
15
+ /** Evaluate a `where` clause: every field, and every tuple on a field, AND-ed. */
16
+ export declare function matchesWhere(row: Record<string, unknown>, where: FilterValues<string> | undefined): boolean;
17
+ /** Evaluate a nested and/or tree. */
18
+ export declare function matchesLogical(row: Record<string, unknown>, condition: LogicalCondition | FilterCondition | undefined): boolean;
19
+ /**
20
+ * Approximate the server's full-text search with a case-insensitive substring
21
+ * scan over the row's own string fields. Narrower than the real thing (no
22
+ * stemming, no configured search columns), and it never matches a field the
23
+ * cached row does not carry — a local list may therefore be missing rows the
24
+ * server would have returned, which is why {@link isExactlyEvaluable} refuses
25
+ * to call a search query exact.
26
+ */
27
+ export declare function matchesSearch(row: Record<string, unknown>, searchString: string | undefined): boolean;
28
+ /** Does this row belong in the result set for `params`, ignoring pagination? */
29
+ export declare function matchesParams(row: Record<string, unknown>, params?: FindParams): boolean;
30
+ /**
31
+ * Sort in place, Postgres-style: nulls last ascending, first descending, with
32
+ * the row id as a tiebreak so paging through an unsorted-but-equal run does
33
+ * not shuffle rows between pages.
34
+ */
35
+ export declare function sortRows<M extends Record<string, unknown>>(rows: M[], orderBy?: OrderByTuple): M[];
36
+ /** Resolve `page`/`offset`/`limit` the way the server does. */
37
+ export declare function resolvePagination(params?: FindParams): {
38
+ limit: number;
39
+ offset: number;
40
+ };
41
+ /**
42
+ * Can a locally evaluated answer to `params` be trusted to match the server's,
43
+ * assuming the cache holds every row of the collection?
44
+ *
45
+ * `include` pulls in rows from other collections that this evaluator never
46
+ * sees, and `searchString` is only approximated — both make the local answer a
47
+ * best effort rather than an equivalent one.
48
+ */
49
+ export declare function isExactlyEvaluable(params?: FindParams): boolean;
50
+ /** Run a full query — filter, sort, paginate — over a set of rows. */
51
+ export declare function runLocalQuery<M extends Record<string, unknown>>(rows: M[], params?: FindParams): FindResult<M>;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Persistence backends for the SDK's offline support.
3
+ *
4
+ * The store is a dumb, namespaced key/value surface with two areas: a read
5
+ * cache (normalized rows, query snapshots and sync bookkeeping) and a mutation
6
+ * queue (local writes waiting to reach the server). All structure — per-user
7
+ * prefixes, the `row|`/`q|`/`meta|` namespaces, mutation ordering — is owned by
8
+ * the {@link OfflineManager}; the store only promises that a prefix listing
9
+ * comes back in lexicographic key order, which is what makes the queue a FIFO.
10
+ *
11
+ * Two implementations ship with the SDK:
12
+ * - {@link IndexedDBOfflineStore} — the browser default; survives reloads.
13
+ * - {@link MemoryOfflineStore} — the fallback everywhere IndexedDB does not
14
+ * exist (Node, React Native, tests); survives only the process.
15
+ *
16
+ * Environments with neither (React Native + AsyncStorage, Electron main, …)
17
+ * implement this interface and pass it via `offline.store`.
18
+ */
19
+ /** A cached value plus the moment it was written, for LRU eviction. */
20
+ export interface OfflineCacheEntry {
21
+ value: unknown;
22
+ cachedAt: number;
23
+ }
24
+ /** A cache entry with its key, as returned by prefix listings. */
25
+ export interface OfflineCacheRecord extends OfflineCacheEntry {
26
+ key: string;
27
+ }
28
+ /** What a mutation has to put back if the server rejects it. */
29
+ export interface MutationRollback {
30
+ /**
31
+ * The rows as they were locally *before* this mutation was applied, keyed
32
+ * by id. A `null` value means "the row did not exist" — restoring it is a
33
+ * delete, not a write.
34
+ */
35
+ rows: Record<string, Record<string, unknown> | null>;
36
+ }
37
+ /**
38
+ * A local write waiting to be replayed against the server.
39
+ *
40
+ * `mutationId` orders the queue globally (not per collection): a create in one
41
+ * collection may be the parent a later insert in another references, so replay
42
+ * must preserve the order the app issued the writes in. It is lexicographically
43
+ * time-ordered and carries a random suffix, so two browser tabs writing in the
44
+ * same millisecond produce distinct, still-roughly-ordered ids instead of
45
+ * silently overwriting each other's queue entry.
46
+ */
47
+ export interface PendingMutation {
48
+ /** Unique, lexicographically sortable identity — also the queue key suffix. */
49
+ mutationId: string;
50
+ collection: string;
51
+ type: "create" | "createMany" | "update" | "delete";
52
+ /** Target row id for update/delete, and the (client-generated) id of an offline create. */
53
+ id?: string | number;
54
+ /**
55
+ * True when the SDK minted this create's id itself. Only such creates may
56
+ * cancel out against a later offline delete: a freshly generated UUID
57
+ * cannot name a row the server already has, while a caller-supplied id
58
+ * can — and there the delete must still replay to remove the server row.
59
+ */
60
+ generatedId?: boolean;
61
+ /** The payload: a row for create/update, an array of rows for createMany. */
62
+ data?: Record<string, unknown> | Record<string, unknown>[];
63
+ upsert?: boolean;
64
+ queuedAt: number;
65
+ /** How many times replay has been attempted (diagnostics for a stuck queue). */
66
+ attempts?: number;
67
+ /** The last replay failure's message, when there was one. */
68
+ lastError?: string;
69
+ /** Local state to restore if the server rejects this mutation. */
70
+ rollback?: MutationRollback;
71
+ }
72
+ export interface OfflineStore {
73
+ getCache(key: string): Promise<OfflineCacheEntry | undefined>;
74
+ setCache(key: string, entry: OfflineCacheEntry): Promise<void>;
75
+ /** Write many entries at once — one transaction where the backend has them. */
76
+ setCacheMany(entries: {
77
+ key: string;
78
+ entry: OfflineCacheEntry;
79
+ }[]): Promise<void>;
80
+ deleteCache(keys: string[]): Promise<void>;
81
+ /** Every cache key starting with `prefix`, with its write time (for eviction). */
82
+ listCache(prefix: string): Promise<{
83
+ key: string;
84
+ cachedAt: number;
85
+ }[]>;
86
+ /** As {@link listCache}, but with the values — the local query engine's input. */
87
+ listCacheEntries(prefix: string): Promise<OfflineCacheRecord[]>;
88
+ enqueue(key: string, mutation: PendingMutation): Promise<void>;
89
+ dequeue(key: string): Promise<void>;
90
+ /** Queued mutations whose key starts with `prefix`, in lexicographic key order. */
91
+ listQueue(prefix: string): Promise<PendingMutation[]>;
92
+ /** Remove every cache entry and queued mutation whose key starts with `prefix`. */
93
+ clear(prefix: string): Promise<void>;
94
+ }
95
+ export declare function createMutationId(now?: number): string;
96
+ /**
97
+ * In-memory store: the default outside the browser and the workhorse of the
98
+ * test suite. Values are deep-copied on the way in and out so a caller
99
+ * mutating a returned row cannot silently edit the "persisted" copy — the
100
+ * IndexedDB implementation gets the same guarantee for free from structured
101
+ * cloning, and the two must not differ in aliasing behaviour.
102
+ */
103
+ export declare class MemoryOfflineStore implements OfflineStore {
104
+ private cache;
105
+ private queue;
106
+ getCache(key: string): Promise<OfflineCacheEntry | undefined>;
107
+ setCache(key: string, entry: OfflineCacheEntry): Promise<void>;
108
+ setCacheMany(entries: {
109
+ key: string;
110
+ entry: OfflineCacheEntry;
111
+ }[]): Promise<void>;
112
+ deleteCache(keys: string[]): Promise<void>;
113
+ listCache(prefix: string): Promise<{
114
+ key: string;
115
+ cachedAt: number;
116
+ }[]>;
117
+ listCacheEntries(prefix: string): Promise<OfflineCacheRecord[]>;
118
+ enqueue(key: string, mutation: PendingMutation): Promise<void>;
119
+ dequeue(key: string): Promise<void>;
120
+ listQueue(prefix: string): Promise<PendingMutation[]>;
121
+ clear(prefix: string): Promise<void>;
122
+ }
123
+ /**
124
+ * IndexedDB-backed store — the browser default, so cached rows and queued
125
+ * writes survive a reload or a browser restart. Everything lives in one
126
+ * database with two object stores; keys are the manager's full prefixed
127
+ * strings, so multiple users (scopes) share the database without ever
128
+ * sharing entries.
129
+ */
130
+ export declare class IndexedDBOfflineStore implements OfflineStore {
131
+ private dbPromise?;
132
+ private open;
133
+ private store;
134
+ getCache(key: string): Promise<OfflineCacheEntry | undefined>;
135
+ setCache(key: string, entry: OfflineCacheEntry): Promise<void>;
136
+ setCacheMany(entries: {
137
+ key: string;
138
+ entry: OfflineCacheEntry;
139
+ }[]): Promise<void>;
140
+ deleteCache(keys: string[]): Promise<void>;
141
+ listCache(prefix: string): Promise<{
142
+ key: string;
143
+ cachedAt: number;
144
+ }[]>;
145
+ listCacheEntries(prefix: string): Promise<OfflineCacheRecord[]>;
146
+ enqueue(key: string, mutation: PendingMutation): Promise<void>;
147
+ dequeue(key: string): Promise<void>;
148
+ listQueue(prefix: string): Promise<PendingMutation[]>;
149
+ clear(prefix: string): Promise<void>;
150
+ }
@@ -0,0 +1,306 @@
1
+ import { SDKCollectionClient } from "@rebasepro/types";
2
+ import { CollectionClient } from "./collection";
3
+ import { OfflineStore, PendingMutation } from "./offline-store";
4
+ /**
5
+ * The SDK's local-first sync engine.
6
+ *
7
+ * The design goal is that the network is never in the way of the interface.
8
+ * That comes from three properties, and everything in this file exists to
9
+ * serve one of them:
10
+ *
11
+ * 1. **A local database, not a response cache.** Rows are stored normalized,
12
+ * by id, and queries are answered by evaluating them
13
+ * ({@link ./offline-query}) against those rows. A row written offline
14
+ * therefore appears in *every* list it belongs to, a row edited in one view
15
+ * updates in all of them, and `findById` answers for a row only ever seen
16
+ * inside a `find`. Server responses are merged into this database rather
17
+ * than replacing it, and a row with unsynced local writes keeps them: the
18
+ * user's own change never flickers away underneath them.
19
+ *
20
+ * 2. **Writes are decided locally.** A write made while offline is applied to
21
+ * the local database and queued — with the state it replaced, so a server
22
+ * rejection can be undone — and the call returns immediately. When
23
+ * connectivity is known to be gone the request is not even attempted, so
24
+ * an offline write costs nothing instead of a timeout.
25
+ *
26
+ * 3. **Reads are reactive.** {@link OfflineManager.observe} emits from the
27
+ * local database synchronously-ish, revalidates in the background, and
28
+ * re-emits whenever anything touches the rows it covers — a local write,
29
+ * a replay landing, a rollback, a realtime event, or another browser tab.
30
+ *
31
+ * What it deliberately is not: a full replica. Only rows the app has actually
32
+ * read or written are local, so a query the cache cannot fully answer is
33
+ * flagged `partial` rather than silently reported as complete.
34
+ */
35
+ export interface OfflineConfig {
36
+ /**
37
+ * Persistence backend. Defaults to IndexedDB in the browser and an
38
+ * in-memory store elsewhere; pass a custom implementation (e.g. backed by
39
+ * AsyncStorage in React Native) to persist in other environments.
40
+ */
41
+ store?: OfflineStore;
42
+ /**
43
+ * Cached query snapshots kept per collection; the least recently written
44
+ * are evicted beyond this. Defaults to 50.
45
+ */
46
+ maxCachedQueriesPerCollection?: number;
47
+ /**
48
+ * Cached rows kept per collection. Rows with unsynced local writes are
49
+ * never evicted. Defaults to 5 000.
50
+ */
51
+ maxCachedRowsPerCollection?: number;
52
+ /**
53
+ * Ceiling for the exponential retry backoff, in milliseconds. Replay
54
+ * retries start at one second and double up to this. `0` disables
55
+ * automatic retries entirely — `client.offline.sync()`, a sign-in, and the
56
+ * browser's `online` event still trigger one. Defaults to 60 000.
57
+ */
58
+ syncIntervalMs?: number;
59
+ /**
60
+ * Keep several tabs of the same app in step over a `BroadcastChannel`: a
61
+ * write in one appears in the others, and only one of them replays the
62
+ * shared queue. Defaults to on for the IndexedDB store (a real shared
63
+ * database) and off for the in-memory one, which no other tab can see.
64
+ */
65
+ crossTab?: boolean;
66
+ /**
67
+ * How many times a mutation rejected with a *retryable* status (429, 503,
68
+ * …) is replayed before it is given up on and rolled back. Network
69
+ * failures do not count against this: being offline is not an attempt.
70
+ * Defaults to 5.
71
+ */
72
+ maxRetries?: number;
73
+ /**
74
+ * Called when the server *rejects* a queued mutation (a 4xx/5xx that will
75
+ * not resolve on its own — validation, RLS, a since-deleted row). The
76
+ * local rows it wrote are rolled back to the state they had before it, and
77
+ * any later queued writes to the same rows are discarded with it — they
78
+ * were built on a change that never happened. Each discarded mutation is
79
+ * reported here.
80
+ *
81
+ * Network failures are not errors: those mutations stay queued.
82
+ */
83
+ onSyncError?: (error: Error, mutation: PendingMutation) => void;
84
+ }
85
+ /** A snapshot of the engine's state, for a status indicator. */
86
+ export interface OfflineStatus {
87
+ /** False once a request has failed to reach the server, until one does. */
88
+ online: boolean;
89
+ /** True while the queue is being replayed. */
90
+ syncing: boolean;
91
+ /** Local writes not yet accepted by the server. */
92
+ pending: number;
93
+ /** When the queue was last fully drained. */
94
+ lastSyncedAt?: number;
95
+ /** The last replay rejection, if any. */
96
+ lastError?: string;
97
+ }
98
+ export type { LiveResult, ObserveOptions, RowSnapshotMeta } from "./collection";
99
+ /** What `client.offline` exposes to the app. */
100
+ export interface OfflineApi {
101
+ /** Replay the queue now. Resolves with what was flushed and what remains. */
102
+ sync(): Promise<{
103
+ flushed: number;
104
+ remaining: number;
105
+ }>;
106
+ /** The queued mutations for the current user, oldest first. */
107
+ pending(): Promise<PendingMutation[]>;
108
+ /** The current engine state — connectivity, queue depth, last sync. */
109
+ status(): OfflineStatus;
110
+ /** Subscribe to {@link OfflineStatus} changes (for a sync indicator). */
111
+ onStatusChange(listener: (status: OfflineStatus) => void): () => void;
112
+ /**
113
+ * Drop the current user's queued mutations AND their local rows.
114
+ * Destructive: queued writes are lost, not replayed. For "discard my
115
+ * offline changes" flows, not for sign-out (scoping already isolates
116
+ * users).
117
+ */
118
+ clear(): Promise<void>;
119
+ /** Subscribe to queue-size changes (for a "pending changes" badge). */
120
+ onQueueChange(listener: (count: number) => void): () => void;
121
+ }
122
+ /** True when a read failed because there was neither network nor local data. */
123
+ export declare function isOfflineError(error: unknown): boolean;
124
+ type AnyRow = Record<string, unknown>;
125
+ type InnerFactory = (slug: string) => SDKCollectionClient<AnyRow>;
126
+ export declare class OfflineManager {
127
+ private readonly store;
128
+ private readonly maxCachedQueries;
129
+ private readonly maxCachedRows;
130
+ private readonly maxRetries;
131
+ private readonly onSyncError?;
132
+ private readonly createInner;
133
+ private readonly inners;
134
+ private readonly connectivity;
135
+ private scope;
136
+ /** The local database: normalized rows and query snapshots per collection. */
137
+ private collections;
138
+ /** In-memory mirror of the current scope's queue, in replay order. */
139
+ private queue;
140
+ private queueLoad?;
141
+ /** Serializes enqueues so concurrent writes keep the order the app made them. */
142
+ private enqueueChain;
143
+ private flushPromise?;
144
+ private queueListeners;
145
+ private statusListeners;
146
+ private observers;
147
+ private refreshPending;
148
+ private revCounter;
149
+ private disposed;
150
+ private currentStatus;
151
+ private readonly channel?;
152
+ private readonly tabId;
153
+ readonly api: OfflineApi;
154
+ constructor(config: OfflineConfig, createInner: InnerFactory);
155
+ /**
156
+ * Cache and queue are partitioned per signed-in user: cached rows are
157
+ * RLS-filtered for the user who fetched them, and queued writes must
158
+ * replay under the credentials that made them — so neither may ever leak
159
+ * across a sign-out/sign-in on a shared browser.
160
+ */
161
+ setScope(uid: string | undefined): void;
162
+ /**
163
+ * Throw away every local row, for a scope change or an explicit clear.
164
+ *
165
+ * The state objects are replaced rather than emptied, so a load still in
166
+ * flight for the previous user fails its identity check and discards what
167
+ * it read instead of grafting it onto the new one. The replacements are
168
+ * marked ready: nothing needs loading until something asks, and observers
169
+ * have to be told *now* that the rows they are showing are gone.
170
+ */
171
+ private resetCollections;
172
+ /** Release listeners, timers and the cross-tab channel (client.close()). */
173
+ dispose(): void;
174
+ wrap<M extends AnyRow>(slug: string, inner: CollectionClient<M>): CollectionClient<M>;
175
+ private observe;
176
+ private observeById;
177
+ private observersFor;
178
+ /** Cheap change detection: which rows, in what order, at which revision. */
179
+ private signature;
180
+ private notifyCollection;
181
+ /** Connectivity came back (or the user changed): re-read everything live. */
182
+ private revalidateAll;
183
+ private collectionState;
184
+ private ensureCollection;
185
+ private snapshotFor;
186
+ private hasLocalAnswer;
187
+ /**
188
+ * Answer a query from the local database.
189
+ *
190
+ * With a snapshot, the server's own page — its ids, order and total — is
191
+ * the skeleton, and the local rows fill it in: rows deleted locally drop
192
+ * out, rows edited locally show the edit, and rows *created* locally join
193
+ * the first page if they match. Without one, the query is evaluated
194
+ * outright over every cached row, which is the best that can be done for a
195
+ * query the server has never answered here.
196
+ */
197
+ private answer;
198
+ private localFind;
199
+ private rawLocalRow;
200
+ private localRow;
201
+ private setLocalRow;
202
+ /**
203
+ * Drop a row and, when the server is the one saying it is gone, remember
204
+ * that. "I looked it up and it does not exist" is real knowledge: without
205
+ * it, opening a deleted row while offline would report a missing local
206
+ * database instead of a missing row.
207
+ */
208
+ private removeLocalRow;
209
+ private forgetTombstone;
210
+ /**
211
+ * Merge server rows into the local database. A row with unsynced local
212
+ * writes keeps them: the server's copy is the base the queued mutations
213
+ * are re-applied to, not a replacement for what the user did.
214
+ *
215
+ * Rows that came back unchanged keep their identity and revision, so a
216
+ * refetch that changed nothing does not re-render every live query that
217
+ * touches them — or rewrite them all to disk.
218
+ */
219
+ private ingest;
220
+ /**
221
+ * Fold the queued mutations for one row over a base, newest last.
222
+ * `afterMutationId` skips everything up to and including that mutation,
223
+ * which is how a just-replayed write avoids being applied on top of the
224
+ * server's response to it.
225
+ */
226
+ private applyPendingToRow;
227
+ private recordSnapshot;
228
+ /**
229
+ * A write changed which rows belong in a list, and only the server can say
230
+ * how — a row it generated is in no cached page, and the totals moved.
231
+ * Re-run every live query on the collection; queries nobody is watching
232
+ * are corrected by their next `find`.
233
+ *
234
+ * Coalesced per microtask so a burst of writes costs one round trip, and
235
+ * skipped entirely while offline, where the local database is already the
236
+ * best answer available.
237
+ */
238
+ private scheduleRefresh;
239
+ private evictRows;
240
+ private evictSnapshots;
241
+ private ensureQueueLoaded;
242
+ private enqueue;
243
+ private hasPending;
244
+ /** Is this row one the server has never been told about? */
245
+ private isLocallyCreated;
246
+ /** How many rows the queue adds to (or removes from) a server-side count. */
247
+ private pendingDelta;
248
+ sync(): Promise<{
249
+ flushed: number;
250
+ remaining: number;
251
+ }>;
252
+ private flush;
253
+ private replay;
254
+ /**
255
+ * Take the server's version of a row the client created offline.
256
+ *
257
+ * The server may have assigned a different id — a serial column ignores
258
+ * the id we invented — in which case every local trace of the temporary id
259
+ * has to move with it, including queued writes that were made against it
260
+ * before it was ever sent.
261
+ */
262
+ private adoptServerRow;
263
+ /**
264
+ * Write a server row over the local one, ignoring the mutation that just
265
+ * produced it — re-applying that would put the pre-server values back on
266
+ * top of the server's answer — but keeping every write queued *after* it.
267
+ * Those are still unsent, and dropping them here would make the row snap
268
+ * back to the server's version in front of the user, only to change again
269
+ * when they replay a moment later.
270
+ */
271
+ private ingestReplaced;
272
+ /**
273
+ * The server refused a mutation. Put back what it changed, and discard the
274
+ * queued writes that were built on top of it: an edit to a row whose
275
+ * creation was rejected can only fail the same way, and applying it would
276
+ * leave the local database claiming a row the server does not have.
277
+ *
278
+ * The cascade stops the moment a later write stops *depending* on the
279
+ * rejected one. An `update` reads the row it edits, so it is doomed with
280
+ * it; a `create` overwrites the row outright and a `delete` needs nothing
281
+ * of it, so both stand on their own and are kept — dropping them would
282
+ * silently lose writes the server would have accepted.
283
+ */
284
+ private rejectMutation;
285
+ /** Every row id a mutation writes to. */
286
+ private idsOf;
287
+ private drop;
288
+ /** Replay uses unwrapped clients: a failure must never re-enqueue itself. */
289
+ private innerFor;
290
+ private withLock;
291
+ private broadcast;
292
+ private onBroadcast;
293
+ /** Re-read one collection from the store, replacing what is in memory. */
294
+ private reloadCollection;
295
+ private reloadQueue;
296
+ private afterQueueChange;
297
+ private notifyQueue;
298
+ private patchStatus;
299
+ private countKey;
300
+ private rowKey;
301
+ private absentKey;
302
+ private queueKey;
303
+ private readCache;
304
+ private writeCache;
305
+ private deleteCache;
306
+ }
@@ -26,6 +26,20 @@ export interface RebaseClientConfig {
26
26
  * Defaults to `"/api"`; override only if the server mounts it elsewhere.
27
27
  */
28
28
  apiPath?: string;
29
+ /**
30
+ * Origin to use instead of {@link baseUrl} for URLs that are handed to the
31
+ * browser to fetch on its own — storage file downloads and previews.
32
+ *
33
+ * API *requests* always go to `baseUrl`; this only changes URLs the SDK
34
+ * *returns* (e.g. `storage.getSignedUrl`). It exists for proxied setups:
35
+ * when `baseUrl` routes through an authenticated middleman (the Rebase
36
+ * console's Studio proxy), a plain `<img src>` or a copied link cannot
37
+ * satisfy the middleman's auth — but the file route itself is reachable
38
+ * directly at the origin server and secured by its own scoped `?token=`.
39
+ * Set this to that server's public origin (no path; {@link apiPath} is
40
+ * appended) and returned file URLs point straight at it.
41
+ */
42
+ storageUrlOrigin?: string;
29
43
  fetch?: typeof globalThis.fetch;
30
44
  onUnauthorized?: () => Promise<boolean>;
31
45
  websocketUrl?: string;
@@ -56,6 +70,8 @@ export interface Transport {
56
70
  setOnUnauthorized: (handler: () => Promise<boolean>) => void;
57
71
  readonly baseUrl: string;
58
72
  readonly apiPath: string;
73
+ /** See {@link RebaseClientConfig.storageUrlOrigin}. Undefined = use `baseUrl`. */
74
+ readonly storageUrlOrigin?: string;
59
75
  readonly fetchFn: typeof globalThis.fetch;
60
76
  getHeaders: (init?: RequestInit) => Record<string, string>;
61
77
  resolveToken: () => Promise<string | null>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/client",
3
3
  "type": "module",
4
- "version": "0.10.0",
4
+ "version": "0.10.1-canary.14e53ae",
5
5
  "description": "HTTP SDK client for the Rebase custom backend",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -29,9 +29,9 @@
29
29
  "./package.json": "./package.json"
30
30
  },
31
31
  "dependencies": {
32
- "@rebasepro/utils": "0.10.0",
33
- "@rebasepro/types": "0.10.0",
34
- "@rebasepro/common": "0.10.0"
32
+ "@rebasepro/common": "0.10.1-canary.14e53ae",
33
+ "@rebasepro/types": "0.10.1-canary.14e53ae",
34
+ "@rebasepro/utils": "0.10.1-canary.14e53ae"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@jest/globals": "^30.4.1",
@@ -39,6 +39,7 @@
39
39
  "@types/node": "^25.9.3",
40
40
  "@types/ws": "^8.18.1",
41
41
  "cross-env": "^10.1.0",
42
+ "fake-indexeddb": "^6.2.5",
42
43
  "jest": "^30.4.2",
43
44
  "ts-jest": "^29.4.11",
44
45
  "tsd": "^0.33.0",