@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.
- package/README.md +9 -1
- package/dist/collection.d.ts +55 -3
- package/dist/index.d.ts +23 -0
- package/dist/index.es.js +2045 -10
- package/dist/index.es.js.map +1 -1
- package/dist/offline-codec.d.ts +4 -0
- package/dist/offline-connectivity.d.ts +78 -0
- package/dist/offline-query.d.ts +51 -0
- package/dist/offline-store.d.ts +150 -0
- package/dist/offline.d.ts +306 -0
- package/dist/transport.d.ts +16 -0
- package/package.json +5 -4
- package/src/auth.ts +71 -3
- package/src/collection.ts +118 -2
- package/src/index.ts +58 -1
- package/src/offline-codec.ts +79 -0
- package/src/offline-connectivity.test.ts +157 -0
- package/src/offline-connectivity.ts +207 -0
- package/src/offline-idb-store.test.ts +286 -0
- package/src/offline-integration.test.ts +175 -0
- package/src/offline-query.test.ts +249 -0
- package/src/offline-query.ts +356 -0
- package/src/offline-store.ts +353 -0
- package/src/offline-sync-engine.test.ts +727 -0
- package/src/offline.test.ts +719 -0
- package/src/offline.ts +1687 -0
- package/src/storage.ts +11 -3
- package/src/transport.ts +17 -0
|
@@ -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
|
+
}
|
package/dist/transport.d.ts
CHANGED
|
@@ -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.
|
|
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/
|
|
33
|
-
"@rebasepro/types": "0.10.
|
|
34
|
-
"@rebasepro/
|
|
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",
|