@gonvex/client 0.5.2-staging.13 → 0.5.2-staging.14
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/dist/browser-upgrades.js +58 -12
- package/dist/browser-upgrades.js.map +1 -1
- package/dist/control.d.ts +2 -0
- package/dist/control.js +1 -0
- package/dist/control.js.map +1 -1
- package/dist/index.d.ts +37 -3
- package/dist/index.js +624 -192
- package/dist/index.js.map +1 -1
- package/dist/indexeddb-read-view.d.ts +24 -0
- package/dist/indexeddb-read-view.js +164 -0
- package/dist/indexeddb-read-view.js.map +1 -0
- package/dist/indexeddb-replica.d.ts +38 -3
- package/dist/indexeddb-replica.js +381 -101
- package/dist/indexeddb-replica.js.map +1 -1
- package/dist/local-replica.d.ts +102 -4
- package/dist/local-replica.js +812 -86
- package/dist/local-replica.js.map +1 -1
- package/dist/outbox.d.ts +11 -1
- package/dist/outbox.js +69 -10
- package/dist/outbox.js.map +1 -1
- package/dist/paged-read-view.d.ts +13 -0
- package/dist/paged-read-view.js +60 -0
- package/dist/paged-read-view.js.map +1 -0
- package/dist/query-expression.d.ts +39 -2
- package/dist/query-expression.js +106 -5
- package/dist/query-expression.js.map +1 -1
- package/dist/replica-integrity.d.ts +2 -0
- package/dist/replica-integrity.js +10 -0
- package/dist/replica-integrity.js.map +1 -1
- package/dist/replica-record.d.ts +14 -0
- package/dist/replica-record.js +46 -0
- package/dist/replica-record.js.map +1 -0
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readIsCovered, primaryReadKeys } from "@gonvex/local-runtime/read-view";
|
|
2
|
+
import { applyReplicaHashDelta, replicaHashesDigest, replicaRowsHashes } from "./replica-integrity.js";
|
|
2
3
|
import { GonvexErrorReporter } from "./error-reporter.js";
|
|
3
4
|
export { GonvexErrorReporter } from "./error-reporter.js";
|
|
4
5
|
import { optimisticPatchesFromReference, } from "./optimistic.js";
|
|
5
6
|
import { createReducerOutbox, } from "./outbox.js";
|
|
6
7
|
import { LocalReplica, } from "./local-replica.js";
|
|
7
|
-
import { runOfflineLiveQuery } from "./query-expression.js";
|
|
8
|
+
import { runOfflineLiveQuery, liveScopeCoverage } from "./query-expression.js";
|
|
8
9
|
export * from "./error-reporter.js";
|
|
9
10
|
export * from "./optimistic.js";
|
|
10
11
|
export * from "./outbox.js";
|
|
@@ -12,6 +13,8 @@ export * from "./kv-stores.js";
|
|
|
12
13
|
export * from "./client-upgrades.js";
|
|
13
14
|
export * from "./browser-upgrades.js";
|
|
14
15
|
export * from "./signals.js";
|
|
16
|
+
export * from "./paged-read-view.js";
|
|
17
|
+
export { mergeReplicaRecord } from './replica-record.js';
|
|
15
18
|
export * from "./external-auth.js";
|
|
16
19
|
// Keep the mutable LocalReplica implementation private to GonvexClient. The
|
|
17
20
|
// public package exposes only the read-only view plus storage/value types.
|
|
@@ -41,6 +44,7 @@ function createLocalReplicaView(replica) {
|
|
|
41
44
|
cursor: () => replica.cursor(),
|
|
42
45
|
freshness: () => replica.freshness(),
|
|
43
46
|
version: () => replica.version(),
|
|
47
|
+
entityVersion: (entity, id) => replica.entityVersion(entity, id),
|
|
44
48
|
subscribe: (listener) => replica.subscribe(listener),
|
|
45
49
|
hasPendingCommand: (commandId) => replica.hasPendingCommand(commandId),
|
|
46
50
|
getWindow: (signature) => replica.getWindow(signature),
|
|
@@ -51,6 +55,7 @@ function createLocalReplicaView(replica) {
|
|
|
51
55
|
entityRows: (entity) => replica.entityRows(entity),
|
|
52
56
|
entityCompleteness: (entity) => replica.entityCompleteness(entity),
|
|
53
57
|
liveQuery: (signature) => replica.liveQuery(signature),
|
|
58
|
+
liveQuerySnapshot: (signature) => replica.liveQuerySnapshot(signature),
|
|
54
59
|
collectionState: (signature) => replica.collectionState(signature),
|
|
55
60
|
hasLiveQuery: (signature) => replica.hasLiveQuery(signature),
|
|
56
61
|
snapshot: () => replica.snapshot(),
|
|
@@ -117,20 +122,26 @@ const maxReplicaBatchOpens = 256;
|
|
|
117
122
|
// normally settle in a few milliseconds.
|
|
118
123
|
export class GonvexClient {
|
|
119
124
|
url;
|
|
125
|
+
localTableBindings = new Map();
|
|
120
126
|
localBinding;
|
|
121
127
|
localExecutor;
|
|
122
128
|
localStorage;
|
|
123
129
|
localIdentity;
|
|
124
130
|
localLane = Promise.resolve();
|
|
131
|
+
preparationLane = Promise.resolve();
|
|
125
132
|
localReplayScheduled = false;
|
|
133
|
+
waitingLocalEdits = 0;
|
|
126
134
|
replacingLocal = false;
|
|
127
135
|
unsubscribeLocal;
|
|
128
136
|
localCollectionClosers = [];
|
|
137
|
+
localCollectionKeys = new Set();
|
|
138
|
+
localExecutionTables = new Map();
|
|
129
139
|
reducerRejectionHandlers = new Set();
|
|
130
140
|
socket;
|
|
131
141
|
handlers = new Map();
|
|
132
142
|
querySubscriptions = new Map();
|
|
133
143
|
replicaSubscriptions = new Map();
|
|
144
|
+
replicaMetadataListeners = new WeakSet();
|
|
134
145
|
oneShotQueries = new Map();
|
|
135
146
|
telemetryHandlers = new Set();
|
|
136
147
|
pendingMessages = [];
|
|
@@ -179,6 +190,11 @@ export class GonvexClient {
|
|
|
179
190
|
processedReplicaWatermarkRevision = 0;
|
|
180
191
|
pendingReplicaTransactions = [];
|
|
181
192
|
unsubscribeOutbox;
|
|
193
|
+
sharedOutboxStore;
|
|
194
|
+
unsubscribePeerOutbox;
|
|
195
|
+
unsubscribePeerReplica;
|
|
196
|
+
peerRefreshScheduled = false;
|
|
197
|
+
peerRefreshDirty = false;
|
|
182
198
|
unsubscribeBrowserOnline;
|
|
183
199
|
drainingOutbox = false;
|
|
184
200
|
outboxDrainTimer;
|
|
@@ -206,17 +222,37 @@ export class GonvexClient {
|
|
|
206
222
|
this.clientContract = options.clientContract ?? options.localRuntime?.clientContract;
|
|
207
223
|
this.updateRequiredHandler = options.onUpdateRequired;
|
|
208
224
|
this.localBinding = options.localRuntime;
|
|
209
|
-
this.
|
|
225
|
+
for (const table of this.localBinding?.tables ?? []) {
|
|
226
|
+
const references = (this.localBinding?.collections ?? []).filter(ref => ref.replica?.table === table
|
|
227
|
+
&& !Object.keys(ref.replica.equalFilters ?? {}).length && !(ref.replica.excludeWhenSet?.length));
|
|
228
|
+
this.localTableBindings.set(table, { signatures: references.map(ref => querySubscriptionKey(ref, {})),
|
|
229
|
+
columns: [...new Set(references.flatMap(ref => ref.replica?.columns ?? []))] });
|
|
230
|
+
}
|
|
231
|
+
this.localExecutor = options.localRuntime ? deferredLocalExecutor(() => options.localRuntime.create()) : undefined;
|
|
210
232
|
this.localStorage = options.localReplica?.storage;
|
|
211
233
|
this.auth = authFromOptions(options);
|
|
212
234
|
this.telemetryEnabled = options.telemetry === true;
|
|
213
235
|
this.querySubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.querySubscriptionRetentionMs);
|
|
214
236
|
this.replicaSubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.replicaSubscriptionRetentionMs);
|
|
237
|
+
this.sharedOutboxStore = options.outbox?.store?.shared ? options.outbox.store : undefined;
|
|
215
238
|
this.reducerOutbox = createReducerOutbox(options.outbox);
|
|
216
|
-
this.replica = new LocalReplica(options.localReplica?.storage);
|
|
239
|
+
this.replica = new LocalReplica(options.localReplica?.storage, { maxResidentRows: options.localReplica?.maxResidentRows, maxResidentBytes: options.localReplica?.maxResidentBytes });
|
|
217
240
|
this.replicaView = createLocalReplicaView(this.replica);
|
|
218
|
-
if (this.localExecutor)
|
|
219
|
-
|
|
241
|
+
if (this.localExecutor) {
|
|
242
|
+
let executionVersion = this.replica.executionVersion();
|
|
243
|
+
this.unsubscribeLocal = this.replica.subscribe(() => {
|
|
244
|
+
const next = this.replica.executionVersion();
|
|
245
|
+
if (next === executionVersion)
|
|
246
|
+
return;
|
|
247
|
+
executionVersion = next;
|
|
248
|
+
this.scheduleLocalReplay();
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
this.unsubscribePeerOutbox = this.sharedOutboxStore?.subscribePeer?.(() => this.refreshPeerOutbox());
|
|
252
|
+
this.unsubscribePeerReplica = this.localStorage?.subscribePeer?.(scope => {
|
|
253
|
+
if (scope === this.replicaScope)
|
|
254
|
+
this.refreshPeerOutbox();
|
|
255
|
+
});
|
|
220
256
|
this.unsubscribeOutbox = this.reducerOutbox.subscribe(() => {
|
|
221
257
|
void this.drainOutbox();
|
|
222
258
|
});
|
|
@@ -269,38 +305,162 @@ export class GonvexClient {
|
|
|
269
305
|
return;
|
|
270
306
|
this.lastOnlineAtMs = saved.lastOnlineAtMs ?? 0;
|
|
271
307
|
this.localIdentity = saved.identity;
|
|
308
|
+
void this.localExecutor?.ready.catch(() => undefined);
|
|
272
309
|
await this.activateReplicaDirective(saved.directive);
|
|
273
310
|
this.replica.setFreshness("offline");
|
|
274
311
|
}
|
|
275
|
-
ensureLocalCollections() {
|
|
276
|
-
if (!this.localBinding
|
|
312
|
+
ensureLocalCollections(tables) {
|
|
313
|
+
if (!this.localBinding)
|
|
277
314
|
return;
|
|
315
|
+
const needed = new Set(tables);
|
|
278
316
|
for (const reference of this.localBinding.collections ?? []) {
|
|
279
|
-
|
|
317
|
+
if (!reference.replica || !needed.has(reference.replica.table))
|
|
318
|
+
continue;
|
|
319
|
+
const key = querySubscriptionKey(reference, {});
|
|
320
|
+
if (this.localCollectionKeys.has(key))
|
|
321
|
+
continue;
|
|
322
|
+
this.localCollectionKeys.add(key);
|
|
323
|
+
this.localCollectionClosers.push(this.subscribeReplicaTransport(reference, {}, () => undefined, true));
|
|
280
324
|
}
|
|
281
325
|
}
|
|
326
|
+
hydrateMissingLocalTable(error) {
|
|
327
|
+
if (!(error instanceof Error) || error.name !== "IncompleteReplicaError")
|
|
328
|
+
return;
|
|
329
|
+
// Worker errors preserve the public runtime name/message contract.
|
|
330
|
+
const table = /^Local replica for (.+?) is incomplete;/.exec(error.message)?.[1];
|
|
331
|
+
if (table)
|
|
332
|
+
this.ensureLocalCollections([table]);
|
|
333
|
+
}
|
|
282
334
|
localSnapshot(includePending) {
|
|
283
|
-
const
|
|
335
|
+
const rows = this.replica.captureExecutionRows(includePending);
|
|
284
336
|
const tables = {};
|
|
285
|
-
for (const table of this.
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
const complete = references.some((ref) => {
|
|
289
|
-
const window = snapshot.liveQueries[querySubscriptionKey(ref, {})];
|
|
290
|
-
return window?.completeness === "complete" && window.truncated !== true;
|
|
291
|
-
});
|
|
292
|
-
tables[table] = { complete, columns: [...new Set(references.flatMap(ref => ref.replica?.columns ?? []))], rows: includePending ? this.replica.entityRows(table) : Object.values(snapshot.entities[table] ?? {}) };
|
|
337
|
+
for (const [table, binding] of this.localTableBindings) {
|
|
338
|
+
const complete = binding.signatures.some(signature => this.replica.windowIsComplete(signature));
|
|
339
|
+
tables[table] = { complete, columns: binding.columns, get rows() { return rows(table); } };
|
|
293
340
|
}
|
|
294
341
|
return { scope: this.replicaScope, tables };
|
|
295
342
|
}
|
|
343
|
+
localReadCoverage() {
|
|
344
|
+
return this.replica.captureReadCoverage(Object.fromEntries([...this.localTableBindings].map(([table, binding]) => [table, {
|
|
345
|
+
key: this.localBinding.collections?.find(ref => ref.replica?.table === table)?.replica?.key ?? '_id',
|
|
346
|
+
complete: binding.signatures.some(signature => this.replica.windowIsComplete(signature)), columns: binding.columns,
|
|
347
|
+
}])));
|
|
348
|
+
}
|
|
349
|
+
isUncoveredLocalRead(error) {
|
|
350
|
+
if (!(error instanceof Error) || error.name !== 'IncompleteReplicaError')
|
|
351
|
+
return false;
|
|
352
|
+
const read = error.read;
|
|
353
|
+
if (!read || read.limit === 0)
|
|
354
|
+
return false;
|
|
355
|
+
const known = this.localReadCoverage()[read.table];
|
|
356
|
+
// Disk can recover evicted primary keys or a complete registered slice.
|
|
357
|
+
// It cannot prove a scan over data that has never been delivered. Queue
|
|
358
|
+
// that intent for replay instead of waiting for unrelated cache writes.
|
|
359
|
+
return !readIsCovered(known, read) && primaryReadKeys(read, known?.key ?? '_id') === undefined;
|
|
360
|
+
}
|
|
361
|
+
async executeLocal(path, args, snapshot, execution, pending = true, residentOnly = false) {
|
|
362
|
+
if (this.localBinding?.mode === 'portable') {
|
|
363
|
+
try {
|
|
364
|
+
const run = (source) => this.localExecutor.executeRead(path, args, source, execution);
|
|
365
|
+
let transaction;
|
|
366
|
+
try {
|
|
367
|
+
transaction = await this.replica.withReadView(() => this.localReadCoverage(), pending, run, true);
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
if (residentOnly || !(error instanceof Error) || error.name !== 'IncompleteReplicaError' || this.isUncoveredLocalRead(error))
|
|
371
|
+
throw error;
|
|
372
|
+
transaction = await this.replica.withReadView(() => this.localReadCoverage(), pending, run);
|
|
373
|
+
}
|
|
374
|
+
// The read view already proved every read from the available replica
|
|
375
|
+
// windows or cached primary keys. Do not promote those bounded reads
|
|
376
|
+
// to whole-table subscriptions after a successful user edit. Missing
|
|
377
|
+
// coverage still opens its generated collection in the error path.
|
|
378
|
+
return transaction;
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
// A speculative memory-only read may simply need IndexedDB. Its
|
|
382
|
+
// coordinated retry checks disk before deciding to request more data.
|
|
383
|
+
if (!residentOnly)
|
|
384
|
+
this.hydrateMissingLocalTable(error);
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (!snapshot)
|
|
389
|
+
throw new Error('Legacy local executor requires a snapshot');
|
|
390
|
+
const known = this.localExecutionTables.get(path);
|
|
391
|
+
const input = known ? { ...snapshot, tables: Object.fromEntries(Object.entries(snapshot.tables).filter(([table]) => known.has(table))) } : snapshot;
|
|
392
|
+
let transaction;
|
|
393
|
+
try {
|
|
394
|
+
transaction = await this.localExecutor.execute(path, args, input, execution);
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
this.hydrateMissingLocalTable(error);
|
|
398
|
+
if (!known || !(error instanceof Error) || error.name !== "IncompleteReplicaError")
|
|
399
|
+
throw error;
|
|
400
|
+
// A different argument, tenant or workflow can read different tables.
|
|
401
|
+
// Retry against the SAME captured snapshot, never newer mixed-version rows.
|
|
402
|
+
try {
|
|
403
|
+
transaction = await this.localExecutor.execute(path, args, snapshot, execution);
|
|
404
|
+
}
|
|
405
|
+
catch (retryError) {
|
|
406
|
+
this.hydrateMissingLocalTable(retryError);
|
|
407
|
+
throw retryError;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
this.ensureLocalCollections(transaction.readTables);
|
|
411
|
+
this.localExecutionTables.set(path, new Set([
|
|
412
|
+
...(known ?? []), ...transaction.readTables, ...transaction.patches.map(patch => patch.entity),
|
|
413
|
+
]));
|
|
414
|
+
return transaction;
|
|
415
|
+
}
|
|
296
416
|
inLocalLane(run) {
|
|
297
417
|
const job = this.localLane.then(run);
|
|
298
418
|
this.localLane = job.catch(() => undefined);
|
|
299
419
|
return job;
|
|
300
420
|
}
|
|
421
|
+
replaceLocalPredictions(commands) {
|
|
422
|
+
this.replica.replaceOptimistic(commands);
|
|
423
|
+
}
|
|
424
|
+
coordinateOutbox(lane, run) {
|
|
425
|
+
return this.sharedOutboxStore?.coordinate?.(this.outboxScope, lane, run) ?? run();
|
|
426
|
+
}
|
|
427
|
+
refreshPeerOutbox() {
|
|
428
|
+
if (this.manuallyClosed)
|
|
429
|
+
return;
|
|
430
|
+
this.peerRefreshDirty = true;
|
|
431
|
+
if (this.peerRefreshScheduled)
|
|
432
|
+
return;
|
|
433
|
+
this.peerRefreshScheduled = true;
|
|
434
|
+
void (async () => {
|
|
435
|
+
await this.outboxReady;
|
|
436
|
+
do {
|
|
437
|
+
this.peerRefreshDirty = false;
|
|
438
|
+
await this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
|
|
439
|
+
const scope = this.outboxScope;
|
|
440
|
+
await this.replica.synchronizeStorage();
|
|
441
|
+
const entries = await this.reducerOutbox.list(scope);
|
|
442
|
+
if (this.manuallyClosed || scope !== this.outboxScope)
|
|
443
|
+
return;
|
|
444
|
+
this.replaceLocalPredictions(entries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
|
|
445
|
+
this.optimisticReducerIds.clear();
|
|
446
|
+
this.optimisticOutboxEntryIds.clear();
|
|
447
|
+
for (const entry of entries) {
|
|
448
|
+
this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
|
|
449
|
+
if (entry.patches?.length)
|
|
450
|
+
this.optimisticReducerIds.add(entry.idempotencyKey);
|
|
451
|
+
}
|
|
452
|
+
}));
|
|
453
|
+
} while (this.peerRefreshDirty && !this.manuallyClosed);
|
|
454
|
+
void this.drainOutbox();
|
|
455
|
+
})().catch(() => undefined).finally(() => { this.peerRefreshScheduled = false; });
|
|
456
|
+
}
|
|
301
457
|
scheduleLocalReplay() {
|
|
302
458
|
if (!this.localExecutor || this.replacingLocal || this.localReplayScheduled || this.manuallyClosed || !this.localIdentity)
|
|
303
459
|
return;
|
|
460
|
+
// Restore and peer notifications discover durable intents explicitly. A
|
|
461
|
+
// table arriving at startup cannot invalidate a prediction that does not exist.
|
|
462
|
+
if (this.optimisticOutboxEntryIds.size === 0)
|
|
463
|
+
return;
|
|
304
464
|
this.localReplayScheduled = true;
|
|
305
465
|
queueMicrotask(() => {
|
|
306
466
|
void (async () => {
|
|
@@ -314,21 +474,52 @@ export class GonvexClient {
|
|
|
314
474
|
for (const listener of this.reducerRejectionHandlers)
|
|
315
475
|
listener({ reducerId, path, error: message });
|
|
316
476
|
}
|
|
317
|
-
|
|
477
|
+
rebaseLocalEntries(afterRejection = false) {
|
|
478
|
+
return this.coordinateOutbox("intent", () => this.rebaseLocalEntriesLocked(afterRejection));
|
|
479
|
+
}
|
|
480
|
+
async rebaseLocalEntriesLocked(afterRejection = false) {
|
|
318
481
|
if (!this.localExecutor || !this.localBinding || !this.localIdentity || !this.hasAuthoritativeReplicaScope)
|
|
319
482
|
return;
|
|
483
|
+
// The authoritative drain validates the pending chain in order. Keep its
|
|
484
|
+
// predictions while it progresses, then rebase once it settles or loses
|
|
485
|
+
// connectivity instead of replaying N + (N-1) + ... local transactions.
|
|
486
|
+
if (!afterRejection && this.drainingOutbox && this.canSendReducerNow())
|
|
487
|
+
return;
|
|
488
|
+
await this.replica.synchronizeStorage();
|
|
320
489
|
const scope = this.outboxScope;
|
|
321
490
|
const replicaScope = this.replicaScope;
|
|
322
491
|
const entries = await this.reducerOutbox.list(scope);
|
|
323
|
-
|
|
324
|
-
|
|
492
|
+
if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
|
|
493
|
+
return;
|
|
494
|
+
if (entries.length === 0) {
|
|
495
|
+
this.replacingLocal = true;
|
|
496
|
+
try {
|
|
497
|
+
this.replaceLocalPredictions([]);
|
|
498
|
+
}
|
|
499
|
+
finally {
|
|
500
|
+
this.replacingLocal = false;
|
|
501
|
+
}
|
|
502
|
+
this.optimisticReducerIds.clear();
|
|
503
|
+
this.optimisticOutboxEntryIds.clear();
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const baseVersion = this.replica.executionVersion();
|
|
507
|
+
const needsExecution = entries.some(entry => entry.localExecution && entry.state !== "inflight" && entry.state !== "committed");
|
|
508
|
+
const snapshot = needsExecution && this.localBinding.mode !== 'portable' ? this.localSnapshot(false) : undefined;
|
|
509
|
+
const replayPatches = [];
|
|
325
510
|
const commands = [];
|
|
326
511
|
for (const entry of entries) {
|
|
512
|
+
// Predictions can wait; a fresh user intent must not sit behind the
|
|
513
|
+
// entire durable backlog. Publish only a complete, coherent rebase.
|
|
514
|
+
if (this.waitingLocalEdits > 0) {
|
|
515
|
+
setTimeout(() => this.scheduleLocalReplay(), 0);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
327
518
|
let patches = entry.patches ?? [];
|
|
328
519
|
if (entry.localExecution && entry.state !== "inflight" && entry.state !== "committed") {
|
|
329
520
|
const execution = { ...entry.localExecution, scope: replicaScope, identity: this.localIdentity, artifactHash: this.localBinding.artifactHash };
|
|
330
521
|
try {
|
|
331
|
-
const transaction = await this.
|
|
522
|
+
const transaction = await this.executeLocal(entry.path, entry.args, snapshot, execution, replayPatches);
|
|
332
523
|
patches = transaction.patches;
|
|
333
524
|
if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
|
|
334
525
|
return;
|
|
@@ -344,16 +535,25 @@ export class GonvexClient {
|
|
|
344
535
|
// Storage failures must abort rebasing, never masquerade as rejection.
|
|
345
536
|
await this.reducerOutbox.updateLocal(entry.id, patches, execution);
|
|
346
537
|
}
|
|
347
|
-
|
|
538
|
+
if (snapshot)
|
|
539
|
+
applyLocalPatches(snapshot, patches);
|
|
540
|
+
for (const patch of patches)
|
|
541
|
+
replayPatches.push(patch.op === 'delete'
|
|
542
|
+
? { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: 'delete' }
|
|
543
|
+
: { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: patch.op === 'upsert' ? 'insert' : patch.op, fields: patch.fields });
|
|
348
544
|
commands.push({ commandId: entry.idempotencyKey, patches });
|
|
349
545
|
}
|
|
350
546
|
if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
|
|
351
547
|
return;
|
|
352
|
-
if (baseVersion !== this.replica.
|
|
353
|
-
|
|
548
|
+
if (baseVersion !== this.replica.executionVersion()) {
|
|
549
|
+
// A changing server base must not recursively occupy the local lane.
|
|
550
|
+
// Release it so user intents already queued can run before the retry.
|
|
551
|
+
setTimeout(() => this.scheduleLocalReplay(), 0);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
354
554
|
this.replacingLocal = true;
|
|
355
555
|
try {
|
|
356
|
-
this.
|
|
556
|
+
this.replaceLocalPredictions(commands);
|
|
357
557
|
}
|
|
358
558
|
finally {
|
|
359
559
|
this.replacingLocal = false;
|
|
@@ -362,9 +562,10 @@ export class GonvexClient {
|
|
|
362
562
|
this.optimisticOutboxEntryIds.clear();
|
|
363
563
|
for (const entry of entries) {
|
|
364
564
|
this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
|
|
365
|
-
if (commands.find((command) => command.commandId === entry.idempotencyKey)?.patches.length)
|
|
366
|
-
this.optimisticReducerIds.add(entry.idempotencyKey);
|
|
367
565
|
}
|
|
566
|
+
for (const command of commands)
|
|
567
|
+
if (command.patches.length)
|
|
568
|
+
this.optimisticReducerIds.add(command.commandId);
|
|
368
569
|
}
|
|
369
570
|
async runLocalReducer(ref, args) {
|
|
370
571
|
args = structuredClone(args);
|
|
@@ -373,7 +574,38 @@ export class GonvexClient {
|
|
|
373
574
|
await this.replicaReady;
|
|
374
575
|
if (!this.localExecutor || !this.localBinding)
|
|
375
576
|
throw new Error("This reducer requires the generated Gonvex client with local execution enabled");
|
|
376
|
-
|
|
577
|
+
const reducerId = randomID();
|
|
578
|
+
const execution = this.localIdentity ? {
|
|
579
|
+
scope: this.replicaScope, commandId: reducerId, now: Date.now(),
|
|
580
|
+
intentEntropy: Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join(''),
|
|
581
|
+
artifactHash: this.localBinding.artifactHash, identity: structuredClone(this.localIdentity),
|
|
582
|
+
} : undefined;
|
|
583
|
+
let preparedResult;
|
|
584
|
+
let preparedFailure;
|
|
585
|
+
const prepared = this.preparationLane.then(async () => {
|
|
586
|
+
if (!execution || this.localBinding?.mode !== 'portable' || !this.hasAuthoritativeReplicaScope || this.manuallyClosed || this.updateRequired || admittedGeneration !== this.outboxScopeGeneration)
|
|
587
|
+
return;
|
|
588
|
+
if (this.clientContract && this.clientContract.offlineMaxAgeMs !== null && !this.canSendReducerNow() && (Date.now() - this.lastOnlineAtMs > this.clientContract.offlineMaxAgeMs || Date.now() < this.lastOnlineAtMs))
|
|
589
|
+
return;
|
|
590
|
+
const base = this.replica.executionVersion();
|
|
591
|
+
const pending = this.replica.pendingReducerPatches();
|
|
592
|
+
try {
|
|
593
|
+
const transaction = await this.executeLocal(ref.path, args, undefined, execution, pending, true);
|
|
594
|
+
if (base !== this.replica.executionVersion() || admittedGeneration !== this.outboxScopeGeneration || this.manuallyClosed)
|
|
595
|
+
return;
|
|
596
|
+
preparedResult = { transaction, base, pending: JSON.stringify(pending) };
|
|
597
|
+
}
|
|
598
|
+
catch (error) {
|
|
599
|
+
preparedFailure = error; /* Recheck coverage under coordinated admission. */
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
this.preparationLane = prepared.catch(() => undefined);
|
|
603
|
+
this.waitingLocalEdits += 1;
|
|
604
|
+
let entered = false;
|
|
605
|
+
return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
|
|
606
|
+
entered = true;
|
|
607
|
+
this.waitingLocalEdits -= 1;
|
|
608
|
+
await prepared;
|
|
377
609
|
if (admittedGeneration !== this.outboxScopeGeneration)
|
|
378
610
|
throw new GonvexClientError("Session changed before local reducer execution", { code: "superseded" });
|
|
379
611
|
if (!this.localIdentity || !this.hasAuthoritativeReplicaScope)
|
|
@@ -385,15 +617,51 @@ export class GonvexClient {
|
|
|
385
617
|
if (this.clientContract && this.clientContract.offlineMaxAgeMs !== null && !this.canSendReducerNow() && (Date.now() - this.lastOnlineAtMs > this.clientContract.offlineMaxAgeMs || Date.now() < this.lastOnlineAtMs))
|
|
386
618
|
throw new Error("Offline editing window expired. Reconnect before making more changes.");
|
|
387
619
|
const scope = this.outboxScope;
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
620
|
+
if (!execution || execution.scope !== this.replicaScope)
|
|
621
|
+
throw new GonvexClientError("Session changed before local reducer execution", { code: 'superseded' });
|
|
622
|
+
const sharedEntries = await this.reducerOutbox.list(scope);
|
|
623
|
+
const sharedPatches = sharedEntries?.filter(entry => entry.state !== "committed").flatMap(entry => (entry.patches ?? []).map(patch => patch.op === "delete"
|
|
624
|
+
? { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: "delete" }
|
|
625
|
+
: { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: patch.op === "upsert" ? "insert" : patch.op, fields: patch.fields }));
|
|
626
|
+
// A fully resident prediction reads the already published atomic replica.
|
|
627
|
+
// Unrelated snapshots may still be persisting; waiting for those here
|
|
628
|
+
// makes a small edit inherit their disk latency. The shared intent lock
|
|
629
|
+
// and journal still order every edit, and known peer changes must be
|
|
630
|
+
// reconciled before a resident prediction can be reused.
|
|
631
|
+
const reuseResident = preparedResult
|
|
632
|
+
&& preparedResult.base === this.replica.executionVersion()
|
|
633
|
+
&& preparedResult.pending === JSON.stringify(sharedPatches)
|
|
634
|
+
&& !this.peerRefreshDirty && !this.peerRefreshScheduled
|
|
635
|
+
&& !sharedEntries.some(entry => entry.state === "committed");
|
|
636
|
+
const uncoveredRead = this.isUncoveredLocalRead(preparedFailure)
|
|
637
|
+
&& !this.peerRefreshDirty && !this.peerRefreshScheduled
|
|
638
|
+
&& !sharedEntries.some(entry => entry.state === "committed");
|
|
639
|
+
if (!reuseResident && !uncoveredRead)
|
|
640
|
+
await this.replica.synchronizeStorage();
|
|
641
|
+
if (sharedEntries)
|
|
642
|
+
this.replaceLocalPredictions(sharedEntries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
|
|
643
|
+
const executionBaseVersion = this.replica.executionVersion();
|
|
394
644
|
let transaction;
|
|
395
645
|
try {
|
|
396
|
-
|
|
646
|
+
if (preparedResult && preparedResult.base === executionBaseVersion && preparedResult.pending === JSON.stringify(sharedPatches)) {
|
|
647
|
+
transaction = preparedResult.transaction;
|
|
648
|
+
}
|
|
649
|
+
else if (this.localBinding.mode === 'portable') {
|
|
650
|
+
// A commit may invalidate preparation while the intent lock is being
|
|
651
|
+
// acquired. Read the newly published atomic replica again before
|
|
652
|
+
// joining disk work that arrived after synchronizeStorage completed.
|
|
653
|
+
try {
|
|
654
|
+
transaction = await this.executeLocal(ref.path, args, undefined, execution, sharedPatches ?? true, true);
|
|
655
|
+
}
|
|
656
|
+
catch (error) {
|
|
657
|
+
if (!(error instanceof Error) || error.name !== 'IncompleteReplicaError')
|
|
658
|
+
throw error;
|
|
659
|
+
transaction = await this.executeLocal(ref.path, args, undefined, execution, sharedPatches ?? true);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
else {
|
|
663
|
+
transaction = await this.executeLocal(ref.path, args, this.localSnapshot(true), execution, sharedPatches ?? true);
|
|
664
|
+
}
|
|
397
665
|
}
|
|
398
666
|
catch (error) {
|
|
399
667
|
if (!(error instanceof Error) || error.name !== "IncompleteReplicaError")
|
|
@@ -404,6 +672,10 @@ export class GonvexClient {
|
|
|
404
672
|
const patches = transaction?.patches ?? [];
|
|
405
673
|
this.directOutboxReducerIds.add(reducerId);
|
|
406
674
|
try {
|
|
675
|
+
// A visible prediction must already have a durable intent. Otherwise a
|
|
676
|
+
// reload immediately after paint can lose an offline edit. Local
|
|
677
|
+
// execution still avoids a server round trip; disk admission is bounded
|
|
678
|
+
// to this intent, rather than persisting the whole replica.
|
|
407
679
|
const entry = await this.reducerOutbox.enqueue({
|
|
408
680
|
scope, path: ref.path, args, idempotencyKey: reducerId, state: "pending",
|
|
409
681
|
// All local intents form one causal chain, including reads of rows
|
|
@@ -423,13 +695,37 @@ export class GonvexClient {
|
|
|
423
695
|
}
|
|
424
696
|
return transaction ? transaction.result : { status: "queued", reducerId };
|
|
425
697
|
}
|
|
698
|
+
catch (error) {
|
|
699
|
+
this.optimisticReducerIds.delete(reducerId);
|
|
700
|
+
this.replica.rejectCommand(reducerId);
|
|
701
|
+
throw error;
|
|
702
|
+
}
|
|
426
703
|
finally {
|
|
427
704
|
this.directOutboxReducerIds.delete(reducerId);
|
|
428
|
-
|
|
705
|
+
// This transaction already executed against all earlier predictions.
|
|
706
|
+
// Appending it does not invalidate those predictions. Rebase only if
|
|
707
|
+
// the authoritative execution snapshot changed during execution.
|
|
708
|
+
// An uncovered read has no new data to replay against yet. Its
|
|
709
|
+
// hydration subscription schedules replay when coverage arrives;
|
|
710
|
+
// immediately retrying here only locks edits behind the same disk work.
|
|
711
|
+
if (this.replica.executionVersion() !== executionBaseVersion)
|
|
712
|
+
this.scheduleLocalReplay();
|
|
429
713
|
void this.drainOutbox();
|
|
430
714
|
}
|
|
715
|
+
})).catch(error => {
|
|
716
|
+
if (!entered)
|
|
717
|
+
this.waitingLocalEdits -= 1;
|
|
718
|
+
this.optimisticReducerIds.delete(reducerId);
|
|
719
|
+
this.replica.rejectCommand(reducerId);
|
|
720
|
+
throw error;
|
|
431
721
|
});
|
|
432
722
|
}
|
|
723
|
+
/** Preload code for a mounted control without reading data or executing an intent. */
|
|
724
|
+
prepareReducer(ref) {
|
|
725
|
+
if (ref.localExecution !== 1 || this.manuallyClosed)
|
|
726
|
+
return Promise.resolve();
|
|
727
|
+
return this.localExecutor?.prepare?.(ref.path) ?? Promise.resolve();
|
|
728
|
+
}
|
|
433
729
|
/** The single normalized authoritative + optimistic application data store. */
|
|
434
730
|
get localReplica() {
|
|
435
731
|
return this.replicaView;
|
|
@@ -445,17 +741,31 @@ export class GonvexClient {
|
|
|
445
741
|
replicaEntities(entity, ids) {
|
|
446
742
|
return this.replica.entityBatch(entity, ids);
|
|
447
743
|
}
|
|
744
|
+
/** Retain only the data an active view observes; cold rows remain on disk. */
|
|
745
|
+
retainReplicaEntities(entity, ids) { return this.replica.retainRows(entity, ids); }
|
|
746
|
+
retainReplicaWindow(signature) { return this.replica.retainWindow(signature); }
|
|
448
747
|
/** Read rows and server-owned completeness for a persisted Replica Collection. */
|
|
449
748
|
replicaCollectionState(ref, args = {}) {
|
|
450
749
|
return this.replica.collectionState(this.replicaSignature(ref, args));
|
|
451
750
|
}
|
|
452
751
|
/** Run the generated Live Query plan over the bounded normalized cache. */
|
|
453
|
-
offlineLiveQuery(ref, args = {}) {
|
|
752
|
+
offlineLiveQuery(ref, args = {}, options = {}) {
|
|
454
753
|
if (!ref.live?.plan) {
|
|
455
754
|
return { rows: [], completeness: "partial", supported: false, unsupportedOperator: "missingPlan" };
|
|
456
755
|
}
|
|
457
756
|
const queryArgs = isJsonRecord(args) ? args : {};
|
|
458
|
-
return runOfflineLiveQuery(this.replica.entityRows(ref.live.entity), ref.live.plan, queryArgs, this.replica.entityCompleteness(ref.live.entity))
|
|
757
|
+
return runOfflineLiveQuery(this.replica.entityRows(ref.live.entity), options.window === false ? { ...ref.live.plan, window: undefined, sort: undefined } : ref.live.plan, queryArgs, this.replica.entityCompleteness(ref.live.entity), (table, id) => this.replica.entity(table, id), {
|
|
758
|
+
context: {
|
|
759
|
+
...(this.localIdentity?.member?.id ? { 'member.id': this.localIdentity.member.id } : {}),
|
|
760
|
+
...(this.localIdentity?.auth.account?.id ? { 'account.id': this.localIdentity.auth.account.id } : {}),
|
|
761
|
+
...(this.localIdentity?.tenant?.id ? { 'tenant.id': this.localIdentity.tenant.id } : {}),
|
|
762
|
+
},
|
|
763
|
+
relationRows: (table, where) => {
|
|
764
|
+
const coverage = this.replica.captureReadCoverage({ [table]: { key: '_id', complete: false } });
|
|
765
|
+
return { rows: this.replica.entityRows(table), completeness: this.replica.entityCompleteness(table) === 'complete'
|
|
766
|
+
&& readIsCovered(coverage[table], { table, where: liveScopeCoverage(where, queryArgs) }) ? 'complete' : 'partial' };
|
|
767
|
+
},
|
|
768
|
+
});
|
|
459
769
|
}
|
|
460
770
|
/** Number of reducers waiting for a definitive server result. */
|
|
461
771
|
async outboxCount() {
|
|
@@ -803,8 +1113,10 @@ export class GonvexClient {
|
|
|
803
1113
|
this.authRetriedAfterError = false;
|
|
804
1114
|
const directive = replicaDirectiveFromAuthResult(message.result);
|
|
805
1115
|
const localIdentity = localIdentityFromAuthResult(message.result);
|
|
806
|
-
if (localIdentity)
|
|
1116
|
+
if (localIdentity) {
|
|
807
1117
|
this.localIdentity = localIdentity;
|
|
1118
|
+
void this.localExecutor?.ready.catch(() => undefined);
|
|
1119
|
+
}
|
|
808
1120
|
if (!directive) {
|
|
809
1121
|
this.authInFlight = false;
|
|
810
1122
|
if (!this.auth.tenant) {
|
|
@@ -946,6 +1258,7 @@ export class GonvexClient {
|
|
|
946
1258
|
this.unsubscribeLocal?.();
|
|
947
1259
|
for (const close of this.localCollectionClosers.splice(0))
|
|
948
1260
|
close();
|
|
1261
|
+
this.localCollectionKeys.clear();
|
|
949
1262
|
this.localExecutor?.close();
|
|
950
1263
|
this.cancelManagedAuthAttempt("Gonvex client was closed during authentication.");
|
|
951
1264
|
if (isEphemeralOutboxScope(this.outboxScope)) {
|
|
@@ -982,6 +1295,8 @@ export class GonvexClient {
|
|
|
982
1295
|
this.outboxDrainTimer = undefined;
|
|
983
1296
|
}
|
|
984
1297
|
this.unsubscribeOutbox();
|
|
1298
|
+
this.unsubscribePeerOutbox?.();
|
|
1299
|
+
this.unsubscribePeerReplica?.();
|
|
985
1300
|
this.unsubscribeBrowserOnline?.();
|
|
986
1301
|
this.handlers.clear();
|
|
987
1302
|
this.querySubscriptions.clear();
|
|
@@ -999,6 +1314,7 @@ export class GonvexClient {
|
|
|
999
1314
|
this.notifyConnectionState();
|
|
1000
1315
|
this.connectionStateHandlers.clear();
|
|
1001
1316
|
this.supportCommandHandlers.clear();
|
|
1317
|
+
this.replica.dispose();
|
|
1002
1318
|
const closedError = new Error("Gonvex client is closed");
|
|
1003
1319
|
for (const pending of this.pendingNativeErrors.splice(0))
|
|
1004
1320
|
pending.reject(closedError);
|
|
@@ -1335,7 +1651,9 @@ export class GonvexClient {
|
|
|
1335
1651
|
// could overwrite a result already accepted on the same connection.
|
|
1336
1652
|
return subscription.revisionSocketGeneration !== this.socketGeneration;
|
|
1337
1653
|
}
|
|
1338
|
-
subscribeReplicaTransport(ref, args = {}, onMessage) {
|
|
1654
|
+
subscribeReplicaTransport(ref, args = {}, onMessage, metadataOnly = false) {
|
|
1655
|
+
if (metadataOnly)
|
|
1656
|
+
this.replicaMetadataListeners.add(onMessage);
|
|
1339
1657
|
this.connect();
|
|
1340
1658
|
const key = querySubscriptionKey(ref, args);
|
|
1341
1659
|
const existing = this.replicaSubscriptions.get(key);
|
|
@@ -1345,10 +1663,14 @@ export class GonvexClient {
|
|
|
1345
1663
|
existing.unsubscribeTimer = undefined;
|
|
1346
1664
|
}
|
|
1347
1665
|
existing.listeners.add(onMessage);
|
|
1666
|
+
// A reset may arrive during the retention grace period with no listeners.
|
|
1667
|
+
// Its reopen was correctly deferred; the next subscriber must resume it.
|
|
1668
|
+
if (!existing.isUpToDate && !existing.opening)
|
|
1669
|
+
this.sendReplicaOpen(existing);
|
|
1348
1670
|
if (existing.lastMessage) {
|
|
1349
1671
|
queueMicrotask(() => {
|
|
1350
1672
|
if (existing.listeners.has(onMessage) && existing.lastMessage) {
|
|
1351
|
-
onMessage(this.materializeReplicaMessage(existing, existing.lastMessage));
|
|
1673
|
+
onMessage(metadataOnly ? existing.lastMessage : this.materializeReplicaMessage(existing, existing.lastMessage));
|
|
1352
1674
|
}
|
|
1353
1675
|
});
|
|
1354
1676
|
}
|
|
@@ -1386,12 +1708,16 @@ export class GonvexClient {
|
|
|
1386
1708
|
return this.subscribeReplicaTransport(ref, args, onMessage);
|
|
1387
1709
|
}
|
|
1388
1710
|
/** Watch a bounded Replica Collection through the normalized Local Replica. */
|
|
1389
|
-
watchReplica(ref, args = {}) {
|
|
1711
|
+
watchReplica(ref, args = {}, options = {}) {
|
|
1390
1712
|
const key = querySubscriptionKey(ref, args);
|
|
1391
1713
|
const updateHandlers = new Set();
|
|
1392
1714
|
let latestError;
|
|
1393
1715
|
let snapshotVersion = -1;
|
|
1394
1716
|
let snapshotRows;
|
|
1717
|
+
const readRows = () => {
|
|
1718
|
+
const next = this.replica.liveQuerySnapshot(key).rows;
|
|
1719
|
+
return snapshotRows?.length === next.length && next.every((row, index) => row === snapshotRows[index]) ? snapshotRows : next;
|
|
1720
|
+
};
|
|
1395
1721
|
let stateVersion = -1;
|
|
1396
1722
|
let stateFreshness;
|
|
1397
1723
|
let stateIsUpToDate;
|
|
@@ -1403,45 +1729,57 @@ export class GonvexClient {
|
|
|
1403
1729
|
};
|
|
1404
1730
|
// Keep the ReplicaSubscription as transport/reconciliation state only. The
|
|
1405
1731
|
// value returned by this watch always comes from normalized LocalReplica.
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
}
|
|
1411
|
-
else if (message.type === "replica.syncing" || message.type === "replica.reset") {
|
|
1412
|
-
latestError = undefined;
|
|
1413
|
-
notify();
|
|
1414
|
-
}
|
|
1415
|
-
else if (message.type === "replica.ready") {
|
|
1416
|
-
latestError = undefined;
|
|
1417
|
-
notify();
|
|
1418
|
-
}
|
|
1419
|
-
else if (message.type === "replica.snapshot") {
|
|
1420
|
-
latestError = undefined;
|
|
1421
|
-
}
|
|
1422
|
-
});
|
|
1423
|
-
const unsubscribeReplica = this.replica.subscribe(notify);
|
|
1424
|
-
const unsubscribeScope = this.onSessionScopeChange(() => {
|
|
1425
|
-
latestError = undefined;
|
|
1732
|
+
let stop;
|
|
1733
|
+
const start = () => {
|
|
1734
|
+
if (stop)
|
|
1735
|
+
return;
|
|
1426
1736
|
snapshotVersion = -1;
|
|
1427
|
-
snapshotRows = undefined;
|
|
1428
1737
|
stateVersion = -1;
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1738
|
+
const releaseRows = this.replica.retainWindow(key);
|
|
1739
|
+
const unsubscribeTransport = this.subscribeReplicaTransport(ref, args, (message) => {
|
|
1740
|
+
if (message.type === "replica.error") {
|
|
1741
|
+
latestError = new Error(message.error);
|
|
1742
|
+
notify();
|
|
1743
|
+
}
|
|
1744
|
+
else if (message.type === "replica.syncing" || message.type === "replica.reset") {
|
|
1745
|
+
latestError = undefined;
|
|
1746
|
+
notify();
|
|
1747
|
+
}
|
|
1748
|
+
else if (message.type === "replica.ready") {
|
|
1749
|
+
latestError = undefined;
|
|
1750
|
+
notify();
|
|
1751
|
+
}
|
|
1752
|
+
else if (message.type === "replica.snapshot") {
|
|
1753
|
+
latestError = undefined;
|
|
1754
|
+
}
|
|
1755
|
+
}, true);
|
|
1756
|
+
const unsubscribeReplica = this.replica.subscribe(notify);
|
|
1757
|
+
const unsubscribeScope = this.onSessionScopeChange(() => {
|
|
1758
|
+
latestError = undefined;
|
|
1759
|
+
snapshotVersion = -1;
|
|
1760
|
+
snapshotRows = undefined;
|
|
1761
|
+
stateVersion = -1;
|
|
1762
|
+
stateFreshness = undefined;
|
|
1763
|
+
stateIsUpToDate = undefined;
|
|
1764
|
+
snapshotState = undefined;
|
|
1765
|
+
notify();
|
|
1766
|
+
});
|
|
1767
|
+
stop = () => { unsubscribeTransport(); unsubscribeReplica(); unsubscribeScope(); releaseRows(); stop = undefined; };
|
|
1768
|
+
};
|
|
1769
|
+
// React can discard a render before subscribing. Such watches must own no resources.
|
|
1770
|
+
if (!options.deferStart)
|
|
1771
|
+
start();
|
|
1434
1772
|
return {
|
|
1435
1773
|
localReplicaResult: () => {
|
|
1436
1774
|
if (latestError)
|
|
1437
1775
|
throw latestError;
|
|
1438
1776
|
if (!this.replica.hasLiveQuery(key))
|
|
1439
1777
|
return undefined;
|
|
1440
|
-
const version = this.replica.
|
|
1778
|
+
const version = this.replica.windowRowsVersion(key);
|
|
1441
1779
|
if (snapshotVersion === version)
|
|
1442
1780
|
return snapshotRows;
|
|
1443
1781
|
snapshotVersion = version;
|
|
1444
|
-
snapshotRows =
|
|
1782
|
+
snapshotRows = readRows();
|
|
1445
1783
|
return snapshotRows;
|
|
1446
1784
|
},
|
|
1447
1785
|
localReplicaState: () => {
|
|
@@ -1459,7 +1797,12 @@ export class GonvexClient {
|
|
|
1459
1797
|
stateVersion = version;
|
|
1460
1798
|
stateFreshness = freshness;
|
|
1461
1799
|
stateIsUpToDate = isUpToDate;
|
|
1462
|
-
const
|
|
1800
|
+
const rowsVersion = this.replica.windowRowsVersion(key);
|
|
1801
|
+
if (snapshotVersion !== rowsVersion) {
|
|
1802
|
+
snapshotVersion = rowsVersion;
|
|
1803
|
+
snapshotRows = readRows();
|
|
1804
|
+
}
|
|
1805
|
+
const state = this.replica.collectionState(key, snapshotRows);
|
|
1463
1806
|
snapshotState = {
|
|
1464
1807
|
...state,
|
|
1465
1808
|
isUpToDate,
|
|
@@ -1475,6 +1818,7 @@ export class GonvexClient {
|
|
|
1475
1818
|
isUpToDate: this.replicaSubscriptions.get(key)?.isUpToDate === true,
|
|
1476
1819
|
}),
|
|
1477
1820
|
onUpdate(handler) {
|
|
1821
|
+
start();
|
|
1478
1822
|
if (releaseTimer) {
|
|
1479
1823
|
clearTimeout(releaseTimer);
|
|
1480
1824
|
releaseTimer = undefined;
|
|
@@ -1492,9 +1836,7 @@ export class GonvexClient {
|
|
|
1492
1836
|
releaseTimer = undefined;
|
|
1493
1837
|
if (updateHandlers.size > 0)
|
|
1494
1838
|
return;
|
|
1495
|
-
|
|
1496
|
-
unsubscribeReplica();
|
|
1497
|
-
unsubscribeScope();
|
|
1839
|
+
stop?.();
|
|
1498
1840
|
}, 0);
|
|
1499
1841
|
};
|
|
1500
1842
|
},
|
|
@@ -1505,7 +1847,7 @@ export class GonvexClient {
|
|
|
1505
1847
|
* latest query result is retained only as the transport-shaped skeleton;
|
|
1506
1848
|
* its row window is always rebuilt from LocalReplica membership/entities.
|
|
1507
1849
|
*/
|
|
1508
|
-
watchLiveQuery(ref, args = {}) {
|
|
1850
|
+
watchLiveQuery(ref, args = {}, options = {}) {
|
|
1509
1851
|
const key = querySubscriptionKey(ref, args);
|
|
1510
1852
|
const updateHandlers = new Set();
|
|
1511
1853
|
let transportResult;
|
|
@@ -1525,39 +1867,51 @@ export class GonvexClient {
|
|
|
1525
1867
|
handler();
|
|
1526
1868
|
});
|
|
1527
1869
|
};
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1870
|
+
let stop;
|
|
1871
|
+
const start = () => {
|
|
1872
|
+
if (stop)
|
|
1873
|
+
return;
|
|
1874
|
+
transportResult = undefined;
|
|
1875
|
+
snapshotToken = "";
|
|
1876
|
+
const releaseRows = this.replica.retainWindow(key);
|
|
1877
|
+
const unsubscribeQuery = this.subscribeLiveQuery(ref, args, (message) => {
|
|
1878
|
+
if (message.type === "query.result") {
|
|
1879
|
+
transportResult = message.result;
|
|
1880
|
+
transportGeneration += 1;
|
|
1881
|
+
latestError = undefined;
|
|
1882
|
+
// LocalReplica has already published its atomic window swap before
|
|
1883
|
+
// this callback is emitted, so this is the single initial UI wake-up.
|
|
1884
|
+
notify();
|
|
1885
|
+
}
|
|
1886
|
+
else if (message.type === "query.error") {
|
|
1887
|
+
latestError = new GonvexClientError(`Query ${ref.path} failed: ${message.error}`, {
|
|
1888
|
+
code: "server", path: ref.path, operation: "query",
|
|
1889
|
+
});
|
|
1890
|
+
notify();
|
|
1891
|
+
}
|
|
1892
|
+
});
|
|
1893
|
+
// During the initial query result, LocalReplica notifies before the
|
|
1894
|
+
// transport-shaped skeleton is installed above. Suppress that empty
|
|
1895
|
+
// intermediate wake-up; later transactions notify directly from the
|
|
1896
|
+
// normalized store.
|
|
1897
|
+
const unsubscribeReplica = this.replica.subscribe(() => {
|
|
1898
|
+
if (transportResult !== undefined || this.replica.hasLiveQuery(key))
|
|
1899
|
+
notify();
|
|
1900
|
+
});
|
|
1901
|
+
void this.replicaReady.then(() => notify());
|
|
1902
|
+
const unsubscribeScope = this.onSessionScopeChange(() => {
|
|
1903
|
+
transportResult = undefined;
|
|
1531
1904
|
transportGeneration += 1;
|
|
1905
|
+
snapshotToken = "";
|
|
1906
|
+
snapshotResult = undefined;
|
|
1532
1907
|
latestError = undefined;
|
|
1533
|
-
// LocalReplica has already published its atomic window swap before
|
|
1534
|
-
// this callback is emitted, so this is the single initial UI wake-up.
|
|
1535
|
-
notify();
|
|
1536
|
-
}
|
|
1537
|
-
else if (message.type === "query.error") {
|
|
1538
|
-
latestError = new GonvexClientError(`Query ${ref.path} failed: ${message.error}`, {
|
|
1539
|
-
code: "server", path: ref.path, operation: "query",
|
|
1540
|
-
});
|
|
1541
|
-
notify();
|
|
1542
|
-
}
|
|
1543
|
-
});
|
|
1544
|
-
// During the initial query result, LocalReplica notifies before the
|
|
1545
|
-
// transport-shaped skeleton is installed above. Suppress that empty
|
|
1546
|
-
// intermediate wake-up; later transactions notify directly from the
|
|
1547
|
-
// normalized store.
|
|
1548
|
-
const unsubscribeReplica = this.replica.subscribe(() => {
|
|
1549
|
-
if (transportResult !== undefined || this.replica.hasLiveQuery(key))
|
|
1550
1908
|
notify();
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
snapshotResult = undefined;
|
|
1558
|
-
latestError = undefined;
|
|
1559
|
-
notify();
|
|
1560
|
-
});
|
|
1909
|
+
});
|
|
1910
|
+
stop = () => { unsubscribeQuery(); unsubscribeReplica(); unsubscribeScope(); releaseRows(); stop = undefined; };
|
|
1911
|
+
};
|
|
1912
|
+
// React can discard a render before subscribing. Such watches must own no resources.
|
|
1913
|
+
if (!options.deferStart)
|
|
1914
|
+
start();
|
|
1561
1915
|
return {
|
|
1562
1916
|
localLiveQueryResult: () => {
|
|
1563
1917
|
if (latestError)
|
|
@@ -1592,6 +1946,7 @@ export class GonvexClient {
|
|
|
1592
1946
|
return snapshotResult;
|
|
1593
1947
|
},
|
|
1594
1948
|
onUpdate(handler) {
|
|
1949
|
+
start();
|
|
1595
1950
|
if (releaseTimer) {
|
|
1596
1951
|
clearTimeout(releaseTimer);
|
|
1597
1952
|
releaseTimer = undefined;
|
|
@@ -1609,9 +1964,7 @@ export class GonvexClient {
|
|
|
1609
1964
|
releaseTimer = undefined;
|
|
1610
1965
|
if (updateHandlers.size > 0)
|
|
1611
1966
|
return;
|
|
1612
|
-
|
|
1613
|
-
unsubscribeReplica();
|
|
1614
|
-
unsubscribeScope();
|
|
1967
|
+
stop?.();
|
|
1615
1968
|
}, 0);
|
|
1616
1969
|
};
|
|
1617
1970
|
},
|
|
@@ -1656,7 +2009,7 @@ export class GonvexClient {
|
|
|
1656
2009
|
scope,
|
|
1657
2010
|
};
|
|
1658
2011
|
await this.replica.replaceWindow(window);
|
|
1659
|
-
const snapshot = { ...message, result:
|
|
2012
|
+
const snapshot = { ...message, result: [] };
|
|
1660
2013
|
subscription.lastMessage = snapshot;
|
|
1661
2014
|
this.emitReplicaMessage(subscription, snapshot, scope);
|
|
1662
2015
|
return;
|
|
@@ -1667,12 +2020,20 @@ export class GonvexClient {
|
|
|
1667
2020
|
return;
|
|
1668
2021
|
this.clearReplicaRetry(subscription, true);
|
|
1669
2022
|
raiseReplicaCursorFloor(subscription, message.cursor);
|
|
2023
|
+
const upserts = (message.upserts ?? []).filter((row) => asReplicaRow(row) !== undefined).map(row => asReplicaRow(row));
|
|
2024
|
+
// Incremental integrity is safe only for full projected row images. Older
|
|
2025
|
+
// partial-row protocols keep the full normalized rehash on replica.ready.
|
|
2026
|
+
const canCarryHashes = prior?.hashes && subscription.columns?.length
|
|
2027
|
+
&& upserts.every(row => subscription.columns.every(column => hasOwn(row, column)));
|
|
2028
|
+
const hashes = message.hashes ?? (canCarryHashes
|
|
2029
|
+
? await applyReplicaHashDelta(prior.hashes, projectReplicaIntegrityRows(upserts, subscription.columns), message.deleted ?? [], prior?.key ?? "id")
|
|
2030
|
+
: undefined);
|
|
1670
2031
|
await this.replica.applyWindowDelta({
|
|
1671
2032
|
signature: subscription.key,
|
|
1672
2033
|
kind: "replica",
|
|
1673
2034
|
entity: subscription.entity,
|
|
1674
2035
|
key: prior?.key ?? "id",
|
|
1675
|
-
upserts
|
|
2036
|
+
upserts,
|
|
1676
2037
|
deleted: message.deleted ?? [],
|
|
1677
2038
|
completeness: prior?.completeness ?? "partial",
|
|
1678
2039
|
source: "server",
|
|
@@ -1683,13 +2044,11 @@ export class GonvexClient {
|
|
|
1683
2044
|
orderDirection: prior?.orderDirection,
|
|
1684
2045
|
maxRows: prior?.maxRows,
|
|
1685
2046
|
maxBytes: prior?.maxBytes,
|
|
1686
|
-
|
|
1687
|
-
// supplied a complete replacement map with this frame.
|
|
1688
|
-
hashes: message.hashes,
|
|
2047
|
+
hashes,
|
|
1689
2048
|
});
|
|
1690
2049
|
const snapshot = {
|
|
1691
2050
|
type: "replica.snapshot", id: subscription.id, path: subscription.path,
|
|
1692
|
-
result:
|
|
2051
|
+
result: [], cursor: message.cursor,
|
|
1693
2052
|
key: prior?.key ?? "id", mode: prior?.mode, orderBy: prior?.orderBy,
|
|
1694
2053
|
orderDirection: prior?.orderDirection, maxRows: prior?.maxRows, maxBytes: prior?.maxBytes,
|
|
1695
2054
|
};
|
|
@@ -1839,9 +2198,15 @@ export class GonvexClient {
|
|
|
1839
2198
|
emitReplicaMessage(subscription, message, scope = this.replicaScope) {
|
|
1840
2199
|
if (scope !== this.replicaScope || subscription.scope !== scope)
|
|
1841
2200
|
return;
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
2201
|
+
// Hook observers consume metadata only; their rows come from Local Replica.
|
|
2202
|
+
// Materialize a payload only when a public transport consumer needs it.
|
|
2203
|
+
let outgoing;
|
|
2204
|
+
for (const listener of Array.from(subscription.listeners)) {
|
|
2205
|
+
if (this.replicaMetadataListeners.has(listener))
|
|
2206
|
+
listener(message);
|
|
2207
|
+
else
|
|
2208
|
+
listener(outgoing ??= this.materializeReplicaMessage(subscription, message));
|
|
2209
|
+
}
|
|
1845
2210
|
}
|
|
1846
2211
|
materializeReplicaMessage(subscription, message) {
|
|
1847
2212
|
if (message.type !== "replica.snapshot")
|
|
@@ -1887,7 +2252,7 @@ export class GonvexClient {
|
|
|
1887
2252
|
subscription.isUpToDate = false;
|
|
1888
2253
|
const message = {
|
|
1889
2254
|
type: "replica.snapshot", id: subscription.id, path: subscription.path,
|
|
1890
|
-
result:
|
|
2255
|
+
result: [], cursor: cached.cursor ?? { epoch: "cache", revision: 0 },
|
|
1891
2256
|
key: cached.key, mode: cached.mode, orderBy: cached.orderBy,
|
|
1892
2257
|
orderDirection: cached.orderDirection, maxRows: cached.maxRows, maxBytes: cached.maxBytes,
|
|
1893
2258
|
};
|
|
@@ -2031,7 +2396,6 @@ export class GonvexClient {
|
|
|
2031
2396
|
// and sends a second time with the same command ID.
|
|
2032
2397
|
this.outboxReady = this.restoreOutbox(this.outboxScope, generation);
|
|
2033
2398
|
await this.outboxReady;
|
|
2034
|
-
this.ensureLocalCollections();
|
|
2035
2399
|
}
|
|
2036
2400
|
quarantineReplicaScope() {
|
|
2037
2401
|
this.localIdentity = undefined;
|
|
@@ -2059,6 +2423,7 @@ export class GonvexClient {
|
|
|
2059
2423
|
}
|
|
2060
2424
|
async restoreOutbox(scope, generation) {
|
|
2061
2425
|
await this.replicaReady;
|
|
2426
|
+
await this.replica.synchronizeStorage();
|
|
2062
2427
|
const entries = await this.reducerOutbox.loadAll(scope);
|
|
2063
2428
|
if (this.manuallyClosed
|
|
2064
2429
|
|| generation !== this.outboxScopeGeneration
|
|
@@ -2095,8 +2460,11 @@ export class GonvexClient {
|
|
|
2095
2460
|
void this.drainOutbox();
|
|
2096
2461
|
}
|
|
2097
2462
|
addOptimisticReducer(reducerId, patches, accepted = false) {
|
|
2098
|
-
if (patches.length === 0
|
|
2463
|
+
if (patches.length === 0) {
|
|
2464
|
+
this.optimisticReducerIds.delete(reducerId);
|
|
2465
|
+
this.replica.rejectCommand(reducerId);
|
|
2099
2466
|
return;
|
|
2467
|
+
}
|
|
2100
2468
|
this.optimisticReducerIds.add(reducerId);
|
|
2101
2469
|
this.replica.applyOptimistic(reducerId, patches);
|
|
2102
2470
|
}
|
|
@@ -2124,83 +2492,90 @@ export class GonvexClient {
|
|
|
2124
2492
|
const drainScope = this.outboxScope;
|
|
2125
2493
|
this.drainingOutbox = true;
|
|
2126
2494
|
try {
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
const entry = await this.inLocalLane(async () => {
|
|
2130
|
-
const next = await this.reducerOutbox.nextReady(scope, Date.now());
|
|
2131
|
-
if (next)
|
|
2132
|
-
await this.reducerOutbox.markInflight(next.id);
|
|
2133
|
-
return next;
|
|
2134
|
-
});
|
|
2135
|
-
if (!entry)
|
|
2136
|
-
return;
|
|
2137
|
-
if (scope !== this.outboxScope)
|
|
2138
|
-
return;
|
|
2139
|
-
if (this.directOutboxReducerIds.has(entry.idempotencyKey)) {
|
|
2140
|
-
// Scope recovery may observe an inflight row created by this live
|
|
2141
|
-
// process and reset it to pending under the assumption that a prior
|
|
2142
|
-
// process crashed. The foreground call still owns that command ID.
|
|
2143
|
-
// Restore the durable marker and wait for its real result instead of
|
|
2144
|
-
// registering a second response handler for the same command.
|
|
2145
|
-
await this.reducerOutbox.markInflight(entry.id);
|
|
2495
|
+
await this.coordinateOutbox("delivery", async () => {
|
|
2496
|
+
if (this.manuallyClosed || drainScope !== this.outboxScope || !this.canSendReducerNow())
|
|
2146
2497
|
return;
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
else if ((entry.patches?.length ?? 0) > 0) {
|
|
2170
|
-
await this.settleOptimisticReducer(entry.idempotencyKey);
|
|
2498
|
+
if (this.sharedOutboxStore)
|
|
2499
|
+
await this.reducerOutbox.recoverInflight?.(drainScope);
|
|
2500
|
+
while (!this.manuallyClosed && this.socket?.readyState === WebSocket.OPEN) {
|
|
2501
|
+
const scope = this.outboxScope;
|
|
2502
|
+
const entry = await this.inLocalLane(async () => {
|
|
2503
|
+
const next = await this.reducerOutbox.nextReady(scope, Date.now());
|
|
2504
|
+
if (next)
|
|
2505
|
+
await this.reducerOutbox.markInflight(next.id);
|
|
2506
|
+
return next;
|
|
2507
|
+
});
|
|
2508
|
+
if (!entry)
|
|
2509
|
+
return;
|
|
2510
|
+
if (scope !== this.outboxScope)
|
|
2511
|
+
return;
|
|
2512
|
+
if (this.directOutboxReducerIds.has(entry.idempotencyKey)) {
|
|
2513
|
+
// Scope recovery may observe an inflight row created by this live
|
|
2514
|
+
// process and reset it to pending under the assumption that a prior
|
|
2515
|
+
// process crashed. The foreground call still owns that command ID.
|
|
2516
|
+
// Restore the durable marker and wait for its real result instead of
|
|
2517
|
+
// registering a second response handler for the same command.
|
|
2518
|
+
await this.reducerOutbox.markInflight(entry.id);
|
|
2519
|
+
return;
|
|
2171
2520
|
}
|
|
2172
|
-
|
|
2173
|
-
await this.
|
|
2521
|
+
if (!this.canSendReducerNow()) {
|
|
2522
|
+
await this.reducerOutbox.markPending(entry.id);
|
|
2523
|
+
return;
|
|
2174
2524
|
}
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2525
|
+
await this.reducerOutbox.markInflight(entry.id);
|
|
2526
|
+
if (scope !== this.outboxScope)
|
|
2527
|
+
return;
|
|
2528
|
+
if (!this.canSendReducerNow()) {
|
|
2178
2529
|
await this.reducerOutbox.markPending(entry.id);
|
|
2179
|
-
this.requireClientUpdate(error.message);
|
|
2180
2530
|
return;
|
|
2181
2531
|
}
|
|
2182
|
-
|
|
2532
|
+
try {
|
|
2533
|
+
await this.call("reducer", { kind: "reducer", path: entry.path, receiptPath: entry.receiptPath, ...(entry.localExecution ? { artifactHash: entry.localExecution.artifactHash, intentEntropy: entry.localExecution.intentEntropy } : {}) }, entry.args, this.timeouts.reducerTimeoutMs, entry.idempotencyKey, entry.idempotencyKey);
|
|
2534
|
+
await this.reducerOutbox.markCommitted(entry.id);
|
|
2183
2535
|
if (entry.localExecution && this.localExecutor) {
|
|
2184
2536
|
await this.inLocalLane(async () => {
|
|
2185
2537
|
await this.reducerOutbox.ack(entry.id);
|
|
2186
|
-
if (scope === this.outboxScope)
|
|
2538
|
+
if (scope === this.outboxScope)
|
|
2187
2539
|
await this.rebaseLocalEntries();
|
|
2188
|
-
this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
|
|
2189
|
-
}
|
|
2190
2540
|
});
|
|
2191
2541
|
}
|
|
2192
|
-
else
|
|
2193
|
-
await this.
|
|
2194
|
-
|
|
2542
|
+
else if ((entry.patches?.length ?? 0) > 0) {
|
|
2543
|
+
await this.settleOptimisticReducer(entry.idempotencyKey);
|
|
2544
|
+
}
|
|
2545
|
+
else {
|
|
2546
|
+
await this.ackOptimisticReducer(entry.idempotencyKey, entry.id);
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
catch (error) {
|
|
2550
|
+
if (error instanceof Error && /STALE_REDUCER_ARTIFACT|CLIENT_UPDATE_REQUIRED/.test(error.message)) {
|
|
2551
|
+
await this.reducerOutbox.markPending(entry.id);
|
|
2552
|
+
this.requireClientUpdate(error.message);
|
|
2553
|
+
return;
|
|
2554
|
+
}
|
|
2555
|
+
if (error instanceof GonvexClientError && error.code === "server") {
|
|
2556
|
+
if (entry.localExecution && this.localExecutor) {
|
|
2557
|
+
await this.inLocalLane(async () => {
|
|
2558
|
+
await this.reducerOutbox.ack(entry.id);
|
|
2559
|
+
if (scope === this.outboxScope) {
|
|
2560
|
+
await this.rebaseLocalEntries(true);
|
|
2561
|
+
this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
|
|
2562
|
+
}
|
|
2563
|
+
});
|
|
2564
|
+
}
|
|
2565
|
+
else
|
|
2566
|
+
await this.rejectOptimisticReducer(entry.idempotencyKey, entry.id);
|
|
2567
|
+
continue;
|
|
2568
|
+
}
|
|
2569
|
+
await this.reducerOutbox.fail(entry.id, reducerErrorMessage(error));
|
|
2570
|
+
this.scheduleOutboxDrain(Math.min(30_000, 1_000 * (2 ** (entry.attempts + 1))));
|
|
2571
|
+
return;
|
|
2195
2572
|
}
|
|
2196
|
-
await this.reducerOutbox.fail(entry.id, reducerErrorMessage(error));
|
|
2197
|
-
this.scheduleOutboxDrain(Math.min(30_000, 1_000 * (2 ** (entry.attempts + 1))));
|
|
2198
|
-
return;
|
|
2199
2573
|
}
|
|
2200
|
-
}
|
|
2574
|
+
});
|
|
2201
2575
|
}
|
|
2202
2576
|
finally {
|
|
2203
2577
|
this.drainingOutbox = false;
|
|
2578
|
+
this.scheduleLocalReplay();
|
|
2204
2579
|
if (!this.manuallyClosed && drainScope !== this.outboxScope) {
|
|
2205
2580
|
void this.drainOutbox();
|
|
2206
2581
|
}
|
|
@@ -2514,8 +2889,11 @@ export class GonvexClient {
|
|
|
2514
2889
|
return;
|
|
2515
2890
|
}
|
|
2516
2891
|
if (kind === "reducer" && message.type === "reducer.result") {
|
|
2517
|
-
this.replica.acknowledgeCommand(message.originCommandId, message.committedRevision);
|
|
2518
2892
|
const complete = () => {
|
|
2893
|
+
// A collection frame can advance the shared cursor before another
|
|
2894
|
+
// collection's rows and membership have arrived. Retire the local
|
|
2895
|
+
// prediction only after the commit's durable watermark barrier.
|
|
2896
|
+
this.replica.acknowledgeCommand(message.originCommandId, message.committedRevision);
|
|
2519
2897
|
settle();
|
|
2520
2898
|
this.emitTelemetryFromCall(kind, id, ref.path, "ok", clientSentAtMs, message.trace);
|
|
2521
2899
|
resolve(message.result);
|
|
@@ -3160,15 +3538,33 @@ function replaceOfflineLiveQueryMetadata(result, path, offline) {
|
|
|
3160
3538
|
const current = result[head];
|
|
3161
3539
|
return { ...result, [head]: replaceOfflineLiveQueryMetadata(current ?? null, tail, offline) };
|
|
3162
3540
|
}
|
|
3541
|
+
// Generated references and their plans are immutable module metadata. Retain
|
|
3542
|
+
// one canonical contract per reference, not a fresh sorted serialization for
|
|
3543
|
+
// every React watch/read. Weak keys do not retain replaced modules or callers.
|
|
3544
|
+
const subscriptionContracts = new WeakMap();
|
|
3163
3545
|
function querySubscriptionKey(ref, args) {
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
:
|
|
3170
|
-
|
|
3171
|
-
|
|
3546
|
+
let cached = subscriptionContracts.get(ref);
|
|
3547
|
+
if (!cached || cached.scope !== ref.scope || cached.delivery !== ref.delivery || cached.live !== ref.live) {
|
|
3548
|
+
const contract = {
|
|
3549
|
+
scope: ref.scope ?? "tenant",
|
|
3550
|
+
delivery: ref.delivery ?? "oneShot",
|
|
3551
|
+
live: ref.live
|
|
3552
|
+
? { entity: ref.live.entity, key: ref.live.key, resultPath: [...(ref.live.resultPath ?? [])], plan: ref.live.plan ?? null }
|
|
3553
|
+
: null,
|
|
3554
|
+
};
|
|
3555
|
+
cached = { scope: ref.scope, delivery: ref.delivery, live: ref.live, value: stableStringify(contract), keys: new Map() };
|
|
3556
|
+
subscriptionContracts.set(ref, cached);
|
|
3557
|
+
}
|
|
3558
|
+
const argumentsKey = `${ref.path}\u0000${stableStringify(args)}`;
|
|
3559
|
+
const previous = cached.keys.get(argumentsKey);
|
|
3560
|
+
if (previous !== undefined)
|
|
3561
|
+
return previous;
|
|
3562
|
+
const key = `${argumentsKey}\u0000${cached.value}`;
|
|
3563
|
+
// Search text and pagination may produce unbounded argument combinations.
|
|
3564
|
+
if (cached.keys.size >= 16)
|
|
3565
|
+
cached.keys.delete(cached.keys.keys().next().value);
|
|
3566
|
+
cached.keys.set(argumentsKey, key);
|
|
3567
|
+
return key;
|
|
3172
3568
|
}
|
|
3173
3569
|
function countPendingCalls(calls, kind) {
|
|
3174
3570
|
let count = 0;
|
|
@@ -3202,6 +3598,9 @@ function stableStringify(value) {
|
|
|
3202
3598
|
.join(",")}}`;
|
|
3203
3599
|
}
|
|
3204
3600
|
function utf8KeyCompare(left, right) {
|
|
3601
|
+
// ASCII keys have identical UTF-8 and JavaScript lexical ordering.
|
|
3602
|
+
if (!/[^\x00-\x7f]/.test(left) && !/[^\x00-\x7f]/.test(right))
|
|
3603
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
3205
3604
|
const leftBytes = new TextEncoder().encode(left);
|
|
3206
3605
|
const rightBytes = new TextEncoder().encode(right);
|
|
3207
3606
|
const length = Math.min(leftBytes.length, rightBytes.length);
|
|
@@ -3222,9 +3621,9 @@ function boundReplicaRows(rows, keyField, maxRows, maxBytes, orderBy, orderDirec
|
|
|
3222
3621
|
const key = replicaRowKeyValue(row, keyField);
|
|
3223
3622
|
if (!key || seen.has(key))
|
|
3224
3623
|
continue;
|
|
3225
|
-
const size = replicaJSONSize(row);
|
|
3226
3624
|
if (maxRows && kept.length >= maxRows)
|
|
3227
3625
|
break;
|
|
3626
|
+
const size = maxBytes ? replicaJSONSize(row) : 0;
|
|
3228
3627
|
if (maxBytes && bytes + size > maxBytes)
|
|
3229
3628
|
break;
|
|
3230
3629
|
kept.push(row);
|
|
@@ -3271,7 +3670,10 @@ function replicaRowKeyValue(value, keyField) {
|
|
|
3271
3670
|
return key === null || key === undefined ? "" : String(key);
|
|
3272
3671
|
}
|
|
3273
3672
|
function replicaJSONSize(value) {
|
|
3274
|
-
|
|
3673
|
+
// Property ordering cannot change the encoded length. Native serialization
|
|
3674
|
+
// avoids recursively allocating sorted key arrays and intermediate strings.
|
|
3675
|
+
const json = JSON.stringify(value).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
3676
|
+
return new TextEncoder().encode(json).byteLength;
|
|
3275
3677
|
}
|
|
3276
3678
|
function applyKeyedPatch(previous, patch) {
|
|
3277
3679
|
const rows = new Map();
|
|
@@ -3535,4 +3937,34 @@ function detectDeviceType(userAgent) {
|
|
|
3535
3937
|
return "mobile";
|
|
3536
3938
|
return "desktop";
|
|
3537
3939
|
}
|
|
3940
|
+
/** Sign-in and account-only pages do not need reducer code loaded. */
|
|
3941
|
+
function deferredLocalExecutor(create) {
|
|
3942
|
+
let executor;
|
|
3943
|
+
let closed = false;
|
|
3944
|
+
const start = () => {
|
|
3945
|
+
if (closed)
|
|
3946
|
+
throw new Error("Local reducer executor is closed");
|
|
3947
|
+
return executor ??= create();
|
|
3948
|
+
};
|
|
3949
|
+
return {
|
|
3950
|
+
get ready() {
|
|
3951
|
+
try {
|
|
3952
|
+
return start().ready;
|
|
3953
|
+
}
|
|
3954
|
+
catch (error) {
|
|
3955
|
+
return Promise.reject(error);
|
|
3956
|
+
}
|
|
3957
|
+
},
|
|
3958
|
+
prepare: async (path) => start().prepare?.(path),
|
|
3959
|
+
execute: async (...args) => start().execute(...args),
|
|
3960
|
+
executeRead: async (...args) => {
|
|
3961
|
+
const instance = start();
|
|
3962
|
+
if (!instance.executeRead)
|
|
3963
|
+
throw new Error('Local executor does not support indexed reads');
|
|
3964
|
+
return instance.executeRead(...args);
|
|
3965
|
+
},
|
|
3966
|
+
replay: async (...args) => start().replay(...args),
|
|
3967
|
+
close: () => { closed = true; executor?.close(); },
|
|
3968
|
+
};
|
|
3969
|
+
}
|
|
3538
3970
|
//# sourceMappingURL=index.js.map
|