@convex-localfirst/core 0.1.0
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/LICENSE +21 -0
- package/README.md +17 -0
- package/dist/collection.d.ts +101 -0
- package/dist/collection.js +100 -0
- package/dist/declarative.d.ts +56 -0
- package/dist/declarative.js +86 -0
- package/dist/engine.d.ts +237 -0
- package/dist/engine.js +934 -0
- package/dist/functionName.d.ts +3 -0
- package/dist/functionName.js +15 -0
- package/dist/id.d.ts +5 -0
- package/dist/id.js +22 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +27 -0
- package/dist/indexedDbStore.d.ts +53 -0
- package/dist/indexedDbStore.js +328 -0
- package/dist/internal.d.ts +12 -0
- package/dist/internal.js +22 -0
- package/dist/leadership.d.ts +48 -0
- package/dist/leadership.js +69 -0
- package/dist/manifest.d.ts +84 -0
- package/dist/manifest.js +28 -0
- package/dist/memoryStore.d.ts +33 -0
- package/dist/memoryStore.js +130 -0
- package/dist/multiTab.d.ts +69 -0
- package/dist/multiTab.js +96 -0
- package/dist/mutationCall.d.ts +20 -0
- package/dist/mutationCall.js +40 -0
- package/dist/ordering.d.ts +14 -0
- package/dist/ordering.js +35 -0
- package/dist/rebase.d.ts +14 -0
- package/dist/rebase.js +54 -0
- package/dist/relations.d.ts +42 -0
- package/dist/relations.js +89 -0
- package/dist/setMerge.d.ts +63 -0
- package/dist/setMerge.js +93 -0
- package/dist/status.d.ts +2 -0
- package/dist/status.js +10 -0
- package/dist/storage.d.ts +53 -0
- package/dist/storage.js +1 -0
- package/dist/transport.d.ts +43 -0
- package/dist/transport.js +93 -0
- package/dist/types.d.ts +173 -0
- package/dist/types.js +1 -0
- package/dist/view.d.ts +12 -0
- package/dist/view.js +74 -0
- package/package.json +42 -0
package/dist/engine.js
ADDED
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
import { attachRelations, relationTables } from "./relations.js";
|
|
2
|
+
import { createDefaultIdFactory, createOpId } from "./id.js";
|
|
3
|
+
import { defaultFunctionName } from "./functionName.js";
|
|
4
|
+
import { createLocalFirstMutationCall } from "./mutationCall.js";
|
|
5
|
+
import { computeCounterDelta, computeSetDelta, isCounterDelta, isSetDelta } from "./setMerge.js";
|
|
6
|
+
import { createOfflineTransport } from "./transport.js";
|
|
7
|
+
// Backstop for the pull drain loop; the real exits are "no hasMore" and "cursor stalled".
|
|
8
|
+
// Generous so a large cold start drains in one go.
|
|
9
|
+
const MAX_PULL_ROUNDS = 10000;
|
|
10
|
+
/** True for the server's idempotent no-op-delete ack ({ noop: true }) — an accepted op
|
|
11
|
+
* that produced NO canonical change, so it must be dropped from the outbox explicitly. */
|
|
12
|
+
function isNoopAck(serverResult) {
|
|
13
|
+
return typeof serverResult === "object" && serverResult !== null && serverResult.noop === true;
|
|
14
|
+
}
|
|
15
|
+
export class LocalFirstEngine {
|
|
16
|
+
manifest;
|
|
17
|
+
// Private: raw store reads are UNSCOPED. App code reads through the hooks
|
|
18
|
+
// (useQuery/useLiveQuery), which enforce the scoped fail-closed guard.
|
|
19
|
+
store;
|
|
20
|
+
clientId;
|
|
21
|
+
userId;
|
|
22
|
+
transport;
|
|
23
|
+
nameOf;
|
|
24
|
+
idFactory;
|
|
25
|
+
clock;
|
|
26
|
+
retry;
|
|
27
|
+
syncTimeoutMs;
|
|
28
|
+
sleep;
|
|
29
|
+
status = {
|
|
30
|
+
online: true,
|
|
31
|
+
syncing: false,
|
|
32
|
+
pendingMutations: 0,
|
|
33
|
+
lastPushAt: null,
|
|
34
|
+
lastPullAt: null,
|
|
35
|
+
lastError: null,
|
|
36
|
+
blockedBySchemaMismatch: false,
|
|
37
|
+
partial: false
|
|
38
|
+
};
|
|
39
|
+
opStatuses = new Map();
|
|
40
|
+
// Separate from the store's data-change listeners so a status change (online/syncing/
|
|
41
|
+
// pending) wakes only useSyncStatus, not every data query (avoids a re-render storm).
|
|
42
|
+
statusListeners = new Set();
|
|
43
|
+
// Refcounted reactive subscriptions, keyed by scope: many hooks on one scope share ONE
|
|
44
|
+
// watch + drain loop instead of a per-hook herd of redundant pulls.
|
|
45
|
+
scopeWatchers = new Map();
|
|
46
|
+
// Removes the engine-owned browser online/offline listeners (noop outside a browser).
|
|
47
|
+
disposeConnectivity = () => { };
|
|
48
|
+
// Multi-tab leadership gate (set by the provider's TabLeadership). A follower suppresses
|
|
49
|
+
// only the BACKGROUND batch push so the shared outbox is pushed by one tab; pull/watch,
|
|
50
|
+
// explicit mutations, and the reconnect flush are never gated. Defaults true (lone tab/
|
|
51
|
+
// SSR/tests sync as normal).
|
|
52
|
+
syncEnabled = true;
|
|
53
|
+
// High-water mark keeping operation createdAt (the I4 replay key) strictly increasing per
|
|
54
|
+
// engine, so a backward wall-clock step can't reorder two local edits. seeded across
|
|
55
|
+
// reloads from durable ops by seedTimestampHighWater().
|
|
56
|
+
tsHighWater = 0;
|
|
57
|
+
constructor(options) {
|
|
58
|
+
this.manifest = options.manifest;
|
|
59
|
+
this.store = options.store;
|
|
60
|
+
this.clientId = options.clientId;
|
|
61
|
+
this.userId = options.userId ?? null;
|
|
62
|
+
this.transport = options.transport ?? createOfflineTransport();
|
|
63
|
+
this.nameOf = options.nameOf ?? defaultFunctionName;
|
|
64
|
+
this.idFactory = options.idFactory ?? createDefaultIdFactory();
|
|
65
|
+
this.clock = options.clock ?? (() => Date.now());
|
|
66
|
+
this.retry = options.retry ?? { retries: 3, baseDelayMs: 100 };
|
|
67
|
+
this.syncTimeoutMs = options.syncTimeoutMs ?? 15000;
|
|
68
|
+
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
69
|
+
// Reflect any operations already durable in the store (e.g. after a reload)
|
|
70
|
+
// so getStatus() is accurate without waiting for the first sync.
|
|
71
|
+
void this.refreshPendingCount();
|
|
72
|
+
void this.seedTimestampHighWater();
|
|
73
|
+
// Offline-first connectivity, owned by the engine so EVERY consumer (headless or
|
|
74
|
+
// React) gets it — not just apps that remember to wire window events themselves.
|
|
75
|
+
this.disposeConnectivity = this.wireConnectivity();
|
|
76
|
+
}
|
|
77
|
+
/** Wall-clock timestamp that never goes backward within this engine, so a backward
|
|
78
|
+
* clock step cannot reorder two local edits (I4). */
|
|
79
|
+
monotonicNow() {
|
|
80
|
+
const t = Math.max(this.clock(), this.tsHighWater + 1);
|
|
81
|
+
this.tsHighWater = t;
|
|
82
|
+
return t;
|
|
83
|
+
}
|
|
84
|
+
/** Seed the high-water from durable ops so monotonic order holds across reloads.
|
|
85
|
+
* ponytail: an op created before this async seed resolves could predate it — needs a
|
|
86
|
+
* backward clock step AND reload AND a same-row edit in that window; acceptable. */
|
|
87
|
+
async seedTimestampHighWater() {
|
|
88
|
+
try {
|
|
89
|
+
for (const op of await this.store.getAllOperations()) {
|
|
90
|
+
if (op.createdAt > this.tsHighWater) {
|
|
91
|
+
this.tsHighWater = op.createdAt;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// In-session monotonicity still holds from 0; only the cross-reload seed is lost.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Merge a status patch and notify subscribers so useSyncStatus re-renders. */
|
|
100
|
+
setStatus(patch) {
|
|
101
|
+
this.status = { ...this.status, ...patch };
|
|
102
|
+
for (const listener of Array.from(this.statusListeners)) {
|
|
103
|
+
listener();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Resolve a function reference to its stable name. The React adapter keys effects on
|
|
108
|
+
* this string, not the reference object, because Convex's `api` proxy returns a fresh
|
|
109
|
+
* object per access — keying on identity would re-run the effect every render (a sync loop).
|
|
110
|
+
*/
|
|
111
|
+
functionName(reference) {
|
|
112
|
+
return this.safeName(reference);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Run a transport call and reflect connectivity in status.online (threw → offline,
|
|
116
|
+
* returned → online). ponytail: heuristic — a server-side error also reads as offline;
|
|
117
|
+
* the navigator online/offline events give finer detection.
|
|
118
|
+
*/
|
|
119
|
+
async tracked(fn) {
|
|
120
|
+
try {
|
|
121
|
+
const result = await fn();
|
|
122
|
+
this.setStatus({ online: true });
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
this.setStatus({ online: false });
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
hasLocalQuery(reference) {
|
|
131
|
+
return this.getQueryDefinition(reference) !== null;
|
|
132
|
+
}
|
|
133
|
+
hasLocalMutation(reference) {
|
|
134
|
+
return this.getMutationDefinition(reference) !== null;
|
|
135
|
+
}
|
|
136
|
+
async query(reference, args) {
|
|
137
|
+
const definition = this.getQueryDefinition(reference);
|
|
138
|
+
if (!definition) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
if (this.scopedQueryMissingScope(definition.table, args)) {
|
|
142
|
+
// Fail closed: a workspace/project query whose args lack the scope value
|
|
143
|
+
// must not return the whole cached table (which can span scopes). Same
|
|
144
|
+
// invariant runLocalQuery enforces for the collection() builder.
|
|
145
|
+
return definition.initial;
|
|
146
|
+
}
|
|
147
|
+
const rows = await this.store.getRows(definition.table);
|
|
148
|
+
const scoped = this.filterToScope(definition.table, rows, args);
|
|
149
|
+
const visibleRows = scoped.filter((row) => !row._deleted);
|
|
150
|
+
return definition.run(visibleRows, args, { now: this.clock() });
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Keep only rows in the active scope (owner==userId for byUser, field==value for
|
|
154
|
+
* byWorkspace/byProject). The client caches every scope the user can see, so a query
|
|
155
|
+
* with an incomplete filter could otherwise observe another scope's rows; enforcing it
|
|
156
|
+
* here mirrors the server's I7. `custom` scopes have no client-known field → server-only.
|
|
157
|
+
*/
|
|
158
|
+
filterToScope(table, rows, scopeArgs) {
|
|
159
|
+
const scope = this.manifest.tables[table]?.scope;
|
|
160
|
+
if (!scope) {
|
|
161
|
+
return rows;
|
|
162
|
+
}
|
|
163
|
+
if (scope.kind === "byUser") {
|
|
164
|
+
// Anonymous/local-only mode (no userId) has no owner to match; leave as-is.
|
|
165
|
+
return this.userId == null ? rows : rows.filter((row) => row[scope.field] === this.userId);
|
|
166
|
+
}
|
|
167
|
+
if (scope.kind === "byWorkspace" || scope.kind === "byProject") {
|
|
168
|
+
const field = scope.kind === "byWorkspace" ? scope.workspaceIdField : scope.projectIdField;
|
|
169
|
+
const value = scopeArgs?.[field];
|
|
170
|
+
// Missing value is already failed-closed by scopedQueryMissingScope; if it ever
|
|
171
|
+
// reaches here without one, do not silently widen to all scopes — return none.
|
|
172
|
+
return value == null ? [] : rows.filter((row) => row[field] === value);
|
|
173
|
+
}
|
|
174
|
+
return rows;
|
|
175
|
+
}
|
|
176
|
+
/** True when `table` is workspace/project-scoped but `args` carry no scope value. */
|
|
177
|
+
scopedQueryMissingScope(table, args) {
|
|
178
|
+
const definition = this.manifest.tables[table];
|
|
179
|
+
if (!definition || (definition.scope.kind !== "byWorkspace" && definition.scope.kind !== "byProject")) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
const field = definition.scope.kind === "byWorkspace" ? definition.scope.workspaceIdField : definition.scope.projectIdField;
|
|
183
|
+
return args?.[field] == null;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* @internal All visible (non-deleted) rows for a table, from the derived view
|
|
187
|
+
* (I1). UNSCOPED plumbing for useLiveQuery's subscription — the hook only ever
|
|
188
|
+
* returns these through `applyLocalQuery` (the scoped guard). Not an app API.
|
|
189
|
+
*/
|
|
190
|
+
async tableRows(table) {
|
|
191
|
+
const rows = await this.store.getRows(table);
|
|
192
|
+
return rows.filter((row) => !row._deleted);
|
|
193
|
+
}
|
|
194
|
+
/** Every table a plan reads: its base table plus any relation targets/join tables. */
|
|
195
|
+
tablesForPlan(plan) {
|
|
196
|
+
return [plan.table, ...relationTables(plan.relations)];
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Apply a query plan to already-fetched rows (keyed by table), enforcing the
|
|
200
|
+
* scoped fail-closed guard and attaching relations in memory. Synchronous so the
|
|
201
|
+
* React hook (useLiveQuery) can call it at render and cannot bypass the guard by
|
|
202
|
+
* running plan.run directly.
|
|
203
|
+
*/
|
|
204
|
+
applyLocalQuery(plan, rowsByTable) {
|
|
205
|
+
if (this.scopedQueryMissingScope(plan.table, plan.scopeValues)) {
|
|
206
|
+
// Fail closed: a workspace/project query with no scope value must not return
|
|
207
|
+
// the whole local cache (which can span scopes). Empty .scope({}) lands here.
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
const scoped = this.filterToScope(plan.table, rowsByTable[plan.table] ?? [], plan.scopeValues);
|
|
211
|
+
const base = plan.run(scoped);
|
|
212
|
+
return attachRelations(base, plan.relations, rowsByTable);
|
|
213
|
+
}
|
|
214
|
+
async runLocalQuery(plan) {
|
|
215
|
+
const rowsByTable = {};
|
|
216
|
+
for (const table of this.tablesForPlan(plan)) {
|
|
217
|
+
rowsByTable[table] = await this.tableRows(table);
|
|
218
|
+
}
|
|
219
|
+
return this.applyLocalQuery(plan, rowsByTable);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* One-call imperative read for the service-layer path: background-refresh the plan's
|
|
223
|
+
* scope (offline-safe, never throws), then return the merged local rows (canonical +
|
|
224
|
+
* pending). Prefer over hand-orchestrating refreshPlan + runLocalQuery. For reactive UI
|
|
225
|
+
* use useLiveQuery instead.
|
|
226
|
+
*/
|
|
227
|
+
async read(plan) {
|
|
228
|
+
await this.refreshPlan(plan);
|
|
229
|
+
return this.runLocalQuery(plan);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Read a single live row by id (== row[idField] == _id), or undefined. Local-only, no
|
|
233
|
+
* server pull — for the "I just wrote id X, read it back" case (the write already flushes
|
|
234
|
+
* via its own .server push). Includes pending optimistic state. For a possibly-cold row
|
|
235
|
+
* (e.g. a deep link), use a scoped query so refreshPlan can pull it first.
|
|
236
|
+
*/
|
|
237
|
+
async getRow(table, id) {
|
|
238
|
+
const rows = await this.tableRows(table);
|
|
239
|
+
return rows.find((row) => row._id === id);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Pull scope for a query plan: the explicit workspace/project value when the
|
|
243
|
+
* table is scoped that way, else the authed user. Key format mirrors the
|
|
244
|
+
* declarative path so pull cursors and server membership checks line up.
|
|
245
|
+
*/
|
|
246
|
+
scopeForPlan(plan) {
|
|
247
|
+
const definition = this.manifest.tables[plan.table];
|
|
248
|
+
if (!definition) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
const scope = definition.scope;
|
|
252
|
+
if (scope.kind === "byUser") {
|
|
253
|
+
return this.userId ? { kind: "byUser", key: `u:${this.userId}`, table: plan.table } : null;
|
|
254
|
+
}
|
|
255
|
+
if (scope.kind === "byWorkspace") {
|
|
256
|
+
const value = plan.scopeValues?.[scope.workspaceIdField];
|
|
257
|
+
return value == null ? null : { kind: "byWorkspace", key: `byWorkspace:${String(value)}`, table: plan.table };
|
|
258
|
+
}
|
|
259
|
+
if (scope.kind === "byProject") {
|
|
260
|
+
const value = plan.scopeValues?.[scope.projectIdField];
|
|
261
|
+
return value == null ? null : { kind: "byProject", key: `byProject:${String(value)}`, table: plan.table };
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
/** Background sync for a mounted plan (push pending + pull its scope). Never throws. */
|
|
266
|
+
async refreshPlan(plan) {
|
|
267
|
+
const scope = this.scopeForPlan(plan);
|
|
268
|
+
if (!scope) {
|
|
269
|
+
// A workspace/project table with no .scope({...}) can neither filter nor
|
|
270
|
+
// pull — it would silently show stale/empty data. Surface the misconfig
|
|
271
|
+
// (this is a developer error, not a runtime condition, so always warn).
|
|
272
|
+
const kind = this.manifest.tables[plan.table]?.scope.kind;
|
|
273
|
+
if ((kind === "byWorkspace" || kind === "byProject") && !plan.scopeValues) {
|
|
274
|
+
console.warn(`[convex-localfirst] useLiveQuery on "${plan.table}" is missing .scope({...}); it will not sync from the server.`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
await this.syncOnce(scope ? [scope] : []);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// Swallowed: status.lastError already captures it; background sync must not throw to React.
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/** True when the transport offers a reactive change feed (server push). When
|
|
285
|
+
* false, callers fall back to polling for real-time. */
|
|
286
|
+
get reactive() {
|
|
287
|
+
return typeof this.transport.subscribe === "function";
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Reactive sync for a mounted plan: subscribe to the transport's change feed for
|
|
291
|
+
* this plan's scope and drain (pull) on every server-side change — true server
|
|
292
|
+
* push, no polling. Returns an unsubscribe, or `null` when the transport is not
|
|
293
|
+
* reactive (the caller should fall back to polling) or the plan has no scope.
|
|
294
|
+
*/
|
|
295
|
+
watchPlan(plan) {
|
|
296
|
+
const subscribe = this.transport.subscribe;
|
|
297
|
+
if (!subscribe) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
const scope = this.scopeForPlan(plan);
|
|
301
|
+
if (!scope) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
return this.watchScope(scope, subscribe.bind(this.transport));
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Reactive sync for a declarative (server-defined) query — the `useQuery` path. Like
|
|
308
|
+
* `watchPlan` but resolves the scope from the query DEFINITION. Returns an unsubscribe,
|
|
309
|
+
* or `null` when not reactive / no scope. This is what makes our `useQuery` reactive
|
|
310
|
+
* like `convex/react`'s.
|
|
311
|
+
*/
|
|
312
|
+
watchQuery(reference, args) {
|
|
313
|
+
const subscribe = this.transport.subscribe;
|
|
314
|
+
if (!subscribe) {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
const definition = this.getQueryDefinition(reference);
|
|
318
|
+
if (!definition || this.scopedQueryMissingScope(definition.table, args)) {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
const scope = definition.scope?.(args) ?? this.scopeForTable(definition.table);
|
|
322
|
+
if (!scope) {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
return this.watchScope(scope, subscribe.bind(this.transport));
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Refcounted entry point: many hooks watching the SAME scope share ONE watch + drain
|
|
329
|
+
* loop (started on the first watcher, torn down on the last). Returns an idempotent unwatch.
|
|
330
|
+
*/
|
|
331
|
+
watchScope(scope, subscribe) {
|
|
332
|
+
const key = scope.key;
|
|
333
|
+
let entry = this.scopeWatchers.get(key);
|
|
334
|
+
if (!entry) {
|
|
335
|
+
entry = { count: 0, dispose: this.startScopeWatch(scope, subscribe) };
|
|
336
|
+
this.scopeWatchers.set(key, entry);
|
|
337
|
+
}
|
|
338
|
+
entry.count++;
|
|
339
|
+
let released = false;
|
|
340
|
+
return () => {
|
|
341
|
+
if (released) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
released = true;
|
|
345
|
+
entry.count--;
|
|
346
|
+
if (entry.count === 0) {
|
|
347
|
+
entry.dispose();
|
|
348
|
+
this.scopeWatchers.delete(key);
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Drive one scope's subscription. The doorbell carries no data, so each fire triggers a
|
|
354
|
+
* real `pullScopes` drain, then re-subscribes at the advanced cursor: a fixed-cursor
|
|
355
|
+
* watch grows until it saturates the page limit and goes deaf, so re-pinning keeps the
|
|
356
|
+
* window small. Only resubscribing when the cursor moved avoids an empty-fire loop.
|
|
357
|
+
*/
|
|
358
|
+
startScopeWatch(scope, subscribe) {
|
|
359
|
+
let disposed = false;
|
|
360
|
+
let unsubscribe = () => { };
|
|
361
|
+
let draining = false;
|
|
362
|
+
let refireQueued = false;
|
|
363
|
+
let cursor = null;
|
|
364
|
+
const subscribeAt = (at) => {
|
|
365
|
+
if (disposed) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
unsubscribe = subscribe({
|
|
369
|
+
clientId: this.clientId,
|
|
370
|
+
userId: this.userId,
|
|
371
|
+
schemaVersion: this.manifest.schemaVersion,
|
|
372
|
+
scopes: [scope],
|
|
373
|
+
cursors: { [scope.key]: at }
|
|
374
|
+
}, onDoorbell);
|
|
375
|
+
};
|
|
376
|
+
const onDoorbell = () => {
|
|
377
|
+
if (disposed) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (draining) {
|
|
381
|
+
// A change arrived mid-drain; coalesce into ONE re-drain after this pass so
|
|
382
|
+
// a burst can't stampede overlapping pulls.
|
|
383
|
+
refireQueued = true;
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
draining = true;
|
|
387
|
+
void this.pullScopes([scope])
|
|
388
|
+
.catch((error) => {
|
|
389
|
+
// A reactive drain must never throw into the subscription. pullScopes does
|
|
390
|
+
// NOT set lastError on its own (only syncOnce does), so surface it here so a
|
|
391
|
+
// failing server-push (auth/query/transport error) is visible to the UI.
|
|
392
|
+
this.setStatus({ lastError: error instanceof Error ? error.message : String(error) });
|
|
393
|
+
})
|
|
394
|
+
.then(async () => {
|
|
395
|
+
draining = false;
|
|
396
|
+
if (disposed) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const next = await this.store.getCursor(scope.key);
|
|
400
|
+
if (next !== cursor) {
|
|
401
|
+
// The log advanced — repin the watch to the new tail (bounds payload).
|
|
402
|
+
cursor = next;
|
|
403
|
+
unsubscribe();
|
|
404
|
+
subscribeAt(cursor);
|
|
405
|
+
}
|
|
406
|
+
else if (refireQueued) {
|
|
407
|
+
refireQueued = false;
|
|
408
|
+
onDoorbell();
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
};
|
|
412
|
+
void this.store.getCursor(scope.key).then((c) => {
|
|
413
|
+
if (disposed) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
cursor = c;
|
|
417
|
+
subscribeAt(cursor);
|
|
418
|
+
});
|
|
419
|
+
return () => {
|
|
420
|
+
disposed = true;
|
|
421
|
+
unsubscribe();
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
/** Subscribe to local DATA changes (rows). Used by useQuery. */
|
|
425
|
+
subscribe(listener) {
|
|
426
|
+
return this.store.subscribe(listener);
|
|
427
|
+
}
|
|
428
|
+
/** Subscribe to SYNC STATUS changes (online/syncing/pending). Used by useSyncStatus. */
|
|
429
|
+
subscribeStatus(listener) {
|
|
430
|
+
this.statusListeners.add(listener);
|
|
431
|
+
return () => {
|
|
432
|
+
this.statusListeners.delete(listener);
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
mutate(reference, args) {
|
|
436
|
+
const definition = this.getMutationDefinition(reference);
|
|
437
|
+
if (!definition) {
|
|
438
|
+
throw new Error("Cannot run local-first mutation because the function is not in the manifest");
|
|
439
|
+
}
|
|
440
|
+
const opId = createOpId(this.clientId);
|
|
441
|
+
const planned = definition.plan(args, {
|
|
442
|
+
now: this.clock(),
|
|
443
|
+
clientId: this.clientId,
|
|
444
|
+
userId: this.userId,
|
|
445
|
+
localId: (table) => this.idFactory(table)
|
|
446
|
+
});
|
|
447
|
+
const id = planned.kind === "insert" ? planned.id ?? this.idFactory(planned.table) : planned.id;
|
|
448
|
+
// Stamp the table's idField onto the inserted value so an OPTIMISTIC row carries
|
|
449
|
+
// its id field exactly like a server-synced one (createSyncFunctions sets
|
|
450
|
+
// value[idField] = localId on the server). Without this, row[idField] is undefined
|
|
451
|
+
// until the first sync round-trip while row._id is set — a real optimistic-vs-synced
|
|
452
|
+
// inconsistency that forces every reader to special-case `_id`. id wins (it IS the
|
|
453
|
+
// assigned local id), matching the server's re-stamp, so the two never diverge.
|
|
454
|
+
const idField = this.manifest.tables[planned.table]?.idField;
|
|
455
|
+
const insertValue = planned.kind === "insert"
|
|
456
|
+
? idField
|
|
457
|
+
? { ...planned.value, [idField]: id }
|
|
458
|
+
: planned.value
|
|
459
|
+
: undefined;
|
|
460
|
+
const operation = {
|
|
461
|
+
opId,
|
|
462
|
+
clientId: this.clientId,
|
|
463
|
+
userId: this.userId,
|
|
464
|
+
schemaVersion: this.manifest.schemaVersion,
|
|
465
|
+
functionName: definition.name,
|
|
466
|
+
table: planned.table,
|
|
467
|
+
kind: planned.kind,
|
|
468
|
+
id,
|
|
469
|
+
args: args,
|
|
470
|
+
value: insertValue,
|
|
471
|
+
patch: planned.kind === "patch" ? planned.patch : undefined,
|
|
472
|
+
createdAt: this.monotonicNow(),
|
|
473
|
+
status: "pending"
|
|
474
|
+
};
|
|
475
|
+
const local = this.commitLocal(operation);
|
|
476
|
+
const server = local.then(() => this.pushSingleOperation(operation));
|
|
477
|
+
// The optimistic caller usually awaits only `.local`, so without this nothing handles a
|
|
478
|
+
// failed background push and it becomes an unhandled rejection. The failure is already in
|
|
479
|
+
// status.lastError and the op stays pending for retry. .catch marks it handled without
|
|
480
|
+
// altering `server`, so a caller who awaits `.server`/the call still observes the rejection.
|
|
481
|
+
server.catch(() => { });
|
|
482
|
+
return createLocalFirstMutationCall({
|
|
483
|
+
opId,
|
|
484
|
+
local,
|
|
485
|
+
server,
|
|
486
|
+
status: () => this.operationStatus(operation.opId)
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
async syncOnce(scopes = []) {
|
|
490
|
+
if (this.status.blockedBySchemaMismatch) {
|
|
491
|
+
// A schema mismatch is not retryable by syncing; the client must upgrade.
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
this.setStatus({ syncing: true, lastError: null });
|
|
495
|
+
try {
|
|
496
|
+
await this.pushPendingOperations();
|
|
497
|
+
await this.pullScopes(scopes);
|
|
498
|
+
this.setStatus({ syncing: false });
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
this.setStatus({ syncing: false, lastError: error instanceof Error ? error.message : String(error) });
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
finally {
|
|
505
|
+
await this.refreshPendingCount();
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
getStatus() {
|
|
509
|
+
return this.status;
|
|
510
|
+
}
|
|
511
|
+
/** Reflect externally-known connectivity (e.g. the browser's online/offline events). */
|
|
512
|
+
setOnline(online) {
|
|
513
|
+
this.setStatus({ online });
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Self-wire browser connectivity so offline-first works with zero consumer setup: going
|
|
517
|
+
* offline makes sync a no-op (so reads/writes don't hang on a buffering socket), and
|
|
518
|
+
* reconnect flushes the outbox. Returns a remover; a noop outside a browser. Safe to
|
|
519
|
+
* double-wire with the React provider (setOnline is idempotent; flushPending dedupes).
|
|
520
|
+
*/
|
|
521
|
+
wireConnectivity() {
|
|
522
|
+
if (typeof window === "undefined" || typeof window.addEventListener !== "function") {
|
|
523
|
+
return () => { };
|
|
524
|
+
}
|
|
525
|
+
const onOnline = () => {
|
|
526
|
+
this.setOnline(true);
|
|
527
|
+
this.flushPending();
|
|
528
|
+
};
|
|
529
|
+
const onOffline = () => this.setOnline(false);
|
|
530
|
+
window.addEventListener("online", onOnline);
|
|
531
|
+
window.addEventListener("offline", onOffline);
|
|
532
|
+
if (typeof navigator !== "undefined" && navigator.onLine === false) {
|
|
533
|
+
this.setStatus({ online: false }); // seed current state; no flush on init
|
|
534
|
+
}
|
|
535
|
+
return () => {
|
|
536
|
+
window.removeEventListener("online", onOnline);
|
|
537
|
+
window.removeEventListener("offline", onOffline);
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
/** Remove engine-owned browser listeners. Optional: a singleton engine that lives
|
|
541
|
+
* for the page lifetime need not call this; provided for tests / teardown. */
|
|
542
|
+
dispose() {
|
|
543
|
+
this.disposeConnectivity();
|
|
544
|
+
this.disposeConnectivity = () => { };
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* A HARD offline signal only (navigator.onLine === false), where a push/pull would just
|
|
548
|
+
* hang on a buffering client. Deliberately NOT gated on the softer status.online, which a
|
|
549
|
+
* transient server error can flip false — that would wedge sync off while genuinely online.
|
|
550
|
+
*/
|
|
551
|
+
isLikelyOffline() {
|
|
552
|
+
return typeof navigator !== "undefined" && navigator.onLine === false;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Multi-tab leadership gate (wired by the React provider). Only the leader runs the
|
|
556
|
+
* background batch push; a follower keeps pulling but doesn't re-push the shared outbox.
|
|
557
|
+
* On regaining leadership we flush immediately so an inherited backlog isn't stranded.
|
|
558
|
+
*/
|
|
559
|
+
setSyncEnabled(enabled) {
|
|
560
|
+
if (this.syncEnabled === enabled) {
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
this.syncEnabled = enabled;
|
|
564
|
+
if (enabled) {
|
|
565
|
+
this.flushPending();
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Explicit, UN-gated push of the outbox (reconnect flush, leadership handoff, or a
|
|
570
|
+
* cross-tab wake). Distinct from the background push so an offline-created op in a
|
|
571
|
+
* follower tab is not stranded waiting for the leader's next trigger. Never throws.
|
|
572
|
+
*/
|
|
573
|
+
flushPending() {
|
|
574
|
+
void this.pushPendingOperations({ force: true }).catch(() => {
|
|
575
|
+
// status.lastError already records it; an explicit flush must not throw to callers.
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Cross-tab "db changed" poke: IndexedDB has no cross-tab change event, so when the
|
|
580
|
+
* leader pulls into the shared DB, follower tabs are told to re-derive. Safe to over-call
|
|
581
|
+
* (applyServerChanges is version-folded, so a re-read only surfaces equal-or-newer rows).
|
|
582
|
+
*/
|
|
583
|
+
pokeLocalChange() {
|
|
584
|
+
this.store.notify();
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Background sync triggered by a mounted query: push pending ops and pull this
|
|
588
|
+
* query's scope (if the definition declares one). Never throws — failures are
|
|
589
|
+
* recorded in status.lastError for the UI.
|
|
590
|
+
*/
|
|
591
|
+
async refreshQuery(reference, args) {
|
|
592
|
+
const definition = this.getQueryDefinition(reference);
|
|
593
|
+
if (!definition) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (this.scopedQueryMissingScope(definition.table, args)) {
|
|
597
|
+
// Fail closed: don't pull a scoped table with no scope value (it would build
|
|
598
|
+
// a "byWorkspace:undefined" key). Matches the read-side guard in query().
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
// Prefer an explicit per-query scope; otherwise derive it from the table's
|
|
602
|
+
// scope definition + the authed user so the pull cursor key matches the
|
|
603
|
+
// server's scopeKey (e.g. "u:<userId>").
|
|
604
|
+
const scope = definition.scope?.(args) ?? this.scopeForTable(definition.table);
|
|
605
|
+
try {
|
|
606
|
+
await this.syncOnce(scope ? [scope] : []);
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
// Swallowed: status.lastError already captures it; background sync must not throw to React.
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
scopeForTable(table) {
|
|
613
|
+
const definition = this.manifest.tables[table];
|
|
614
|
+
if (!definition) {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
if (definition.scope.kind === "byUser" && this.userId) {
|
|
618
|
+
return { kind: "byUser", key: `u:${this.userId}`, table };
|
|
619
|
+
}
|
|
620
|
+
// byWorkspace/byProject scopes need a workspace value supplied per query.
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
623
|
+
getQueryDefinition(reference) {
|
|
624
|
+
const name = this.safeName(reference);
|
|
625
|
+
if (!name) {
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
return this.manifest.queries[name] ?? null;
|
|
629
|
+
}
|
|
630
|
+
getMutationDefinition(reference) {
|
|
631
|
+
const name = this.safeName(reference);
|
|
632
|
+
if (!name) {
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
return this.manifest.mutations[name] ?? null;
|
|
636
|
+
}
|
|
637
|
+
safeName(reference) {
|
|
638
|
+
try {
|
|
639
|
+
return this.nameOf(reference);
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* For a patch on a table with declared `setFields`/`counterFields`, rewrite each touched
|
|
647
|
+
* field into a DELTA vs the row's current value, so concurrent edits merge (see setMerge.ts)
|
|
648
|
+
* instead of clobbering: arrays → set deltas, numbers → counter deltas. Runs before the op
|
|
649
|
+
* is persisted/pushed. No-op for non-patches, undeclared fields, or wrong-typed/already-delta values.
|
|
650
|
+
*/
|
|
651
|
+
async applyFieldDeltas(operation) {
|
|
652
|
+
if (operation.kind !== "patch" || !operation.patch) {
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const definition = this.manifest.tables[operation.table];
|
|
656
|
+
const setFields = definition?.setFields ?? [];
|
|
657
|
+
const counterFields = definition?.counterFields ?? [];
|
|
658
|
+
if (setFields.length === 0 && counterFields.length === 0) {
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
const patch = operation.patch; // the Record is mutable (only the property binding is readonly)
|
|
662
|
+
const touchedSets = setFields.filter((field) => Object.prototype.hasOwnProperty.call(patch, field));
|
|
663
|
+
const touchedCounters = counterFields.filter((field) => Object.prototype.hasOwnProperty.call(patch, field));
|
|
664
|
+
if (touchedSets.length === 0 && touchedCounters.length === 0) {
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const current = await this.getRow(operation.table, operation.id);
|
|
668
|
+
for (const field of touchedSets) {
|
|
669
|
+
const value = patch[field];
|
|
670
|
+
if (isSetDelta(value) || !Array.isArray(value)) {
|
|
671
|
+
continue; // already a delta, or a non-array on a set field → leave as plain LWW
|
|
672
|
+
}
|
|
673
|
+
patch[field] = { __lfSet: computeSetDelta(current?.[field], value) };
|
|
674
|
+
}
|
|
675
|
+
for (const field of touchedCounters) {
|
|
676
|
+
const value = patch[field];
|
|
677
|
+
if (isCounterDelta(value) || typeof value !== "number") {
|
|
678
|
+
continue; // already a delta, or a non-number on a counter field → leave as plain LWW
|
|
679
|
+
}
|
|
680
|
+
patch[field] = { __lfCounter: computeCounterDelta(current?.[field], value) };
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
async commitLocal(operation) {
|
|
684
|
+
await this.applyFieldDeltas(operation);
|
|
685
|
+
// I1/I3: enqueuing the op IS the entire local write — the live view is derived from
|
|
686
|
+
// canonical + replayed pending ops. Persist durably FIRST so a failed enqueue (e.g.
|
|
687
|
+
// QuotaExceeded) rejects the caller with nothing half-applied (no phantom "pending").
|
|
688
|
+
await this.store.enqueueOperation(operation);
|
|
689
|
+
this.opStatuses.set(operation.opId, { opId: operation.opId, status: "pending" });
|
|
690
|
+
await this.refreshPendingCount();
|
|
691
|
+
return {
|
|
692
|
+
opId: operation.opId,
|
|
693
|
+
table: operation.table,
|
|
694
|
+
id: operation.id,
|
|
695
|
+
committedAt: this.clock(),
|
|
696
|
+
// Return the resulting row so the caller needs no readback: insert → the optimistic
|
|
697
|
+
// row, patch → the merge after this patch (undefined if not local yet), remove → undefined.
|
|
698
|
+
row: operation.kind === "insert"
|
|
699
|
+
? operation.value
|
|
700
|
+
: operation.kind === "patch"
|
|
701
|
+
? await this.getRow(operation.table, operation.id)
|
|
702
|
+
: undefined
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
async markStatus(opId, status, error) {
|
|
706
|
+
this.opStatuses.set(opId, { opId, status, error });
|
|
707
|
+
await this.store.updateOperationStatus(opId, status, error);
|
|
708
|
+
}
|
|
709
|
+
async pushSingleOperation(operation) {
|
|
710
|
+
await this.markStatus(operation.opId, "pushing");
|
|
711
|
+
// Retry transient failures: the server dedupes by (userId, opId) and re-delivers the
|
|
712
|
+
// confirming change (R9), so a retry after a lost ACK resolves correctly rather than
|
|
713
|
+
// spuriously rejecting call.server. Sustained offline still rejects (op syncs later).
|
|
714
|
+
let response;
|
|
715
|
+
try {
|
|
716
|
+
response = await this.tracked(() => this.withRetry(() => this.transport.push({
|
|
717
|
+
clientId: this.clientId,
|
|
718
|
+
userId: this.userId,
|
|
719
|
+
schemaVersion: this.manifest.schemaVersion,
|
|
720
|
+
mutations: [operation]
|
|
721
|
+
})));
|
|
722
|
+
}
|
|
723
|
+
catch (error) {
|
|
724
|
+
// Our push failed, but under multi-tab the leader may have already pushed this op from
|
|
725
|
+
// the shared outbox and acked it — the write DID succeed. Resolve call.server from that
|
|
726
|
+
// durable outcome instead of rejecting a committed write; reject only if still owed.
|
|
727
|
+
// ponytail: best-effort result — the exact serverResult lives in the tab that got the ack.
|
|
728
|
+
const durable = await this.store.getOperation(operation.opId);
|
|
729
|
+
if (!durable || durable.status === "acked") {
|
|
730
|
+
await this.refreshPendingCount();
|
|
731
|
+
return { ok: true, localId: operation.id };
|
|
732
|
+
}
|
|
733
|
+
throw error;
|
|
734
|
+
}
|
|
735
|
+
if (response.schemaMismatch) {
|
|
736
|
+
// A schema mismatch is not an ack: leave the op pending (do NOT mark acked,
|
|
737
|
+
// or it would drop out of sync and replay forever as local-only state) and
|
|
738
|
+
// block sync until the client upgrades. Mirrors pushPendingOperations.
|
|
739
|
+
this.blockForSchemaMismatch();
|
|
740
|
+
await this.refreshPendingCount();
|
|
741
|
+
throw new Error("Local-first schema version is behind the server; reload to upgrade.");
|
|
742
|
+
}
|
|
743
|
+
await this.store.applyServerChanges(response.changes);
|
|
744
|
+
const rejection = response.rejected.find((item) => item.opId === operation.opId);
|
|
745
|
+
if (rejection) {
|
|
746
|
+
await this.markStatus(operation.opId, "rejected", rejection.message);
|
|
747
|
+
await this.refreshPendingCount();
|
|
748
|
+
throw new Error(rejection.message);
|
|
749
|
+
}
|
|
750
|
+
const accepted = response.accepted.find((item) => item.opId === operation.opId);
|
|
751
|
+
if (!accepted) {
|
|
752
|
+
// Protocol invariant: a non-mismatch response must account for every pushed op (R9).
|
|
753
|
+
// If ours is in neither list the server is buggy; leave it owed (not acked, which would
|
|
754
|
+
// strand it) so the batch path re-pushes it, and surface the error to call.server.
|
|
755
|
+
await this.markStatus(operation.opId, "pending");
|
|
756
|
+
await this.refreshPendingCount();
|
|
757
|
+
throw new Error(`Local-first push: server response did not cover operation ${operation.opId} (neither accepted nor rejected).`);
|
|
758
|
+
}
|
|
759
|
+
await this.markStatus(operation.opId, "acked");
|
|
760
|
+
// A no-op delete is accepted with no confirming change: drop it explicitly so it
|
|
761
|
+
// doesn't linger and replay (a normal op is pruned by its applied/redelivered change).
|
|
762
|
+
if (isNoopAck(accepted.serverResult)) {
|
|
763
|
+
await this.store.dropOperation(operation.opId);
|
|
764
|
+
}
|
|
765
|
+
this.setStatus({ lastPushAt: response.serverTime });
|
|
766
|
+
await this.refreshPendingCount();
|
|
767
|
+
return accepted.serverResult;
|
|
768
|
+
}
|
|
769
|
+
async pushPendingOperations(options) {
|
|
770
|
+
// A follower tab suppresses the background batch push so the leader owns the shared
|
|
771
|
+
// outbox; `force` (reconnect flush / leadership handoff / wake) bypasses the gate.
|
|
772
|
+
if (!this.syncEnabled && !options?.force) {
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const pending = await this.store.getPendingOperations();
|
|
776
|
+
if (pending.length === 0) {
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (this.isLikelyOffline()) {
|
|
780
|
+
// Known offline: leave the ops pending (they flush on the next reconnect) rather
|
|
781
|
+
// than awaiting a push that would hang on the buffering client.
|
|
782
|
+
this.setStatus({ online: false });
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
const response = await this.tracked(() => this.withTimeout(() => this.withRetry(() => this.transport.push({
|
|
786
|
+
clientId: this.clientId,
|
|
787
|
+
userId: this.userId,
|
|
788
|
+
schemaVersion: this.manifest.schemaVersion,
|
|
789
|
+
mutations: pending
|
|
790
|
+
})), "push"));
|
|
791
|
+
if (response.schemaMismatch) {
|
|
792
|
+
this.blockForSchemaMismatch();
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
// Apply confirming changes BEFORE acking: applyServerChanges is what prunes a
|
|
796
|
+
// confirmed op from the outbox. If it threw AFTER we'd marked ops acked, those ops
|
|
797
|
+
// would be neither owed (so never re-pushed) nor canonical — stuck _pending forever.
|
|
798
|
+
await this.store.applyServerChanges(response.changes);
|
|
799
|
+
for (const accepted of response.accepted) {
|
|
800
|
+
await this.markStatus(accepted.opId, "acked");
|
|
801
|
+
// A no-op delete acks with no confirming change, so nothing would ever prune it — drop
|
|
802
|
+
// it explicitly. A normal op is pruned by its change (above/redelivered), so leave it
|
|
803
|
+
// here: dropping before its change arrived would lose the row.
|
|
804
|
+
if (isNoopAck(accepted.serverResult)) {
|
|
805
|
+
await this.store.dropOperation(accepted.opId);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
for (const rejected of response.rejected) {
|
|
809
|
+
await this.markStatus(rejected.opId, "rejected", rejected.message);
|
|
810
|
+
}
|
|
811
|
+
this.setStatus({ lastPushAt: response.serverTime });
|
|
812
|
+
}
|
|
813
|
+
async pullScopes(scopes) {
|
|
814
|
+
if (scopes.length === 0) {
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (this.isLikelyOffline()) {
|
|
818
|
+
// Known offline: serve the local cache (the caller reads it next) instead of
|
|
819
|
+
// awaiting a pull that would hang. Reads are never blocked by connectivity.
|
|
820
|
+
this.setStatus({ online: false });
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const cursors = {};
|
|
824
|
+
for (const scope of scopes) {
|
|
825
|
+
cursors[scope.key] = await this.store.getCursor(scope.key);
|
|
826
|
+
}
|
|
827
|
+
// Drain: the server caps each scope per pull, so keep pulling with the advanced cursors
|
|
828
|
+
// until every scope reports no hasMore (a cold client may be many pages behind). Exits
|
|
829
|
+
// early — marking the cache partial — if a round advances no cursor or hits the backstop.
|
|
830
|
+
let partial = false;
|
|
831
|
+
for (let round = 0; round < MAX_PULL_ROUNDS; round++) {
|
|
832
|
+
const response = await this.tracked(() => this.withTimeout(() => this.withRetry(() => this.transport.pull({
|
|
833
|
+
clientId: this.clientId,
|
|
834
|
+
userId: this.userId,
|
|
835
|
+
schemaVersion: this.manifest.schemaVersion,
|
|
836
|
+
scopes,
|
|
837
|
+
cursors
|
|
838
|
+
})), "pull"));
|
|
839
|
+
if (response.schemaMismatch) {
|
|
840
|
+
// Do not apply changes or advance cursors on a schema mismatch.
|
|
841
|
+
this.blockForSchemaMismatch();
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
await this.store.applyServerChanges(response.changes);
|
|
845
|
+
let advanced = false;
|
|
846
|
+
for (const [scopeKey, cursor] of Object.entries(response.cursors)) {
|
|
847
|
+
if (cursors[scopeKey] !== cursor) {
|
|
848
|
+
advanced = true;
|
|
849
|
+
}
|
|
850
|
+
await this.store.setCursor(scopeKey, cursor);
|
|
851
|
+
cursors[scopeKey] = cursor;
|
|
852
|
+
}
|
|
853
|
+
this.setStatus({ lastPullAt: response.serverTime });
|
|
854
|
+
// Prefer the server's explicit per-scope hasMore; fall back to "this round
|
|
855
|
+
// brought changes" for an older server/transport without it.
|
|
856
|
+
const more = response.hasMore
|
|
857
|
+
? Object.values(response.hasMore).some(Boolean)
|
|
858
|
+
: response.changes.length > 0;
|
|
859
|
+
if (!more) {
|
|
860
|
+
break;
|
|
861
|
+
}
|
|
862
|
+
if (!advanced || round === MAX_PULL_ROUNDS - 1) {
|
|
863
|
+
// More remains but the cursor didn't move (or we hit the backstop): stop and
|
|
864
|
+
// surface that the cache is not fully caught up rather than spin.
|
|
865
|
+
partial = true;
|
|
866
|
+
break;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
this.setStatus({ partial });
|
|
870
|
+
}
|
|
871
|
+
blockForSchemaMismatch() {
|
|
872
|
+
this.setStatus({
|
|
873
|
+
blockedBySchemaMismatch: true,
|
|
874
|
+
lastError: "Schema version mismatch; client must upgrade before syncing"
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Bound a transport call so an unreachable server can't hang sync forever. Races fn()
|
|
879
|
+
* against a timer (cleared on settle, unref'd so it can't keep a process alive).
|
|
880
|
+
* syncTimeoutMs <= 0 disables.
|
|
881
|
+
*/
|
|
882
|
+
withTimeout(fn, label) {
|
|
883
|
+
if (!(this.syncTimeoutMs > 0)) {
|
|
884
|
+
return fn();
|
|
885
|
+
}
|
|
886
|
+
return new Promise((resolve, reject) => {
|
|
887
|
+
const timer = setTimeout(() => {
|
|
888
|
+
reject(new Error(`local-first sync ${label} timed out after ${this.syncTimeoutMs}ms (server unreachable)`));
|
|
889
|
+
}, this.syncTimeoutMs);
|
|
890
|
+
timer.unref?.();
|
|
891
|
+
fn().then((value) => {
|
|
892
|
+
clearTimeout(timer);
|
|
893
|
+
resolve(value);
|
|
894
|
+
}, (error) => {
|
|
895
|
+
clearTimeout(timer);
|
|
896
|
+
reject(error);
|
|
897
|
+
});
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
/** Retry a network call with exponential backoff. */
|
|
901
|
+
async withRetry(fn) {
|
|
902
|
+
let lastError;
|
|
903
|
+
for (let attempt = 0; attempt <= this.retry.retries; attempt++) {
|
|
904
|
+
try {
|
|
905
|
+
return await fn();
|
|
906
|
+
}
|
|
907
|
+
catch (error) {
|
|
908
|
+
lastError = error;
|
|
909
|
+
if (attempt === this.retry.retries) {
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
912
|
+
await this.sleep(this.retry.baseDelayMs * 2 ** attempt);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
throw lastError;
|
|
916
|
+
}
|
|
917
|
+
operationStatus(opId) {
|
|
918
|
+
return this.opStatuses.get(opId) ?? { opId, status: "pending" };
|
|
919
|
+
}
|
|
920
|
+
async refreshPendingCount() {
|
|
921
|
+
const pending = await this.store.getPendingOperations();
|
|
922
|
+
this.setStatus({ pendingMutations: pending.length });
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Headless engine factory — build an engine outside React for imperative consumers (a
|
|
927
|
+
* service layer, a MobX/Zustand store, a worker). The same instance can be passed to the
|
|
928
|
+
* React `ConvexProvider` (its `localFirst.engine` option) to share one engine/outbox/cache.
|
|
929
|
+
* Reads: `query`/`runLocalQuery` (scope-enforced); writes: `mutate`; `subscribe` fires on
|
|
930
|
+
* every local data change.
|
|
931
|
+
*/
|
|
932
|
+
export function createLocalFirstEngine(options) {
|
|
933
|
+
return new LocalFirstEngine(options);
|
|
934
|
+
}
|