@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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/dist/collection.d.ts +101 -0
  4. package/dist/collection.js +100 -0
  5. package/dist/declarative.d.ts +56 -0
  6. package/dist/declarative.js +86 -0
  7. package/dist/engine.d.ts +237 -0
  8. package/dist/engine.js +934 -0
  9. package/dist/functionName.d.ts +3 -0
  10. package/dist/functionName.js +15 -0
  11. package/dist/id.d.ts +5 -0
  12. package/dist/id.js +22 -0
  13. package/dist/index.d.ts +14 -0
  14. package/dist/index.js +27 -0
  15. package/dist/indexedDbStore.d.ts +53 -0
  16. package/dist/indexedDbStore.js +328 -0
  17. package/dist/internal.d.ts +12 -0
  18. package/dist/internal.js +22 -0
  19. package/dist/leadership.d.ts +48 -0
  20. package/dist/leadership.js +69 -0
  21. package/dist/manifest.d.ts +84 -0
  22. package/dist/manifest.js +28 -0
  23. package/dist/memoryStore.d.ts +33 -0
  24. package/dist/memoryStore.js +130 -0
  25. package/dist/multiTab.d.ts +69 -0
  26. package/dist/multiTab.js +96 -0
  27. package/dist/mutationCall.d.ts +20 -0
  28. package/dist/mutationCall.js +40 -0
  29. package/dist/ordering.d.ts +14 -0
  30. package/dist/ordering.js +35 -0
  31. package/dist/rebase.d.ts +14 -0
  32. package/dist/rebase.js +54 -0
  33. package/dist/relations.d.ts +42 -0
  34. package/dist/relations.js +89 -0
  35. package/dist/setMerge.d.ts +63 -0
  36. package/dist/setMerge.js +93 -0
  37. package/dist/status.d.ts +2 -0
  38. package/dist/status.js +10 -0
  39. package/dist/storage.d.ts +53 -0
  40. package/dist/storage.js +1 -0
  41. package/dist/transport.d.ts +43 -0
  42. package/dist/transport.js +93 -0
  43. package/dist/types.d.ts +173 -0
  44. package/dist/types.js +1 -0
  45. package/dist/view.d.ts +12 -0
  46. package/dist/view.js +74 -0
  47. 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
+ }