@gonvex/client 0.5.2-staging.13 → 0.5.2-staging.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/dist/browser-upgrades.js +58 -12
- package/dist/browser-upgrades.js.map +1 -1
- package/dist/client-upgrades.js +2 -1
- package/dist/client-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 +206 -12
- package/dist/index.js +1099 -213
- 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 +102 -10
- package/dist/outbox.js +336 -89
- 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
|
-
import { createReducerOutbox, } from "./outbox.js";
|
|
6
|
+
import { createReducerOutbox, DEFAULT_OUTBOX_MAX_BACKOFF_MS, outboxBackoffMs, outboxEntryIsLive, } 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(),
|
|
@@ -91,14 +96,28 @@ export class GonvexClientError extends Error {
|
|
|
91
96
|
code;
|
|
92
97
|
path;
|
|
93
98
|
operation;
|
|
99
|
+
/**
|
|
100
|
+
* For `server` Reducer errors: the runtime's classification, or a legacy
|
|
101
|
+
* inference when an older runtime sent none. Undefined means a legacy
|
|
102
|
+
* runtime's unclassified error, which is handled as a rejection.
|
|
103
|
+
*/
|
|
104
|
+
errorClass;
|
|
105
|
+
/** True when the same call (same idempotency key) may succeed later. */
|
|
106
|
+
retryable;
|
|
94
107
|
constructor(message, options) {
|
|
95
108
|
super(message);
|
|
96
109
|
this.name = "GonvexClientError";
|
|
97
110
|
this.code = options.code;
|
|
98
111
|
this.path = options.path;
|
|
99
112
|
this.operation = options.operation;
|
|
113
|
+
if (options.errorClass)
|
|
114
|
+
this.errorClass = options.errorClass;
|
|
115
|
+
if (options.retryable !== undefined)
|
|
116
|
+
this.retryable = options.retryable;
|
|
100
117
|
}
|
|
101
118
|
}
|
|
119
|
+
export const DEFAULT_OUTBOX_MAX_ATTEMPTS = 10;
|
|
120
|
+
export const DEFAULT_OUTBOX_RETRY_MAX_BACKOFF_MS = 60_000;
|
|
102
121
|
export const DEFAULT_QUERY_TIMEOUT_MS = 20_000;
|
|
103
122
|
export const DEFAULT_REDUCER_TIMEOUT_MS = 20_000;
|
|
104
123
|
export const DEFAULT_ACTION_TIMEOUT_MS = 60_000;
|
|
@@ -117,20 +136,32 @@ const maxReplicaBatchOpens = 256;
|
|
|
117
136
|
// normally settle in a few milliseconds.
|
|
118
137
|
export class GonvexClient {
|
|
119
138
|
url;
|
|
139
|
+
localTableBindings = new Map();
|
|
120
140
|
localBinding;
|
|
121
141
|
localExecutor;
|
|
122
142
|
localStorage;
|
|
123
143
|
localIdentity;
|
|
124
144
|
localLane = Promise.resolve();
|
|
145
|
+
preparationLane = Promise.resolve();
|
|
125
146
|
localReplayScheduled = false;
|
|
147
|
+
waitingLocalEdits = 0;
|
|
126
148
|
replacingLocal = false;
|
|
127
149
|
unsubscribeLocal;
|
|
128
150
|
localCollectionClosers = [];
|
|
151
|
+
localCollectionKeys = new Set();
|
|
152
|
+
localExecutionTables = new Map();
|
|
129
153
|
reducerRejectionHandlers = new Set();
|
|
154
|
+
outboxRetry;
|
|
155
|
+
unauthenticatedRetries = 0;
|
|
156
|
+
intentsSnapshotValue = [];
|
|
157
|
+
intentListeners = new Set();
|
|
158
|
+
intentsRefreshRunning = false;
|
|
159
|
+
intentsRefreshDirty = false;
|
|
130
160
|
socket;
|
|
131
161
|
handlers = new Map();
|
|
132
162
|
querySubscriptions = new Map();
|
|
133
163
|
replicaSubscriptions = new Map();
|
|
164
|
+
replicaMetadataListeners = new WeakSet();
|
|
134
165
|
oneShotQueries = new Map();
|
|
135
166
|
telemetryHandlers = new Set();
|
|
136
167
|
pendingMessages = [];
|
|
@@ -179,8 +210,15 @@ export class GonvexClient {
|
|
|
179
210
|
processedReplicaWatermarkRevision = 0;
|
|
180
211
|
pendingReplicaTransactions = [];
|
|
181
212
|
unsubscribeOutbox;
|
|
213
|
+
sharedOutboxStore;
|
|
214
|
+
unsubscribePeerOutbox;
|
|
215
|
+
unsubscribePeerReplica;
|
|
216
|
+
peerRefreshScheduled = false;
|
|
217
|
+
peerRefreshDirty = false;
|
|
182
218
|
unsubscribeBrowserOnline;
|
|
183
219
|
drainingOutbox = false;
|
|
220
|
+
/** A wake-up (timer, enqueue, reconnect) that arrived while a drain was running. */
|
|
221
|
+
outboxDrainRequested = false;
|
|
184
222
|
outboxDrainTimer;
|
|
185
223
|
sessionScopeHandlers = new Set();
|
|
186
224
|
errorReporter;
|
|
@@ -206,18 +244,43 @@ export class GonvexClient {
|
|
|
206
244
|
this.clientContract = options.clientContract ?? options.localRuntime?.clientContract;
|
|
207
245
|
this.updateRequiredHandler = options.onUpdateRequired;
|
|
208
246
|
this.localBinding = options.localRuntime;
|
|
209
|
-
this.
|
|
247
|
+
for (const table of this.localBinding?.tables ?? []) {
|
|
248
|
+
const references = (this.localBinding?.collections ?? []).filter(ref => ref.replica?.table === table
|
|
249
|
+
&& !Object.keys(ref.replica.equalFilters ?? {}).length && !(ref.replica.excludeWhenSet?.length));
|
|
250
|
+
this.localTableBindings.set(table, { signatures: references.map(ref => querySubscriptionKey(ref, {})),
|
|
251
|
+
columns: [...new Set(references.flatMap(ref => ref.replica?.columns ?? []))] });
|
|
252
|
+
}
|
|
253
|
+
this.localExecutor = options.localRuntime ? deferredLocalExecutor(() => options.localRuntime.create()) : undefined;
|
|
210
254
|
this.localStorage = options.localReplica?.storage;
|
|
211
255
|
this.auth = authFromOptions(options);
|
|
212
256
|
this.telemetryEnabled = options.telemetry === true;
|
|
213
257
|
this.querySubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.querySubscriptionRetentionMs);
|
|
214
258
|
this.replicaSubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.replicaSubscriptionRetentionMs);
|
|
259
|
+
this.sharedOutboxStore = options.outbox?.store?.shared ? options.outbox.store : undefined;
|
|
215
260
|
this.reducerOutbox = createReducerOutbox(options.outbox);
|
|
216
|
-
this.
|
|
261
|
+
this.outboxRetry = {
|
|
262
|
+
maxAttempts: positiveOr(options.outbox?.retry?.maxAttempts, DEFAULT_OUTBOX_MAX_ATTEMPTS),
|
|
263
|
+
maxBackoffMs: positiveOr(options.outbox?.retry?.maxBackoffMs, DEFAULT_OUTBOX_RETRY_MAX_BACKOFF_MS),
|
|
264
|
+
};
|
|
265
|
+
this.replica = new LocalReplica(options.localReplica?.storage, { maxResidentRows: options.localReplica?.maxResidentRows, maxResidentBytes: options.localReplica?.maxResidentBytes });
|
|
217
266
|
this.replicaView = createLocalReplicaView(this.replica);
|
|
218
|
-
if (this.localExecutor)
|
|
219
|
-
|
|
267
|
+
if (this.localExecutor) {
|
|
268
|
+
let executionVersion = this.replica.executionVersion();
|
|
269
|
+
this.unsubscribeLocal = this.replica.subscribe(() => {
|
|
270
|
+
const next = this.replica.executionVersion();
|
|
271
|
+
if (next === executionVersion)
|
|
272
|
+
return;
|
|
273
|
+
executionVersion = next;
|
|
274
|
+
this.scheduleLocalReplay();
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
this.unsubscribePeerOutbox = this.sharedOutboxStore?.subscribePeer?.(() => this.refreshPeerOutbox());
|
|
278
|
+
this.unsubscribePeerReplica = this.localStorage?.subscribePeer?.(scope => {
|
|
279
|
+
if (scope === this.replicaScope)
|
|
280
|
+
this.refreshPeerOutbox();
|
|
281
|
+
});
|
|
220
282
|
this.unsubscribeOutbox = this.reducerOutbox.subscribe(() => {
|
|
283
|
+
this.refreshIntents();
|
|
221
284
|
void this.drainOutbox();
|
|
222
285
|
});
|
|
223
286
|
if (typeof globalThis.addEventListener === "function") {
|
|
@@ -256,11 +319,254 @@ export class GonvexClient {
|
|
|
256
319
|
if (this.localBinding)
|
|
257
320
|
this.outboxReady = this.restoreLocalSession(initialScope, this.outboxScopeGeneration);
|
|
258
321
|
}
|
|
259
|
-
/**
|
|
322
|
+
/**
|
|
323
|
+
* Authoritative sync failures arrive after an offline call returned locally.
|
|
324
|
+
* The intent also stays in the outbox as `rejected` until it is discarded
|
|
325
|
+
* or retried, so a UI that mounts later can still show it.
|
|
326
|
+
*/
|
|
260
327
|
onReducerRejection(listener) {
|
|
261
328
|
this.reducerRejectionHandlers.add(listener);
|
|
262
329
|
return () => this.reducerRejectionHandlers.delete(listener);
|
|
263
330
|
}
|
|
331
|
+
/** Every durable intent of the current identity, oldest first. */
|
|
332
|
+
async listIntents() {
|
|
333
|
+
await this.outboxReady;
|
|
334
|
+
return (await this.reducerOutbox.list(this.outboxScope)).map(outboxIntentFromEntry);
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Synchronous snapshot for external stores (React `useSyncExternalStore`).
|
|
338
|
+
* Kept current while at least one {@link subscribeIntents} listener exists.
|
|
339
|
+
*/
|
|
340
|
+
intentsSnapshot() {
|
|
341
|
+
return this.intentsSnapshotValue;
|
|
342
|
+
}
|
|
343
|
+
/** Observe intent changes; call {@link intentsSnapshot} for the new value. */
|
|
344
|
+
subscribeIntents(listener) {
|
|
345
|
+
this.intentListeners.add(listener);
|
|
346
|
+
if (this.intentListeners.size === 1)
|
|
347
|
+
this.refreshIntents();
|
|
348
|
+
return () => { this.intentListeners.delete(listener); };
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Delivery status for one row from the current snapshot: `failed` or
|
|
352
|
+
* `rejected` when an intent touching it needs attention, `syncing` while one
|
|
353
|
+
* is still queued, otherwise undefined.
|
|
354
|
+
*/
|
|
355
|
+
entityIntentStatus(entity, id) {
|
|
356
|
+
return entityStatusFromIntents(this.intentsSnapshotValue, entity, id);
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Re-arm a `failed` or `rejected` intent with a fresh retry budget. The
|
|
360
|
+
* original idempotency key is reused, so an attempt that had actually
|
|
361
|
+
* committed is replayed by the server rather than applied twice.
|
|
362
|
+
*/
|
|
363
|
+
async retryIntent(id) {
|
|
364
|
+
await this.outboxReady;
|
|
365
|
+
const retried = await this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
|
|
366
|
+
const scope = this.outboxScope;
|
|
367
|
+
const entry = (await this.reducerOutbox.list(scope)).find((candidate) => candidate.idempotencyKey === id);
|
|
368
|
+
if (!entry || scope !== this.outboxScope)
|
|
369
|
+
return false;
|
|
370
|
+
const updated = await this.reducerOutbox.retry(entry.id);
|
|
371
|
+
if (!updated)
|
|
372
|
+
return false;
|
|
373
|
+
this.optimisticOutboxEntryIds.set(id, updated.id);
|
|
374
|
+
if (!outboxEntryIsLive(entry)) {
|
|
375
|
+
// A rejection rolled the prediction back; restore it at its original
|
|
376
|
+
// position in the causal chain.
|
|
377
|
+
if (updated.localExecution && this.localExecutor)
|
|
378
|
+
await this.rebaseLocalEntriesLocked(true);
|
|
379
|
+
else
|
|
380
|
+
this.addOptimisticReducer(id, updated.patches ?? []);
|
|
381
|
+
}
|
|
382
|
+
return true;
|
|
383
|
+
}));
|
|
384
|
+
if (retried) {
|
|
385
|
+
this.unauthenticatedRetries = 0;
|
|
386
|
+
void this.drainOutbox();
|
|
387
|
+
}
|
|
388
|
+
return retried;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Drop a `pending`, `failed` or `rejected` intent: it will never be sent,
|
|
392
|
+
* its optimistic prediction is removed and later local intents are rebased.
|
|
393
|
+
* Inflight and committed intents cannot be discarded. Discarding an intent
|
|
394
|
+
* whose earlier attempt timed out does not undo a commit that the server
|
|
395
|
+
* may already have applied; the replica then shows the server's truth.
|
|
396
|
+
*/
|
|
397
|
+
async discardIntent(id) {
|
|
398
|
+
await this.outboxReady;
|
|
399
|
+
return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
|
|
400
|
+
const scope = this.outboxScope;
|
|
401
|
+
const entry = (await this.reducerOutbox.list(scope)).find((candidate) => candidate.idempotencyKey === id);
|
|
402
|
+
if (!entry || scope !== this.outboxScope)
|
|
403
|
+
return false;
|
|
404
|
+
const removed = await this.reducerOutbox.discard(entry.id, ["pending", "failed", "rejected"]);
|
|
405
|
+
if (!removed)
|
|
406
|
+
return false;
|
|
407
|
+
this.optimisticOutboxEntryIds.delete(id);
|
|
408
|
+
this.optimisticReducerIds.delete(id);
|
|
409
|
+
if (outboxEntryIsLive(removed)) {
|
|
410
|
+
this.replacingLocal = true;
|
|
411
|
+
try {
|
|
412
|
+
this.replica.rejectCommand(id);
|
|
413
|
+
}
|
|
414
|
+
finally {
|
|
415
|
+
this.replacingLocal = false;
|
|
416
|
+
}
|
|
417
|
+
if (removed.localExecution && this.localExecutor)
|
|
418
|
+
await this.rebaseLocalEntriesLocked(true);
|
|
419
|
+
}
|
|
420
|
+
return true;
|
|
421
|
+
}));
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Discard the persisted Local Replica of the active identity (IndexedDB,
|
|
425
|
+
* Expo SQLite or any configured storage) and rehydrate it from the server.
|
|
426
|
+
*
|
|
427
|
+
* Online only: without an authenticated connection the call rejects with a
|
|
428
|
+
* `GonvexClientError` (`code: "disconnected"`) and changes nothing, so the
|
|
429
|
+
* user is never left with an empty cache that cannot be refilled. The saved
|
|
430
|
+
* offline session (identity and replica directive) is kept.
|
|
431
|
+
*
|
|
432
|
+
* By default the outbox is untouched: pending, failed and rejected intents
|
|
433
|
+
* stay durable, and the predictions of every live intent are re-applied
|
|
434
|
+
* immediately and recomputed as server data arrives. Active Replica
|
|
435
|
+
* Collections and Live Queries are re-requested without their old cursors,
|
|
436
|
+
* so the server sends full snapshots.
|
|
437
|
+
*/
|
|
438
|
+
async resetLocalReplica(options = {}) {
|
|
439
|
+
const refuse = (message) => new GonvexClientError(message, { code: "disconnected", operation: "query" });
|
|
440
|
+
if (this.manuallyClosed)
|
|
441
|
+
throw new GonvexClientError("Gonvex client is closed", { code: "closed", operation: "query" });
|
|
442
|
+
await this.outboxReady;
|
|
443
|
+
await this.replicaReady;
|
|
444
|
+
if (!this.canSendReducerNow() || !this.hasAuthoritativeReplicaScope) {
|
|
445
|
+
throw refuse("Cannot reset local data while offline. Reconnect and try again; cached data was kept.");
|
|
446
|
+
}
|
|
447
|
+
const keepOutbox = options.keepOutbox !== false;
|
|
448
|
+
return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
|
|
449
|
+
const scope = this.outboxScope;
|
|
450
|
+
const replicaScope = this.replicaScope;
|
|
451
|
+
// Re-check under the lock: the connection may have dropped while waiting.
|
|
452
|
+
if (!this.canSendReducerNow() || !this.hasAuthoritativeReplicaScope) {
|
|
453
|
+
throw refuse("Cannot reset local data while offline. Reconnect and try again; cached data was kept.");
|
|
454
|
+
}
|
|
455
|
+
// Stop the server streaming into subscription ids we are about to retire.
|
|
456
|
+
for (const subscription of this.replicaSubscriptions.values()) {
|
|
457
|
+
if (subscription.socketGeneration !== undefined)
|
|
458
|
+
this.send({ type: "replica.close", id: subscription.id });
|
|
459
|
+
}
|
|
460
|
+
for (const subscription of this.querySubscriptions.values()) {
|
|
461
|
+
if (subscription.socketGeneration !== undefined)
|
|
462
|
+
this.send({ type: "query.unsubscribe", id: subscription.id });
|
|
463
|
+
}
|
|
464
|
+
this.pendingReplicaTransactions.length = 0;
|
|
465
|
+
await this.replica.clear(replicaScope);
|
|
466
|
+
this.resetReplicaScopeState();
|
|
467
|
+
this.rotateSubscriptionScopes();
|
|
468
|
+
let discardedIntents = 0;
|
|
469
|
+
if (!keepOutbox) {
|
|
470
|
+
for (const entry of await this.reducerOutbox.list(scope)) {
|
|
471
|
+
if (await this.reducerOutbox.discard(entry.id, ["pending", "failed", "rejected"]))
|
|
472
|
+
discardedIntents += 1;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// replica.clear() dropped every overlay. Restore the stored predictions
|
|
476
|
+
// of live intents now; local intents are re-executed by the replay that
|
|
477
|
+
// each arriving snapshot schedules.
|
|
478
|
+
const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
|
|
479
|
+
this.replacingLocal = true;
|
|
480
|
+
try {
|
|
481
|
+
this.replaceLocalPredictions(entries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
this.replacingLocal = false;
|
|
485
|
+
}
|
|
486
|
+
this.optimisticReducerIds.clear();
|
|
487
|
+
this.optimisticOutboxEntryIds.clear();
|
|
488
|
+
for (const entry of entries) {
|
|
489
|
+
this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
|
|
490
|
+
if (entry.state !== "committed" && entry.patches?.length)
|
|
491
|
+
this.optimisticReducerIds.add(entry.idempotencyKey);
|
|
492
|
+
}
|
|
493
|
+
let resubscribed = 0;
|
|
494
|
+
if (scope === this.outboxScope && replicaScope === this.replicaScope) {
|
|
495
|
+
for (const subscription of this.replicaSubscriptions.values())
|
|
496
|
+
if (subscription.listeners.size > 0)
|
|
497
|
+
resubscribed += 1;
|
|
498
|
+
for (const subscription of this.querySubscriptions.values())
|
|
499
|
+
if (subscription.listeners.size > 0)
|
|
500
|
+
resubscribed += 1;
|
|
501
|
+
this.resumeQuerySubscriptions(true);
|
|
502
|
+
this.resumeReplicaSubscriptions();
|
|
503
|
+
}
|
|
504
|
+
this.refreshIntents();
|
|
505
|
+
return { discardedIntents, resubscribed };
|
|
506
|
+
}));
|
|
507
|
+
}
|
|
508
|
+
/** Outbox owners with durable entries, including identities that never returned. */
|
|
509
|
+
async listOutboxScopes() {
|
|
510
|
+
await this.outboxReady;
|
|
511
|
+
const current = this.outboxScope;
|
|
512
|
+
return (await this.reducerOutbox.listScopes()).map((scope) => ({ ...scope, current: scope.scope === current }));
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Permanently delete another identity's durable intents. The active scope
|
|
516
|
+
* cannot be purged this way; use {@link discardIntent} for its entries.
|
|
517
|
+
*/
|
|
518
|
+
async purgeOutboxScope(scope) {
|
|
519
|
+
await this.outboxReady;
|
|
520
|
+
if (scope === this.outboxScope)
|
|
521
|
+
throw new Error("Cannot purge the active outbox scope");
|
|
522
|
+
return this.reducerOutbox.purgeScope(scope);
|
|
523
|
+
}
|
|
524
|
+
/** Purge every outbox scope except the current identity's. Never runs automatically. */
|
|
525
|
+
async purgeForeignOutboxScopes() {
|
|
526
|
+
let removed = 0;
|
|
527
|
+
for (const scope of await this.listOutboxScopes()) {
|
|
528
|
+
if (!scope.current)
|
|
529
|
+
removed += await this.purgeOutboxScope(scope.scope);
|
|
530
|
+
}
|
|
531
|
+
return removed;
|
|
532
|
+
}
|
|
533
|
+
refreshIntents() {
|
|
534
|
+
if (this.intentListeners.size === 0 || this.manuallyClosed)
|
|
535
|
+
return;
|
|
536
|
+
this.intentsRefreshDirty = true;
|
|
537
|
+
if (this.intentsRefreshRunning)
|
|
538
|
+
return;
|
|
539
|
+
this.intentsRefreshRunning = true;
|
|
540
|
+
void (async () => {
|
|
541
|
+
try {
|
|
542
|
+
while (this.intentsRefreshDirty && !this.manuallyClosed) {
|
|
543
|
+
this.intentsRefreshDirty = false;
|
|
544
|
+
await this.outboxReady.catch(() => undefined);
|
|
545
|
+
const scope = this.outboxScope;
|
|
546
|
+
const entries = await this.reducerOutbox.list(scope).catch(() => undefined);
|
|
547
|
+
if (!entries || scope !== this.outboxScope) {
|
|
548
|
+
this.intentsRefreshDirty ||= scope !== this.outboxScope;
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
this.publishIntents(entries.map(outboxIntentFromEntry));
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
finally {
|
|
555
|
+
this.intentsRefreshRunning = false;
|
|
556
|
+
}
|
|
557
|
+
})();
|
|
558
|
+
}
|
|
559
|
+
publishIntents(next) {
|
|
560
|
+
if (sameIntents(this.intentsSnapshotValue, next))
|
|
561
|
+
return;
|
|
562
|
+
this.intentsSnapshotValue = next;
|
|
563
|
+
for (const listener of this.intentListeners) {
|
|
564
|
+
try {
|
|
565
|
+
listener();
|
|
566
|
+
}
|
|
567
|
+
catch { /* A listener must not break delivery. */ }
|
|
568
|
+
}
|
|
569
|
+
}
|
|
264
570
|
async restoreLocalSession(scope, generation) {
|
|
265
571
|
if (!this.localBinding || !scope.startsWith("identity\u0000"))
|
|
266
572
|
return;
|
|
@@ -269,38 +575,163 @@ export class GonvexClient {
|
|
|
269
575
|
return;
|
|
270
576
|
this.lastOnlineAtMs = saved.lastOnlineAtMs ?? 0;
|
|
271
577
|
this.localIdentity = saved.identity;
|
|
578
|
+
void this.localExecutor?.ready.catch(() => undefined);
|
|
272
579
|
await this.activateReplicaDirective(saved.directive);
|
|
273
580
|
this.replica.setFreshness("offline");
|
|
274
581
|
}
|
|
275
|
-
ensureLocalCollections() {
|
|
276
|
-
if (!this.localBinding
|
|
582
|
+
ensureLocalCollections(tables) {
|
|
583
|
+
if (!this.localBinding)
|
|
277
584
|
return;
|
|
585
|
+
const needed = new Set(tables);
|
|
278
586
|
for (const reference of this.localBinding.collections ?? []) {
|
|
279
|
-
|
|
587
|
+
if (!reference.replica || !needed.has(reference.replica.table))
|
|
588
|
+
continue;
|
|
589
|
+
const key = querySubscriptionKey(reference, {});
|
|
590
|
+
if (this.localCollectionKeys.has(key))
|
|
591
|
+
continue;
|
|
592
|
+
this.localCollectionKeys.add(key);
|
|
593
|
+
this.localCollectionClosers.push(this.subscribeReplicaTransport(reference, {}, () => undefined, true));
|
|
280
594
|
}
|
|
281
595
|
}
|
|
596
|
+
hydrateMissingLocalTable(error) {
|
|
597
|
+
if (!(error instanceof Error) || error.name !== "IncompleteReplicaError")
|
|
598
|
+
return;
|
|
599
|
+
// Worker errors preserve the public runtime name/message contract.
|
|
600
|
+
const table = /^Local replica for (.+?) is incomplete;/.exec(error.message)?.[1];
|
|
601
|
+
if (table)
|
|
602
|
+
this.ensureLocalCollections([table]);
|
|
603
|
+
}
|
|
282
604
|
localSnapshot(includePending) {
|
|
283
|
-
const
|
|
605
|
+
const rows = this.replica.captureExecutionRows(includePending);
|
|
284
606
|
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] ?? {}) };
|
|
607
|
+
for (const [table, binding] of this.localTableBindings) {
|
|
608
|
+
const complete = binding.signatures.some(signature => this.replica.windowIsComplete(signature));
|
|
609
|
+
tables[table] = { complete, columns: binding.columns, get rows() { return rows(table); } };
|
|
293
610
|
}
|
|
294
611
|
return { scope: this.replicaScope, tables };
|
|
295
612
|
}
|
|
613
|
+
localReadCoverage() {
|
|
614
|
+
return this.replica.captureReadCoverage(Object.fromEntries([...this.localTableBindings].map(([table, binding]) => [table, {
|
|
615
|
+
key: this.localBinding.collections?.find(ref => ref.replica?.table === table)?.replica?.key ?? '_id',
|
|
616
|
+
complete: binding.signatures.some(signature => this.replica.windowIsComplete(signature)), columns: binding.columns,
|
|
617
|
+
}])));
|
|
618
|
+
}
|
|
619
|
+
isUncoveredLocalRead(error) {
|
|
620
|
+
if (!(error instanceof Error) || error.name !== 'IncompleteReplicaError')
|
|
621
|
+
return false;
|
|
622
|
+
const read = error.read;
|
|
623
|
+
if (!read || read.limit === 0)
|
|
624
|
+
return false;
|
|
625
|
+
const known = this.localReadCoverage()[read.table];
|
|
626
|
+
// Disk can recover evicted primary keys or a complete registered slice.
|
|
627
|
+
// It cannot prove a scan over data that has never been delivered. Queue
|
|
628
|
+
// that intent for replay instead of waiting for unrelated cache writes.
|
|
629
|
+
return !readIsCovered(known, read) && primaryReadKeys(read, known?.key ?? '_id') === undefined;
|
|
630
|
+
}
|
|
631
|
+
async executeLocal(path, args, snapshot, execution, pending = true, residentOnly = false) {
|
|
632
|
+
if (this.localBinding?.mode === 'portable') {
|
|
633
|
+
try {
|
|
634
|
+
const run = (source) => this.localExecutor.executeRead(path, args, source, execution);
|
|
635
|
+
let transaction;
|
|
636
|
+
try {
|
|
637
|
+
transaction = await this.replica.withReadView(() => this.localReadCoverage(), pending, run, true);
|
|
638
|
+
}
|
|
639
|
+
catch (error) {
|
|
640
|
+
if (residentOnly || !(error instanceof Error) || error.name !== 'IncompleteReplicaError' || this.isUncoveredLocalRead(error))
|
|
641
|
+
throw error;
|
|
642
|
+
transaction = await this.replica.withReadView(() => this.localReadCoverage(), pending, run);
|
|
643
|
+
}
|
|
644
|
+
// The read view already proved every read from the available replica
|
|
645
|
+
// windows or cached primary keys. Do not promote those bounded reads
|
|
646
|
+
// to whole-table subscriptions after a successful user edit. Missing
|
|
647
|
+
// coverage still opens its generated collection in the error path.
|
|
648
|
+
return transaction;
|
|
649
|
+
}
|
|
650
|
+
catch (error) {
|
|
651
|
+
// A speculative memory-only read may simply need IndexedDB. Its
|
|
652
|
+
// coordinated retry checks disk before deciding to request more data.
|
|
653
|
+
if (!residentOnly)
|
|
654
|
+
this.hydrateMissingLocalTable(error);
|
|
655
|
+
throw error;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (!snapshot)
|
|
659
|
+
throw new Error('Legacy local executor requires a snapshot');
|
|
660
|
+
const known = this.localExecutionTables.get(path);
|
|
661
|
+
const input = known ? { ...snapshot, tables: Object.fromEntries(Object.entries(snapshot.tables).filter(([table]) => known.has(table))) } : snapshot;
|
|
662
|
+
let transaction;
|
|
663
|
+
try {
|
|
664
|
+
transaction = await this.localExecutor.execute(path, args, input, execution);
|
|
665
|
+
}
|
|
666
|
+
catch (error) {
|
|
667
|
+
this.hydrateMissingLocalTable(error);
|
|
668
|
+
if (!known || !(error instanceof Error) || error.name !== "IncompleteReplicaError")
|
|
669
|
+
throw error;
|
|
670
|
+
// A different argument, tenant or workflow can read different tables.
|
|
671
|
+
// Retry against the SAME captured snapshot, never newer mixed-version rows.
|
|
672
|
+
try {
|
|
673
|
+
transaction = await this.localExecutor.execute(path, args, snapshot, execution);
|
|
674
|
+
}
|
|
675
|
+
catch (retryError) {
|
|
676
|
+
this.hydrateMissingLocalTable(retryError);
|
|
677
|
+
throw retryError;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
this.ensureLocalCollections(transaction.readTables);
|
|
681
|
+
this.localExecutionTables.set(path, new Set([
|
|
682
|
+
...(known ?? []), ...transaction.readTables, ...transaction.patches.map(patch => patch.entity),
|
|
683
|
+
]));
|
|
684
|
+
return transaction;
|
|
685
|
+
}
|
|
296
686
|
inLocalLane(run) {
|
|
297
687
|
const job = this.localLane.then(run);
|
|
298
688
|
this.localLane = job.catch(() => undefined);
|
|
299
689
|
return job;
|
|
300
690
|
}
|
|
691
|
+
replaceLocalPredictions(commands) {
|
|
692
|
+
this.replica.replaceOptimistic(commands);
|
|
693
|
+
}
|
|
694
|
+
coordinateOutbox(lane, run) {
|
|
695
|
+
return this.sharedOutboxStore?.coordinate?.(this.outboxScope, lane, run) ?? run();
|
|
696
|
+
}
|
|
697
|
+
refreshPeerOutbox() {
|
|
698
|
+
if (this.manuallyClosed)
|
|
699
|
+
return;
|
|
700
|
+
this.peerRefreshDirty = true;
|
|
701
|
+
if (this.peerRefreshScheduled)
|
|
702
|
+
return;
|
|
703
|
+
this.peerRefreshScheduled = true;
|
|
704
|
+
void (async () => {
|
|
705
|
+
await this.outboxReady;
|
|
706
|
+
do {
|
|
707
|
+
this.peerRefreshDirty = false;
|
|
708
|
+
await this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
|
|
709
|
+
const scope = this.outboxScope;
|
|
710
|
+
await this.replica.synchronizeStorage();
|
|
711
|
+
const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
|
|
712
|
+
if (this.manuallyClosed || scope !== this.outboxScope)
|
|
713
|
+
return;
|
|
714
|
+
this.replaceLocalPredictions(entries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
|
|
715
|
+
this.optimisticReducerIds.clear();
|
|
716
|
+
this.optimisticOutboxEntryIds.clear();
|
|
717
|
+
for (const entry of entries) {
|
|
718
|
+
this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
|
|
719
|
+
if (entry.patches?.length)
|
|
720
|
+
this.optimisticReducerIds.add(entry.idempotencyKey);
|
|
721
|
+
}
|
|
722
|
+
}));
|
|
723
|
+
} while (this.peerRefreshDirty && !this.manuallyClosed);
|
|
724
|
+
this.refreshIntents();
|
|
725
|
+
void this.drainOutbox();
|
|
726
|
+
})().catch(() => undefined).finally(() => { this.peerRefreshScheduled = false; });
|
|
727
|
+
}
|
|
301
728
|
scheduleLocalReplay() {
|
|
302
729
|
if (!this.localExecutor || this.replacingLocal || this.localReplayScheduled || this.manuallyClosed || !this.localIdentity)
|
|
303
730
|
return;
|
|
731
|
+
// Restore and peer notifications discover durable intents explicitly. A
|
|
732
|
+
// table arriving at startup cannot invalidate a prediction that does not exist.
|
|
733
|
+
if (this.optimisticOutboxEntryIds.size === 0)
|
|
734
|
+
return;
|
|
304
735
|
this.localReplayScheduled = true;
|
|
305
736
|
queueMicrotask(() => {
|
|
306
737
|
void (async () => {
|
|
@@ -311,24 +742,61 @@ export class GonvexClient {
|
|
|
311
742
|
}
|
|
312
743
|
reportLocalRejection(reducerId, path, error) {
|
|
313
744
|
const message = reducerErrorMessage(error);
|
|
314
|
-
for (const listener of this.reducerRejectionHandlers)
|
|
315
|
-
|
|
745
|
+
for (const listener of this.reducerRejectionHandlers) {
|
|
746
|
+
try {
|
|
747
|
+
listener({ reducerId, path, error: message, errorClass: "rejected" });
|
|
748
|
+
}
|
|
749
|
+
catch { /* A listener must not break delivery. */ }
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
rebaseLocalEntries(afterRejection = false) {
|
|
753
|
+
return this.coordinateOutbox("intent", () => this.rebaseLocalEntriesLocked(afterRejection));
|
|
316
754
|
}
|
|
317
|
-
async
|
|
755
|
+
async rebaseLocalEntriesLocked(afterRejection = false) {
|
|
318
756
|
if (!this.localExecutor || !this.localBinding || !this.localIdentity || !this.hasAuthoritativeReplicaScope)
|
|
319
757
|
return;
|
|
758
|
+
// The authoritative drain validates the pending chain in order. Keep its
|
|
759
|
+
// predictions while it progresses, then rebase once it settles or loses
|
|
760
|
+
// connectivity instead of replaying N + (N-1) + ... local transactions.
|
|
761
|
+
if (!afterRejection && this.drainingOutbox && this.canSendReducerNow())
|
|
762
|
+
return;
|
|
763
|
+
await this.replica.synchronizeStorage();
|
|
320
764
|
const scope = this.outboxScope;
|
|
321
765
|
const replicaScope = this.replicaScope;
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const
|
|
766
|
+
// Rejected records keep their payload for the UI but are never predicted.
|
|
767
|
+
// Failed (parked) intents stay predicted until retried or discarded.
|
|
768
|
+
const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
|
|
769
|
+
if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
|
|
770
|
+
return;
|
|
771
|
+
if (entries.length === 0) {
|
|
772
|
+
this.replacingLocal = true;
|
|
773
|
+
try {
|
|
774
|
+
this.replaceLocalPredictions([]);
|
|
775
|
+
}
|
|
776
|
+
finally {
|
|
777
|
+
this.replacingLocal = false;
|
|
778
|
+
}
|
|
779
|
+
this.optimisticReducerIds.clear();
|
|
780
|
+
this.optimisticOutboxEntryIds.clear();
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const baseVersion = this.replica.executionVersion();
|
|
784
|
+
const needsExecution = entries.some(entry => entry.localExecution && entry.state !== "inflight" && entry.state !== "committed");
|
|
785
|
+
const snapshot = needsExecution && this.localBinding.mode !== 'portable' ? this.localSnapshot(false) : undefined;
|
|
786
|
+
const replayPatches = [];
|
|
325
787
|
const commands = [];
|
|
326
788
|
for (const entry of entries) {
|
|
789
|
+
// Predictions can wait; a fresh user intent must not sit behind the
|
|
790
|
+
// entire durable backlog. Publish only a complete, coherent rebase.
|
|
791
|
+
if (this.waitingLocalEdits > 0) {
|
|
792
|
+
setTimeout(() => this.scheduleLocalReplay(), 0);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
327
795
|
let patches = entry.patches ?? [];
|
|
328
796
|
if (entry.localExecution && entry.state !== "inflight" && entry.state !== "committed") {
|
|
329
797
|
const execution = { ...entry.localExecution, scope: replicaScope, identity: this.localIdentity, artifactHash: this.localBinding.artifactHash };
|
|
330
798
|
try {
|
|
331
|
-
const transaction = await this.
|
|
799
|
+
const transaction = await this.executeLocal(entry.path, entry.args, snapshot, execution, replayPatches);
|
|
332
800
|
patches = transaction.patches;
|
|
333
801
|
if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
|
|
334
802
|
return;
|
|
@@ -344,16 +812,25 @@ export class GonvexClient {
|
|
|
344
812
|
// Storage failures must abort rebasing, never masquerade as rejection.
|
|
345
813
|
await this.reducerOutbox.updateLocal(entry.id, patches, execution);
|
|
346
814
|
}
|
|
347
|
-
|
|
815
|
+
if (snapshot)
|
|
816
|
+
applyLocalPatches(snapshot, patches);
|
|
817
|
+
for (const patch of patches)
|
|
818
|
+
replayPatches.push(patch.op === 'delete'
|
|
819
|
+
? { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: 'delete' }
|
|
820
|
+
: { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: patch.op === 'upsert' ? 'insert' : patch.op, fields: patch.fields });
|
|
348
821
|
commands.push({ commandId: entry.idempotencyKey, patches });
|
|
349
822
|
}
|
|
350
823
|
if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
|
|
351
824
|
return;
|
|
352
|
-
if (baseVersion !== this.replica.
|
|
353
|
-
|
|
825
|
+
if (baseVersion !== this.replica.executionVersion()) {
|
|
826
|
+
// A changing server base must not recursively occupy the local lane.
|
|
827
|
+
// Release it so user intents already queued can run before the retry.
|
|
828
|
+
setTimeout(() => this.scheduleLocalReplay(), 0);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
354
831
|
this.replacingLocal = true;
|
|
355
832
|
try {
|
|
356
|
-
this.
|
|
833
|
+
this.replaceLocalPredictions(commands);
|
|
357
834
|
}
|
|
358
835
|
finally {
|
|
359
836
|
this.replacingLocal = false;
|
|
@@ -362,9 +839,10 @@ export class GonvexClient {
|
|
|
362
839
|
this.optimisticOutboxEntryIds.clear();
|
|
363
840
|
for (const entry of entries) {
|
|
364
841
|
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
842
|
}
|
|
843
|
+
for (const command of commands)
|
|
844
|
+
if (command.patches.length)
|
|
845
|
+
this.optimisticReducerIds.add(command.commandId);
|
|
368
846
|
}
|
|
369
847
|
async runLocalReducer(ref, args) {
|
|
370
848
|
args = structuredClone(args);
|
|
@@ -373,7 +851,38 @@ export class GonvexClient {
|
|
|
373
851
|
await this.replicaReady;
|
|
374
852
|
if (!this.localExecutor || !this.localBinding)
|
|
375
853
|
throw new Error("This reducer requires the generated Gonvex client with local execution enabled");
|
|
376
|
-
|
|
854
|
+
const reducerId = randomID();
|
|
855
|
+
const execution = this.localIdentity ? {
|
|
856
|
+
scope: this.replicaScope, commandId: reducerId, now: Date.now(),
|
|
857
|
+
intentEntropy: Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join(''),
|
|
858
|
+
artifactHash: this.localBinding.artifactHash, identity: structuredClone(this.localIdentity),
|
|
859
|
+
} : undefined;
|
|
860
|
+
let preparedResult;
|
|
861
|
+
let preparedFailure;
|
|
862
|
+
const prepared = this.preparationLane.then(async () => {
|
|
863
|
+
if (!execution || this.localBinding?.mode !== 'portable' || !this.hasAuthoritativeReplicaScope || this.manuallyClosed || this.updateRequired || admittedGeneration !== this.outboxScopeGeneration)
|
|
864
|
+
return;
|
|
865
|
+
if (this.clientContract && this.clientContract.offlineMaxAgeMs !== null && !this.canSendReducerNow() && (Date.now() - this.lastOnlineAtMs > this.clientContract.offlineMaxAgeMs || Date.now() < this.lastOnlineAtMs))
|
|
866
|
+
return;
|
|
867
|
+
const base = this.replica.executionVersion();
|
|
868
|
+
const pending = this.replica.pendingReducerPatches();
|
|
869
|
+
try {
|
|
870
|
+
const transaction = await this.executeLocal(ref.path, args, undefined, execution, pending, true);
|
|
871
|
+
if (base !== this.replica.executionVersion() || admittedGeneration !== this.outboxScopeGeneration || this.manuallyClosed)
|
|
872
|
+
return;
|
|
873
|
+
preparedResult = { transaction, base, pending: JSON.stringify(pending) };
|
|
874
|
+
}
|
|
875
|
+
catch (error) {
|
|
876
|
+
preparedFailure = error; /* Recheck coverage under coordinated admission. */
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
this.preparationLane = prepared.catch(() => undefined);
|
|
880
|
+
this.waitingLocalEdits += 1;
|
|
881
|
+
let entered = false;
|
|
882
|
+
return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
|
|
883
|
+
entered = true;
|
|
884
|
+
this.waitingLocalEdits -= 1;
|
|
885
|
+
await prepared;
|
|
377
886
|
if (admittedGeneration !== this.outboxScopeGeneration)
|
|
378
887
|
throw new GonvexClientError("Session changed before local reducer execution", { code: "superseded" });
|
|
379
888
|
if (!this.localIdentity || !this.hasAuthoritativeReplicaScope)
|
|
@@ -385,15 +894,51 @@ export class GonvexClient {
|
|
|
385
894
|
if (this.clientContract && this.clientContract.offlineMaxAgeMs !== null && !this.canSendReducerNow() && (Date.now() - this.lastOnlineAtMs > this.clientContract.offlineMaxAgeMs || Date.now() < this.lastOnlineAtMs))
|
|
386
895
|
throw new Error("Offline editing window expired. Reconnect before making more changes.");
|
|
387
896
|
const scope = this.outboxScope;
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
897
|
+
if (!execution || execution.scope !== this.replicaScope)
|
|
898
|
+
throw new GonvexClientError("Session changed before local reducer execution", { code: 'superseded' });
|
|
899
|
+
const sharedEntries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
|
|
900
|
+
const sharedPatches = sharedEntries?.filter(entry => entry.state !== "committed").flatMap(entry => (entry.patches ?? []).map(patch => patch.op === "delete"
|
|
901
|
+
? { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: "delete" }
|
|
902
|
+
: { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: patch.op === "upsert" ? "insert" : patch.op, fields: patch.fields }));
|
|
903
|
+
// A fully resident prediction reads the already published atomic replica.
|
|
904
|
+
// Unrelated snapshots may still be persisting; waiting for those here
|
|
905
|
+
// makes a small edit inherit their disk latency. The shared intent lock
|
|
906
|
+
// and journal still order every edit, and known peer changes must be
|
|
907
|
+
// reconciled before a resident prediction can be reused.
|
|
908
|
+
const reuseResident = preparedResult
|
|
909
|
+
&& preparedResult.base === this.replica.executionVersion()
|
|
910
|
+
&& preparedResult.pending === JSON.stringify(sharedPatches)
|
|
911
|
+
&& !this.peerRefreshDirty && !this.peerRefreshScheduled
|
|
912
|
+
&& !sharedEntries.some(entry => entry.state === "committed");
|
|
913
|
+
const uncoveredRead = this.isUncoveredLocalRead(preparedFailure)
|
|
914
|
+
&& !this.peerRefreshDirty && !this.peerRefreshScheduled
|
|
915
|
+
&& !sharedEntries.some(entry => entry.state === "committed");
|
|
916
|
+
if (!reuseResident && !uncoveredRead)
|
|
917
|
+
await this.replica.synchronizeStorage();
|
|
918
|
+
if (sharedEntries)
|
|
919
|
+
this.replaceLocalPredictions(sharedEntries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
|
|
920
|
+
const executionBaseVersion = this.replica.executionVersion();
|
|
394
921
|
let transaction;
|
|
395
922
|
try {
|
|
396
|
-
|
|
923
|
+
if (preparedResult && preparedResult.base === executionBaseVersion && preparedResult.pending === JSON.stringify(sharedPatches)) {
|
|
924
|
+
transaction = preparedResult.transaction;
|
|
925
|
+
}
|
|
926
|
+
else if (this.localBinding.mode === 'portable') {
|
|
927
|
+
// A commit may invalidate preparation while the intent lock is being
|
|
928
|
+
// acquired. Read the newly published atomic replica again before
|
|
929
|
+
// joining disk work that arrived after synchronizeStorage completed.
|
|
930
|
+
try {
|
|
931
|
+
transaction = await this.executeLocal(ref.path, args, undefined, execution, sharedPatches ?? true, true);
|
|
932
|
+
}
|
|
933
|
+
catch (error) {
|
|
934
|
+
if (!(error instanceof Error) || error.name !== 'IncompleteReplicaError')
|
|
935
|
+
throw error;
|
|
936
|
+
transaction = await this.executeLocal(ref.path, args, undefined, execution, sharedPatches ?? true);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
else {
|
|
940
|
+
transaction = await this.executeLocal(ref.path, args, this.localSnapshot(true), execution, sharedPatches ?? true);
|
|
941
|
+
}
|
|
397
942
|
}
|
|
398
943
|
catch (error) {
|
|
399
944
|
if (!(error instanceof Error) || error.name !== "IncompleteReplicaError")
|
|
@@ -404,6 +949,10 @@ export class GonvexClient {
|
|
|
404
949
|
const patches = transaction?.patches ?? [];
|
|
405
950
|
this.directOutboxReducerIds.add(reducerId);
|
|
406
951
|
try {
|
|
952
|
+
// A visible prediction must already have a durable intent. Otherwise a
|
|
953
|
+
// reload immediately after paint can lose an offline edit. Local
|
|
954
|
+
// execution still avoids a server round trip; disk admission is bounded
|
|
955
|
+
// to this intent, rather than persisting the whole replica.
|
|
407
956
|
const entry = await this.reducerOutbox.enqueue({
|
|
408
957
|
scope, path: ref.path, args, idempotencyKey: reducerId, state: "pending",
|
|
409
958
|
// All local intents form one causal chain, including reads of rows
|
|
@@ -423,13 +972,37 @@ export class GonvexClient {
|
|
|
423
972
|
}
|
|
424
973
|
return transaction ? transaction.result : { status: "queued", reducerId };
|
|
425
974
|
}
|
|
975
|
+
catch (error) {
|
|
976
|
+
this.optimisticReducerIds.delete(reducerId);
|
|
977
|
+
this.replica.rejectCommand(reducerId);
|
|
978
|
+
throw error;
|
|
979
|
+
}
|
|
426
980
|
finally {
|
|
427
981
|
this.directOutboxReducerIds.delete(reducerId);
|
|
428
|
-
|
|
982
|
+
// This transaction already executed against all earlier predictions.
|
|
983
|
+
// Appending it does not invalidate those predictions. Rebase only if
|
|
984
|
+
// the authoritative execution snapshot changed during execution.
|
|
985
|
+
// An uncovered read has no new data to replay against yet. Its
|
|
986
|
+
// hydration subscription schedules replay when coverage arrives;
|
|
987
|
+
// immediately retrying here only locks edits behind the same disk work.
|
|
988
|
+
if (this.replica.executionVersion() !== executionBaseVersion)
|
|
989
|
+
this.scheduleLocalReplay();
|
|
429
990
|
void this.drainOutbox();
|
|
430
991
|
}
|
|
992
|
+
})).catch(error => {
|
|
993
|
+
if (!entered)
|
|
994
|
+
this.waitingLocalEdits -= 1;
|
|
995
|
+
this.optimisticReducerIds.delete(reducerId);
|
|
996
|
+
this.replica.rejectCommand(reducerId);
|
|
997
|
+
throw error;
|
|
431
998
|
});
|
|
432
999
|
}
|
|
1000
|
+
/** Preload code for a mounted control without reading data or executing an intent. */
|
|
1001
|
+
prepareReducer(ref) {
|
|
1002
|
+
if (ref.localExecution !== 1 || this.manuallyClosed)
|
|
1003
|
+
return Promise.resolve();
|
|
1004
|
+
return this.localExecutor?.prepare?.(ref.path) ?? Promise.resolve();
|
|
1005
|
+
}
|
|
433
1006
|
/** The single normalized authoritative + optimistic application data store. */
|
|
434
1007
|
get localReplica() {
|
|
435
1008
|
return this.replicaView;
|
|
@@ -445,19 +1018,33 @@ export class GonvexClient {
|
|
|
445
1018
|
replicaEntities(entity, ids) {
|
|
446
1019
|
return this.replica.entityBatch(entity, ids);
|
|
447
1020
|
}
|
|
1021
|
+
/** Retain only the data an active view observes; cold rows remain on disk. */
|
|
1022
|
+
retainReplicaEntities(entity, ids) { return this.replica.retainRows(entity, ids); }
|
|
1023
|
+
retainReplicaWindow(signature) { return this.replica.retainWindow(signature); }
|
|
448
1024
|
/** Read rows and server-owned completeness for a persisted Replica Collection. */
|
|
449
1025
|
replicaCollectionState(ref, args = {}) {
|
|
450
1026
|
return this.replica.collectionState(this.replicaSignature(ref, args));
|
|
451
1027
|
}
|
|
452
1028
|
/** Run the generated Live Query plan over the bounded normalized cache. */
|
|
453
|
-
offlineLiveQuery(ref, args = {}) {
|
|
1029
|
+
offlineLiveQuery(ref, args = {}, options = {}) {
|
|
454
1030
|
if (!ref.live?.plan) {
|
|
455
1031
|
return { rows: [], completeness: "partial", supported: false, unsupportedOperator: "missingPlan" };
|
|
456
1032
|
}
|
|
457
1033
|
const queryArgs = isJsonRecord(args) ? args : {};
|
|
458
|
-
return runOfflineLiveQuery(this.replica.entityRows(ref.live.entity), ref.live.plan, queryArgs, this.replica.entityCompleteness(ref.live.entity))
|
|
1034
|
+
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), {
|
|
1035
|
+
context: {
|
|
1036
|
+
...(this.localIdentity?.member?.id ? { 'member.id': this.localIdentity.member.id } : {}),
|
|
1037
|
+
...(this.localIdentity?.auth.account?.id ? { 'account.id': this.localIdentity.auth.account.id } : {}),
|
|
1038
|
+
...(this.localIdentity?.tenant?.id ? { 'tenant.id': this.localIdentity.tenant.id } : {}),
|
|
1039
|
+
},
|
|
1040
|
+
relationRows: (table, where) => {
|
|
1041
|
+
const coverage = this.replica.captureReadCoverage({ [table]: { key: '_id', complete: false } });
|
|
1042
|
+
return { rows: this.replica.entityRows(table), completeness: this.replica.entityCompleteness(table) === 'complete'
|
|
1043
|
+
&& readIsCovered(coverage[table], { table, where: liveScopeCoverage(where, queryArgs) }) ? 'complete' : 'partial' };
|
|
1044
|
+
},
|
|
1045
|
+
});
|
|
459
1046
|
}
|
|
460
|
-
/** Number of reducers
|
|
1047
|
+
/** Number of reducers still queued for delivery (excludes failed and rejected intents). */
|
|
461
1048
|
async outboxCount() {
|
|
462
1049
|
await this.outboxReady;
|
|
463
1050
|
return this.reducerOutbox.count(this.outboxScope);
|
|
@@ -803,8 +1390,10 @@ export class GonvexClient {
|
|
|
803
1390
|
this.authRetriedAfterError = false;
|
|
804
1391
|
const directive = replicaDirectiveFromAuthResult(message.result);
|
|
805
1392
|
const localIdentity = localIdentityFromAuthResult(message.result);
|
|
806
|
-
if (localIdentity)
|
|
1393
|
+
if (localIdentity) {
|
|
807
1394
|
this.localIdentity = localIdentity;
|
|
1395
|
+
void this.localExecutor?.ready.catch(() => undefined);
|
|
1396
|
+
}
|
|
808
1397
|
if (!directive) {
|
|
809
1398
|
this.authInFlight = false;
|
|
810
1399
|
if (!this.auth.tenant) {
|
|
@@ -946,6 +1535,7 @@ export class GonvexClient {
|
|
|
946
1535
|
this.unsubscribeLocal?.();
|
|
947
1536
|
for (const close of this.localCollectionClosers.splice(0))
|
|
948
1537
|
close();
|
|
1538
|
+
this.localCollectionKeys.clear();
|
|
949
1539
|
this.localExecutor?.close();
|
|
950
1540
|
this.cancelManagedAuthAttempt("Gonvex client was closed during authentication.");
|
|
951
1541
|
if (isEphemeralOutboxScope(this.outboxScope)) {
|
|
@@ -982,6 +1572,8 @@ export class GonvexClient {
|
|
|
982
1572
|
this.outboxDrainTimer = undefined;
|
|
983
1573
|
}
|
|
984
1574
|
this.unsubscribeOutbox();
|
|
1575
|
+
this.unsubscribePeerOutbox?.();
|
|
1576
|
+
this.unsubscribePeerReplica?.();
|
|
985
1577
|
this.unsubscribeBrowserOnline?.();
|
|
986
1578
|
this.handlers.clear();
|
|
987
1579
|
this.querySubscriptions.clear();
|
|
@@ -999,6 +1591,7 @@ export class GonvexClient {
|
|
|
999
1591
|
this.notifyConnectionState();
|
|
1000
1592
|
this.connectionStateHandlers.clear();
|
|
1001
1593
|
this.supportCommandHandlers.clear();
|
|
1594
|
+
this.replica.dispose();
|
|
1002
1595
|
const closedError = new Error("Gonvex client is closed");
|
|
1003
1596
|
for (const pending of this.pendingNativeErrors.splice(0))
|
|
1004
1597
|
pending.reject(closedError);
|
|
@@ -1335,7 +1928,9 @@ export class GonvexClient {
|
|
|
1335
1928
|
// could overwrite a result already accepted on the same connection.
|
|
1336
1929
|
return subscription.revisionSocketGeneration !== this.socketGeneration;
|
|
1337
1930
|
}
|
|
1338
|
-
subscribeReplicaTransport(ref, args = {}, onMessage) {
|
|
1931
|
+
subscribeReplicaTransport(ref, args = {}, onMessage, metadataOnly = false) {
|
|
1932
|
+
if (metadataOnly)
|
|
1933
|
+
this.replicaMetadataListeners.add(onMessage);
|
|
1339
1934
|
this.connect();
|
|
1340
1935
|
const key = querySubscriptionKey(ref, args);
|
|
1341
1936
|
const existing = this.replicaSubscriptions.get(key);
|
|
@@ -1345,10 +1940,14 @@ export class GonvexClient {
|
|
|
1345
1940
|
existing.unsubscribeTimer = undefined;
|
|
1346
1941
|
}
|
|
1347
1942
|
existing.listeners.add(onMessage);
|
|
1943
|
+
// A reset may arrive during the retention grace period with no listeners.
|
|
1944
|
+
// Its reopen was correctly deferred; the next subscriber must resume it.
|
|
1945
|
+
if (!existing.isUpToDate && !existing.opening)
|
|
1946
|
+
this.sendReplicaOpen(existing);
|
|
1348
1947
|
if (existing.lastMessage) {
|
|
1349
1948
|
queueMicrotask(() => {
|
|
1350
1949
|
if (existing.listeners.has(onMessage) && existing.lastMessage) {
|
|
1351
|
-
onMessage(this.materializeReplicaMessage(existing, existing.lastMessage));
|
|
1950
|
+
onMessage(metadataOnly ? existing.lastMessage : this.materializeReplicaMessage(existing, existing.lastMessage));
|
|
1352
1951
|
}
|
|
1353
1952
|
});
|
|
1354
1953
|
}
|
|
@@ -1386,12 +1985,16 @@ export class GonvexClient {
|
|
|
1386
1985
|
return this.subscribeReplicaTransport(ref, args, onMessage);
|
|
1387
1986
|
}
|
|
1388
1987
|
/** Watch a bounded Replica Collection through the normalized Local Replica. */
|
|
1389
|
-
watchReplica(ref, args = {}) {
|
|
1988
|
+
watchReplica(ref, args = {}, options = {}) {
|
|
1390
1989
|
const key = querySubscriptionKey(ref, args);
|
|
1391
1990
|
const updateHandlers = new Set();
|
|
1392
1991
|
let latestError;
|
|
1393
1992
|
let snapshotVersion = -1;
|
|
1394
1993
|
let snapshotRows;
|
|
1994
|
+
const readRows = () => {
|
|
1995
|
+
const next = this.replica.liveQuerySnapshot(key).rows;
|
|
1996
|
+
return snapshotRows?.length === next.length && next.every((row, index) => row === snapshotRows[index]) ? snapshotRows : next;
|
|
1997
|
+
};
|
|
1395
1998
|
let stateVersion = -1;
|
|
1396
1999
|
let stateFreshness;
|
|
1397
2000
|
let stateIsUpToDate;
|
|
@@ -1403,45 +2006,57 @@ export class GonvexClient {
|
|
|
1403
2006
|
};
|
|
1404
2007
|
// Keep the ReplicaSubscription as transport/reconciliation state only. The
|
|
1405
2008
|
// 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;
|
|
2009
|
+
let stop;
|
|
2010
|
+
const start = () => {
|
|
2011
|
+
if (stop)
|
|
2012
|
+
return;
|
|
1426
2013
|
snapshotVersion = -1;
|
|
1427
|
-
snapshotRows = undefined;
|
|
1428
2014
|
stateVersion = -1;
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
2015
|
+
const releaseRows = this.replica.retainWindow(key);
|
|
2016
|
+
const unsubscribeTransport = this.subscribeReplicaTransport(ref, args, (message) => {
|
|
2017
|
+
if (message.type === "replica.error") {
|
|
2018
|
+
latestError = new Error(message.error);
|
|
2019
|
+
notify();
|
|
2020
|
+
}
|
|
2021
|
+
else if (message.type === "replica.syncing" || message.type === "replica.reset") {
|
|
2022
|
+
latestError = undefined;
|
|
2023
|
+
notify();
|
|
2024
|
+
}
|
|
2025
|
+
else if (message.type === "replica.ready") {
|
|
2026
|
+
latestError = undefined;
|
|
2027
|
+
notify();
|
|
2028
|
+
}
|
|
2029
|
+
else if (message.type === "replica.snapshot") {
|
|
2030
|
+
latestError = undefined;
|
|
2031
|
+
}
|
|
2032
|
+
}, true);
|
|
2033
|
+
const unsubscribeReplica = this.replica.subscribe(notify);
|
|
2034
|
+
const unsubscribeScope = this.onSessionScopeChange(() => {
|
|
2035
|
+
latestError = undefined;
|
|
2036
|
+
snapshotVersion = -1;
|
|
2037
|
+
snapshotRows = undefined;
|
|
2038
|
+
stateVersion = -1;
|
|
2039
|
+
stateFreshness = undefined;
|
|
2040
|
+
stateIsUpToDate = undefined;
|
|
2041
|
+
snapshotState = undefined;
|
|
2042
|
+
notify();
|
|
2043
|
+
});
|
|
2044
|
+
stop = () => { unsubscribeTransport(); unsubscribeReplica(); unsubscribeScope(); releaseRows(); stop = undefined; };
|
|
2045
|
+
};
|
|
2046
|
+
// React can discard a render before subscribing. Such watches must own no resources.
|
|
2047
|
+
if (!options.deferStart)
|
|
2048
|
+
start();
|
|
1434
2049
|
return {
|
|
1435
2050
|
localReplicaResult: () => {
|
|
1436
2051
|
if (latestError)
|
|
1437
2052
|
throw latestError;
|
|
1438
2053
|
if (!this.replica.hasLiveQuery(key))
|
|
1439
2054
|
return undefined;
|
|
1440
|
-
const version = this.replica.
|
|
2055
|
+
const version = this.replica.windowRowsVersion(key);
|
|
1441
2056
|
if (snapshotVersion === version)
|
|
1442
2057
|
return snapshotRows;
|
|
1443
2058
|
snapshotVersion = version;
|
|
1444
|
-
snapshotRows =
|
|
2059
|
+
snapshotRows = readRows();
|
|
1445
2060
|
return snapshotRows;
|
|
1446
2061
|
},
|
|
1447
2062
|
localReplicaState: () => {
|
|
@@ -1459,7 +2074,12 @@ export class GonvexClient {
|
|
|
1459
2074
|
stateVersion = version;
|
|
1460
2075
|
stateFreshness = freshness;
|
|
1461
2076
|
stateIsUpToDate = isUpToDate;
|
|
1462
|
-
const
|
|
2077
|
+
const rowsVersion = this.replica.windowRowsVersion(key);
|
|
2078
|
+
if (snapshotVersion !== rowsVersion) {
|
|
2079
|
+
snapshotVersion = rowsVersion;
|
|
2080
|
+
snapshotRows = readRows();
|
|
2081
|
+
}
|
|
2082
|
+
const state = this.replica.collectionState(key, snapshotRows);
|
|
1463
2083
|
snapshotState = {
|
|
1464
2084
|
...state,
|
|
1465
2085
|
isUpToDate,
|
|
@@ -1475,6 +2095,7 @@ export class GonvexClient {
|
|
|
1475
2095
|
isUpToDate: this.replicaSubscriptions.get(key)?.isUpToDate === true,
|
|
1476
2096
|
}),
|
|
1477
2097
|
onUpdate(handler) {
|
|
2098
|
+
start();
|
|
1478
2099
|
if (releaseTimer) {
|
|
1479
2100
|
clearTimeout(releaseTimer);
|
|
1480
2101
|
releaseTimer = undefined;
|
|
@@ -1492,9 +2113,7 @@ export class GonvexClient {
|
|
|
1492
2113
|
releaseTimer = undefined;
|
|
1493
2114
|
if (updateHandlers.size > 0)
|
|
1494
2115
|
return;
|
|
1495
|
-
|
|
1496
|
-
unsubscribeReplica();
|
|
1497
|
-
unsubscribeScope();
|
|
2116
|
+
stop?.();
|
|
1498
2117
|
}, 0);
|
|
1499
2118
|
};
|
|
1500
2119
|
},
|
|
@@ -1505,7 +2124,7 @@ export class GonvexClient {
|
|
|
1505
2124
|
* latest query result is retained only as the transport-shaped skeleton;
|
|
1506
2125
|
* its row window is always rebuilt from LocalReplica membership/entities.
|
|
1507
2126
|
*/
|
|
1508
|
-
watchLiveQuery(ref, args = {}) {
|
|
2127
|
+
watchLiveQuery(ref, args = {}, options = {}) {
|
|
1509
2128
|
const key = querySubscriptionKey(ref, args);
|
|
1510
2129
|
const updateHandlers = new Set();
|
|
1511
2130
|
let transportResult;
|
|
@@ -1525,39 +2144,51 @@ export class GonvexClient {
|
|
|
1525
2144
|
handler();
|
|
1526
2145
|
});
|
|
1527
2146
|
};
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
2147
|
+
let stop;
|
|
2148
|
+
const start = () => {
|
|
2149
|
+
if (stop)
|
|
2150
|
+
return;
|
|
2151
|
+
transportResult = undefined;
|
|
2152
|
+
snapshotToken = "";
|
|
2153
|
+
const releaseRows = this.replica.retainWindow(key);
|
|
2154
|
+
const unsubscribeQuery = this.subscribeLiveQuery(ref, args, (message) => {
|
|
2155
|
+
if (message.type === "query.result") {
|
|
2156
|
+
transportResult = message.result;
|
|
2157
|
+
transportGeneration += 1;
|
|
2158
|
+
latestError = undefined;
|
|
2159
|
+
// LocalReplica has already published its atomic window swap before
|
|
2160
|
+
// this callback is emitted, so this is the single initial UI wake-up.
|
|
2161
|
+
notify();
|
|
2162
|
+
}
|
|
2163
|
+
else if (message.type === "query.error") {
|
|
2164
|
+
latestError = new GonvexClientError(`Query ${ref.path} failed: ${message.error}`, {
|
|
2165
|
+
code: "server", path: ref.path, operation: "query",
|
|
2166
|
+
});
|
|
2167
|
+
notify();
|
|
2168
|
+
}
|
|
2169
|
+
});
|
|
2170
|
+
// During the initial query result, LocalReplica notifies before the
|
|
2171
|
+
// transport-shaped skeleton is installed above. Suppress that empty
|
|
2172
|
+
// intermediate wake-up; later transactions notify directly from the
|
|
2173
|
+
// normalized store.
|
|
2174
|
+
const unsubscribeReplica = this.replica.subscribe(() => {
|
|
2175
|
+
if (transportResult !== undefined || this.replica.hasLiveQuery(key))
|
|
2176
|
+
notify();
|
|
2177
|
+
});
|
|
2178
|
+
void this.replicaReady.then(() => notify());
|
|
2179
|
+
const unsubscribeScope = this.onSessionScopeChange(() => {
|
|
2180
|
+
transportResult = undefined;
|
|
1531
2181
|
transportGeneration += 1;
|
|
2182
|
+
snapshotToken = "";
|
|
2183
|
+
snapshotResult = undefined;
|
|
1532
2184
|
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
2185
|
notify();
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
//
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
const unsubscribeReplica = this.replica.subscribe(() => {
|
|
1549
|
-
if (transportResult !== undefined || this.replica.hasLiveQuery(key))
|
|
1550
|
-
notify();
|
|
1551
|
-
});
|
|
1552
|
-
void this.replicaReady.then(() => notify());
|
|
1553
|
-
const unsubscribeScope = this.onSessionScopeChange(() => {
|
|
1554
|
-
transportResult = undefined;
|
|
1555
|
-
transportGeneration += 1;
|
|
1556
|
-
snapshotToken = "";
|
|
1557
|
-
snapshotResult = undefined;
|
|
1558
|
-
latestError = undefined;
|
|
1559
|
-
notify();
|
|
1560
|
-
});
|
|
2186
|
+
});
|
|
2187
|
+
stop = () => { unsubscribeQuery(); unsubscribeReplica(); unsubscribeScope(); releaseRows(); stop = undefined; };
|
|
2188
|
+
};
|
|
2189
|
+
// React can discard a render before subscribing. Such watches must own no resources.
|
|
2190
|
+
if (!options.deferStart)
|
|
2191
|
+
start();
|
|
1561
2192
|
return {
|
|
1562
2193
|
localLiveQueryResult: () => {
|
|
1563
2194
|
if (latestError)
|
|
@@ -1592,6 +2223,7 @@ export class GonvexClient {
|
|
|
1592
2223
|
return snapshotResult;
|
|
1593
2224
|
},
|
|
1594
2225
|
onUpdate(handler) {
|
|
2226
|
+
start();
|
|
1595
2227
|
if (releaseTimer) {
|
|
1596
2228
|
clearTimeout(releaseTimer);
|
|
1597
2229
|
releaseTimer = undefined;
|
|
@@ -1609,9 +2241,7 @@ export class GonvexClient {
|
|
|
1609
2241
|
releaseTimer = undefined;
|
|
1610
2242
|
if (updateHandlers.size > 0)
|
|
1611
2243
|
return;
|
|
1612
|
-
|
|
1613
|
-
unsubscribeReplica();
|
|
1614
|
-
unsubscribeScope();
|
|
2244
|
+
stop?.();
|
|
1615
2245
|
}, 0);
|
|
1616
2246
|
};
|
|
1617
2247
|
},
|
|
@@ -1656,7 +2286,7 @@ export class GonvexClient {
|
|
|
1656
2286
|
scope,
|
|
1657
2287
|
};
|
|
1658
2288
|
await this.replica.replaceWindow(window);
|
|
1659
|
-
const snapshot = { ...message, result:
|
|
2289
|
+
const snapshot = { ...message, result: [] };
|
|
1660
2290
|
subscription.lastMessage = snapshot;
|
|
1661
2291
|
this.emitReplicaMessage(subscription, snapshot, scope);
|
|
1662
2292
|
return;
|
|
@@ -1667,12 +2297,20 @@ export class GonvexClient {
|
|
|
1667
2297
|
return;
|
|
1668
2298
|
this.clearReplicaRetry(subscription, true);
|
|
1669
2299
|
raiseReplicaCursorFloor(subscription, message.cursor);
|
|
2300
|
+
const upserts = (message.upserts ?? []).filter((row) => asReplicaRow(row) !== undefined).map(row => asReplicaRow(row));
|
|
2301
|
+
// Incremental integrity is safe only for full projected row images. Older
|
|
2302
|
+
// partial-row protocols keep the full normalized rehash on replica.ready.
|
|
2303
|
+
const canCarryHashes = prior?.hashes && subscription.columns?.length
|
|
2304
|
+
&& upserts.every(row => subscription.columns.every(column => hasOwn(row, column)));
|
|
2305
|
+
const hashes = message.hashes ?? (canCarryHashes
|
|
2306
|
+
? await applyReplicaHashDelta(prior.hashes, projectReplicaIntegrityRows(upserts, subscription.columns), message.deleted ?? [], prior?.key ?? "id")
|
|
2307
|
+
: undefined);
|
|
1670
2308
|
await this.replica.applyWindowDelta({
|
|
1671
2309
|
signature: subscription.key,
|
|
1672
2310
|
kind: "replica",
|
|
1673
2311
|
entity: subscription.entity,
|
|
1674
2312
|
key: prior?.key ?? "id",
|
|
1675
|
-
upserts
|
|
2313
|
+
upserts,
|
|
1676
2314
|
deleted: message.deleted ?? [],
|
|
1677
2315
|
completeness: prior?.completeness ?? "partial",
|
|
1678
2316
|
source: "server",
|
|
@@ -1683,13 +2321,11 @@ export class GonvexClient {
|
|
|
1683
2321
|
orderDirection: prior?.orderDirection,
|
|
1684
2322
|
maxRows: prior?.maxRows,
|
|
1685
2323
|
maxBytes: prior?.maxBytes,
|
|
1686
|
-
|
|
1687
|
-
// supplied a complete replacement map with this frame.
|
|
1688
|
-
hashes: message.hashes,
|
|
2324
|
+
hashes,
|
|
1689
2325
|
});
|
|
1690
2326
|
const snapshot = {
|
|
1691
2327
|
type: "replica.snapshot", id: subscription.id, path: subscription.path,
|
|
1692
|
-
result:
|
|
2328
|
+
result: [], cursor: message.cursor,
|
|
1693
2329
|
key: prior?.key ?? "id", mode: prior?.mode, orderBy: prior?.orderBy,
|
|
1694
2330
|
orderDirection: prior?.orderDirection, maxRows: prior?.maxRows, maxBytes: prior?.maxBytes,
|
|
1695
2331
|
};
|
|
@@ -1839,9 +2475,15 @@ export class GonvexClient {
|
|
|
1839
2475
|
emitReplicaMessage(subscription, message, scope = this.replicaScope) {
|
|
1840
2476
|
if (scope !== this.replicaScope || subscription.scope !== scope)
|
|
1841
2477
|
return;
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
2478
|
+
// Hook observers consume metadata only; their rows come from Local Replica.
|
|
2479
|
+
// Materialize a payload only when a public transport consumer needs it.
|
|
2480
|
+
let outgoing;
|
|
2481
|
+
for (const listener of Array.from(subscription.listeners)) {
|
|
2482
|
+
if (this.replicaMetadataListeners.has(listener))
|
|
2483
|
+
listener(message);
|
|
2484
|
+
else
|
|
2485
|
+
listener(outgoing ??= this.materializeReplicaMessage(subscription, message));
|
|
2486
|
+
}
|
|
1845
2487
|
}
|
|
1846
2488
|
materializeReplicaMessage(subscription, message) {
|
|
1847
2489
|
if (message.type !== "replica.snapshot")
|
|
@@ -1887,7 +2529,7 @@ export class GonvexClient {
|
|
|
1887
2529
|
subscription.isUpToDate = false;
|
|
1888
2530
|
const message = {
|
|
1889
2531
|
type: "replica.snapshot", id: subscription.id, path: subscription.path,
|
|
1890
|
-
result:
|
|
2532
|
+
result: [], cursor: cached.cursor ?? { epoch: "cache", revision: 0 },
|
|
1891
2533
|
key: cached.key, mode: cached.mode, orderBy: cached.orderBy,
|
|
1892
2534
|
orderDirection: cached.orderDirection, maxRows: cached.maxRows, maxBytes: cached.maxBytes,
|
|
1893
2535
|
};
|
|
@@ -1998,6 +2640,9 @@ export class GonvexClient {
|
|
|
1998
2640
|
? this.restoreOutbox(scope, generation)
|
|
1999
2641
|
: this.restoreLocalSession(scope, generation);
|
|
2000
2642
|
this.outboxReady = ready;
|
|
2643
|
+
// Never show the previous identity's intents under the new one.
|
|
2644
|
+
this.publishIntents([]);
|
|
2645
|
+
this.refreshIntents();
|
|
2001
2646
|
return ready;
|
|
2002
2647
|
}
|
|
2003
2648
|
async activateReplicaDirective(directive) {
|
|
@@ -2031,7 +2676,6 @@ export class GonvexClient {
|
|
|
2031
2676
|
// and sends a second time with the same command ID.
|
|
2032
2677
|
this.outboxReady = this.restoreOutbox(this.outboxScope, generation);
|
|
2033
2678
|
await this.outboxReady;
|
|
2034
|
-
this.ensureLocalCollections();
|
|
2035
2679
|
}
|
|
2036
2680
|
quarantineReplicaScope() {
|
|
2037
2681
|
this.localIdentity = undefined;
|
|
@@ -2059,6 +2703,7 @@ export class GonvexClient {
|
|
|
2059
2703
|
}
|
|
2060
2704
|
async restoreOutbox(scope, generation) {
|
|
2061
2705
|
await this.replicaReady;
|
|
2706
|
+
await this.replica.synchronizeStorage();
|
|
2062
2707
|
const entries = await this.reducerOutbox.loadAll(scope);
|
|
2063
2708
|
if (this.manuallyClosed
|
|
2064
2709
|
|| generation !== this.outboxScopeGeneration
|
|
@@ -2076,6 +2721,8 @@ export class GonvexClient {
|
|
|
2076
2721
|
for (const entry of entries) {
|
|
2077
2722
|
if (entry.localExecution && this.localExecutor)
|
|
2078
2723
|
continue;
|
|
2724
|
+
if (!outboxEntryIsLive(entry))
|
|
2725
|
+
continue;
|
|
2079
2726
|
if (entry.state === "committed" && (entry.patches?.length ?? 0) === 0) {
|
|
2080
2727
|
await this.reducerOutbox.ack(entry.id);
|
|
2081
2728
|
continue;
|
|
@@ -2092,11 +2739,15 @@ export class GonvexClient {
|
|
|
2092
2739
|
// If this scope was installed after the socket authenticated, no reconnect
|
|
2093
2740
|
// or new enqueue may occur to wake the queue. The await inside drainOutbox
|
|
2094
2741
|
// yields until this restore promise resolves, then safely resumes it.
|
|
2742
|
+
this.refreshIntents();
|
|
2095
2743
|
void this.drainOutbox();
|
|
2096
2744
|
}
|
|
2097
2745
|
addOptimisticReducer(reducerId, patches, accepted = false) {
|
|
2098
|
-
if (patches.length === 0
|
|
2746
|
+
if (patches.length === 0) {
|
|
2747
|
+
this.optimisticReducerIds.delete(reducerId);
|
|
2748
|
+
this.replica.rejectCommand(reducerId);
|
|
2099
2749
|
return;
|
|
2750
|
+
}
|
|
2100
2751
|
this.optimisticReducerIds.add(reducerId);
|
|
2101
2752
|
this.replica.applyOptimistic(reducerId, patches);
|
|
2102
2753
|
}
|
|
@@ -2117,95 +2768,185 @@ export class GonvexClient {
|
|
|
2117
2768
|
}
|
|
2118
2769
|
async drainOutbox() {
|
|
2119
2770
|
await this.outboxReady;
|
|
2120
|
-
if (this.drainingOutbox
|
|
2121
|
-
|
|
2122
|
-
|
|
2771
|
+
if (this.drainingOutbox) {
|
|
2772
|
+
// The running drain may already be past the entry this wake-up is for
|
|
2773
|
+
// (for example a short backoff timer firing before the drain returned).
|
|
2774
|
+
this.outboxDrainRequested = true;
|
|
2775
|
+
return;
|
|
2776
|
+
}
|
|
2777
|
+
if (this.manuallyClosed || !this.canSendReducerNow())
|
|
2123
2778
|
return;
|
|
2124
2779
|
const drainScope = this.outboxScope;
|
|
2125
2780
|
this.drainingOutbox = true;
|
|
2781
|
+
this.outboxDrainRequested = false;
|
|
2126
2782
|
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);
|
|
2146
|
-
return;
|
|
2147
|
-
}
|
|
2148
|
-
if (!this.canSendReducerNow()) {
|
|
2149
|
-
await this.reducerOutbox.markPending(entry.id);
|
|
2783
|
+
await this.coordinateOutbox("delivery", async () => {
|
|
2784
|
+
if (this.manuallyClosed || drainScope !== this.outboxScope || !this.canSendReducerNow())
|
|
2150
2785
|
return;
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2786
|
+
if (this.sharedOutboxStore)
|
|
2787
|
+
await this.reducerOutbox.recoverInflight?.(drainScope);
|
|
2788
|
+
while (!this.manuallyClosed && this.socket?.readyState === WebSocket.OPEN) {
|
|
2789
|
+
const scope = this.outboxScope;
|
|
2790
|
+
const entry = await this.inLocalLane(async () => {
|
|
2791
|
+
const next = await this.reducerOutbox.nextReady(scope, Date.now());
|
|
2792
|
+
// A concurrent discard (this or another tab) fences the entry; a
|
|
2793
|
+
// refused transition means it must not be sent.
|
|
2794
|
+
if (next && (await this.reducerOutbox.markInflight(next.id)) === false)
|
|
2795
|
+
return null;
|
|
2796
|
+
return next;
|
|
2797
|
+
});
|
|
2798
|
+
if (entry === null)
|
|
2799
|
+
continue;
|
|
2800
|
+
if (!entry) {
|
|
2801
|
+
await this.scheduleNextOutboxAttempt(scope);
|
|
2802
|
+
return;
|
|
2168
2803
|
}
|
|
2169
|
-
|
|
2170
|
-
|
|
2804
|
+
if (scope !== this.outboxScope)
|
|
2805
|
+
return;
|
|
2806
|
+
if (this.directOutboxReducerIds.has(entry.idempotencyKey)) {
|
|
2807
|
+
// Scope recovery may observe an inflight row created by this live
|
|
2808
|
+
// process and reset it to pending under the assumption that a prior
|
|
2809
|
+
// process crashed. The foreground call still owns that command ID.
|
|
2810
|
+
// Restore the durable marker and wait for its real result instead of
|
|
2811
|
+
// registering a second response handler for the same command.
|
|
2812
|
+
await this.reducerOutbox.markInflight(entry.id);
|
|
2813
|
+
return;
|
|
2171
2814
|
}
|
|
2172
|
-
|
|
2173
|
-
await this.
|
|
2815
|
+
if (!this.canSendReducerNow()) {
|
|
2816
|
+
await this.reducerOutbox.markPending(entry.id);
|
|
2817
|
+
return;
|
|
2174
2818
|
}
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
if (
|
|
2819
|
+
if ((await this.reducerOutbox.markInflight(entry.id)) === false)
|
|
2820
|
+
continue;
|
|
2821
|
+
if (scope !== this.outboxScope)
|
|
2822
|
+
return;
|
|
2823
|
+
if (!this.canSendReducerNow()) {
|
|
2178
2824
|
await this.reducerOutbox.markPending(entry.id);
|
|
2179
|
-
this.requireClientUpdate(error.message);
|
|
2180
2825
|
return;
|
|
2181
2826
|
}
|
|
2182
|
-
|
|
2827
|
+
try {
|
|
2828
|
+
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);
|
|
2829
|
+
this.unauthenticatedRetries = 0;
|
|
2830
|
+
await this.reducerOutbox.markCommitted(entry.id);
|
|
2183
2831
|
if (entry.localExecution && this.localExecutor) {
|
|
2184
2832
|
await this.inLocalLane(async () => {
|
|
2185
2833
|
await this.reducerOutbox.ack(entry.id);
|
|
2186
|
-
if (scope === this.outboxScope)
|
|
2834
|
+
if (scope === this.outboxScope)
|
|
2187
2835
|
await this.rebaseLocalEntries();
|
|
2188
|
-
this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
|
|
2189
|
-
}
|
|
2190
2836
|
});
|
|
2191
2837
|
}
|
|
2192
|
-
else
|
|
2193
|
-
await this.
|
|
2194
|
-
|
|
2838
|
+
else if ((entry.patches?.length ?? 0) > 0) {
|
|
2839
|
+
await this.settleOptimisticReducer(entry.idempotencyKey);
|
|
2840
|
+
}
|
|
2841
|
+
else {
|
|
2842
|
+
await this.ackOptimisticReducer(entry.idempotencyKey, entry.id);
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
catch (error) {
|
|
2846
|
+
const disposition = deliveryErrorClass(error);
|
|
2847
|
+
if (disposition === "rejected") {
|
|
2848
|
+
// Keep today's rollback and rebase, but record the rejection
|
|
2849
|
+
// durably instead of deleting the intent without a trace.
|
|
2850
|
+
if (entry.localExecution && this.localExecutor) {
|
|
2851
|
+
await this.inLocalLane(async () => {
|
|
2852
|
+
await this.reducerOutbox.reject(entry.id, reducerErrorMessage(error));
|
|
2853
|
+
if (scope === this.outboxScope) {
|
|
2854
|
+
await this.rebaseLocalEntries(true);
|
|
2855
|
+
this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
|
|
2856
|
+
}
|
|
2857
|
+
});
|
|
2858
|
+
}
|
|
2859
|
+
else {
|
|
2860
|
+
this.optimisticReducerIds.delete(entry.idempotencyKey);
|
|
2861
|
+
this.optimisticOutboxEntryIds.delete(entry.idempotencyKey);
|
|
2862
|
+
this.replica.rejectCommand(entry.idempotencyKey);
|
|
2863
|
+
await this.reducerOutbox.reject(entry.id, reducerErrorMessage(error));
|
|
2864
|
+
if (scope === this.outboxScope)
|
|
2865
|
+
this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
|
|
2866
|
+
}
|
|
2867
|
+
continue;
|
|
2868
|
+
}
|
|
2869
|
+
const outcome = await this.recordDeliveryFailure(entry, error, disposition);
|
|
2870
|
+
// A parked intent keeps its prediction but no longer blocks the
|
|
2871
|
+
// chain; later intents proceed and the server validates them.
|
|
2872
|
+
if (outcome === "parked")
|
|
2873
|
+
continue;
|
|
2874
|
+
return;
|
|
2195
2875
|
}
|
|
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
2876
|
}
|
|
2200
|
-
}
|
|
2877
|
+
});
|
|
2201
2878
|
}
|
|
2202
2879
|
finally {
|
|
2203
2880
|
this.drainingOutbox = false;
|
|
2204
|
-
|
|
2881
|
+
this.scheduleLocalReplay();
|
|
2882
|
+
const requested = this.outboxDrainRequested;
|
|
2883
|
+
this.outboxDrainRequested = false;
|
|
2884
|
+
if (!this.manuallyClosed && (drainScope !== this.outboxScope || requested)) {
|
|
2205
2885
|
void this.drainOutbox();
|
|
2206
2886
|
}
|
|
2207
2887
|
}
|
|
2208
2888
|
}
|
|
2889
|
+
/**
|
|
2890
|
+
* Record a non-rejection delivery failure and arm the next attempt.
|
|
2891
|
+
* - update_required: keep the intent pending and stop for an app update.
|
|
2892
|
+
* - unauthenticated: keep the intent, re-authenticate, retry with backoff
|
|
2893
|
+
* that never spends the retry budget.
|
|
2894
|
+
* - network: connectivity loss; retried on reconnect without spending budget.
|
|
2895
|
+
* - transient (and timeouts): exponential backoff, parked as `failed` once
|
|
2896
|
+
* the retry budget is spent.
|
|
2897
|
+
*/
|
|
2898
|
+
async recordDeliveryFailure(entry, error, disposition) {
|
|
2899
|
+
const message = reducerErrorMessage(error);
|
|
2900
|
+
if (disposition === "update_required") {
|
|
2901
|
+
await this.reducerOutbox.fail(entry.id, message, { errorClass: disposition, countAttempt: false, delayMs: 0 });
|
|
2902
|
+
this.requireClientUpdate(message);
|
|
2903
|
+
return "update_required";
|
|
2904
|
+
}
|
|
2905
|
+
if (disposition === "unauthenticated") {
|
|
2906
|
+
const delay = outboxBackoffMs(this.unauthenticatedRetries++, this.outboxRetry.maxBackoffMs);
|
|
2907
|
+
await this.reducerOutbox.fail(entry.id, message, { errorClass: disposition, countAttempt: false, delayMs: delay });
|
|
2908
|
+
this.requestReauthentication();
|
|
2909
|
+
this.scheduleOutboxDrain(delay);
|
|
2910
|
+
return "retrying";
|
|
2911
|
+
}
|
|
2912
|
+
const counted = disposition === "transient";
|
|
2913
|
+
const updated = await this.reducerOutbox.fail(entry.id, message, {
|
|
2914
|
+
errorClass: disposition,
|
|
2915
|
+
countAttempt: counted,
|
|
2916
|
+
maxAttempts: this.outboxRetry.maxAttempts,
|
|
2917
|
+
maxBackoffMs: this.outboxRetry.maxBackoffMs,
|
|
2918
|
+
...(counted ? {} : { delayMs: 1_000 }),
|
|
2919
|
+
});
|
|
2920
|
+
if (updated && updated.state === "failed")
|
|
2921
|
+
return "parked";
|
|
2922
|
+
const delay = updated ? Math.max(0, updated.nextAttemptAt - Date.now()) : DEFAULT_OUTBOX_MAX_BACKOFF_MS;
|
|
2923
|
+
this.scheduleOutboxDrain(delay);
|
|
2924
|
+
return "retrying";
|
|
2925
|
+
}
|
|
2926
|
+
/**
|
|
2927
|
+
* Arm a timer for the earliest backed-off entry. Timer clocks and
|
|
2928
|
+
* `Date.now()` can disagree by a millisecond, so a wake-up may find its
|
|
2929
|
+
* entry not quite due; without this the queue would wait for an unrelated
|
|
2930
|
+
* event to resume.
|
|
2931
|
+
*/
|
|
2932
|
+
async scheduleNextOutboxAttempt(scope) {
|
|
2933
|
+
const now = Date.now();
|
|
2934
|
+
let next = Infinity;
|
|
2935
|
+
for (const entry of await this.reducerOutbox.list(scope)) {
|
|
2936
|
+
if (entry.state === "pending" && entry.nextAttemptAt > now)
|
|
2937
|
+
next = Math.min(next, entry.nextAttemptAt);
|
|
2938
|
+
}
|
|
2939
|
+
if (Number.isFinite(next) && scope === this.outboxScope)
|
|
2940
|
+
this.scheduleOutboxDrain(next - now + 1);
|
|
2941
|
+
}
|
|
2942
|
+
/** Re-send auth on the open socket so a lost tenant session is restored. */
|
|
2943
|
+
requestReauthentication() {
|
|
2944
|
+
if (this.manuallyClosed || this.authInFlight || this.socket?.readyState !== WebSocket.OPEN)
|
|
2945
|
+
return;
|
|
2946
|
+
if (!this.auth.project && !this.auth.tenant && !this.auth.token && !this.auth.fetchToken)
|
|
2947
|
+
return;
|
|
2948
|
+
this.sendAuth(false);
|
|
2949
|
+
}
|
|
2209
2950
|
scheduleOutboxDrain(delay) {
|
|
2210
2951
|
if (this.manuallyClosed)
|
|
2211
2952
|
return;
|
|
@@ -2245,7 +2986,6 @@ export class GonvexClient {
|
|
|
2245
2986
|
}
|
|
2246
2987
|
this.directOutboxReducerIds.add(reducerId);
|
|
2247
2988
|
let entryId;
|
|
2248
|
-
let entryAttempts = 0;
|
|
2249
2989
|
try {
|
|
2250
2990
|
const scope = this.outboxScope;
|
|
2251
2991
|
const entry = await this.reducerOutbox.enqueue({
|
|
@@ -2258,7 +2998,6 @@ export class GonvexClient {
|
|
|
2258
2998
|
state: "inflight",
|
|
2259
2999
|
});
|
|
2260
3000
|
entryId = entry.id;
|
|
2261
|
-
entryAttempts = entry.attempts;
|
|
2262
3001
|
if (this.manuallyClosed) {
|
|
2263
3002
|
await this.reducerOutbox.ack(entry.id);
|
|
2264
3003
|
throw new GonvexClientError(`Gonvex client was closed before reducer ${ref.path} could be sent.`, { code: "closed", path: ref.path, operation: "reducer" });
|
|
@@ -2286,14 +3025,13 @@ export class GonvexClient {
|
|
|
2286
3025
|
return result;
|
|
2287
3026
|
}
|
|
2288
3027
|
catch (error) {
|
|
2289
|
-
|
|
3028
|
+
const disposition = deliveryErrorClass(error);
|
|
3029
|
+
if (disposition !== "rejected" && options.offline === "queue") {
|
|
2290
3030
|
const queuedEntryId = this.optimisticOutboxEntryIds.get(reducerId) ?? entryId;
|
|
2291
3031
|
if (queuedEntryId !== undefined) {
|
|
2292
|
-
|
|
2293
|
-
//
|
|
2294
|
-
|
|
2295
|
-
// next deterministic drain just like the background drain path.
|
|
2296
|
-
this.scheduleOutboxDrain(Math.min(30_000, 1_000 * (2 ** (entryAttempts + 1))));
|
|
3032
|
+
// Records backoff (or parks the entry) and arms the client's timer,
|
|
3033
|
+
// exactly like the background drain path.
|
|
3034
|
+
await this.recordDeliveryFailure({ id: queuedEntryId }, error, disposition);
|
|
2297
3035
|
}
|
|
2298
3036
|
return { status: "queued", reducerId };
|
|
2299
3037
|
}
|
|
@@ -2514,8 +3252,11 @@ export class GonvexClient {
|
|
|
2514
3252
|
return;
|
|
2515
3253
|
}
|
|
2516
3254
|
if (kind === "reducer" && message.type === "reducer.result") {
|
|
2517
|
-
this.replica.acknowledgeCommand(message.originCommandId, message.committedRevision);
|
|
2518
3255
|
const complete = () => {
|
|
3256
|
+
// A collection frame can advance the shared cursor before another
|
|
3257
|
+
// collection's rows and membership have arrived. Retire the local
|
|
3258
|
+
// prediction only after the commit's durable watermark barrier.
|
|
3259
|
+
this.replica.acknowledgeCommand(message.originCommandId, message.committedRevision);
|
|
2519
3260
|
settle();
|
|
2520
3261
|
this.emitTelemetryFromCall(kind, id, ref.path, "ok", clientSentAtMs, message.trace);
|
|
2521
3262
|
resolve(message.result);
|
|
@@ -2539,7 +3280,11 @@ export class GonvexClient {
|
|
|
2539
3280
|
if (kind === "reducer" && message.type === "reducer.error") {
|
|
2540
3281
|
settle();
|
|
2541
3282
|
this.emitTelemetryFromCall(kind, id, ref.path, "error", clientSentAtMs, message.trace, message.error);
|
|
2542
|
-
|
|
3283
|
+
const errorClass = reducerErrorClassFromMessage(message);
|
|
3284
|
+
reject(new GonvexClientError(message.error, {
|
|
3285
|
+
code: "server", path: ref.path, operation: kind,
|
|
3286
|
+
...(errorClass ? { errorClass, retryable: message.retryable ?? (errorClass === "transient" || errorClass === "unauthenticated") } : {}),
|
|
3287
|
+
}));
|
|
2543
3288
|
}
|
|
2544
3289
|
if (kind === "action" && message.type === "action.result") {
|
|
2545
3290
|
const complete = () => {
|
|
@@ -3160,15 +3905,33 @@ function replaceOfflineLiveQueryMetadata(result, path, offline) {
|
|
|
3160
3905
|
const current = result[head];
|
|
3161
3906
|
return { ...result, [head]: replaceOfflineLiveQueryMetadata(current ?? null, tail, offline) };
|
|
3162
3907
|
}
|
|
3908
|
+
// Generated references and their plans are immutable module metadata. Retain
|
|
3909
|
+
// one canonical contract per reference, not a fresh sorted serialization for
|
|
3910
|
+
// every React watch/read. Weak keys do not retain replaced modules or callers.
|
|
3911
|
+
const subscriptionContracts = new WeakMap();
|
|
3163
3912
|
function querySubscriptionKey(ref, args) {
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
:
|
|
3170
|
-
|
|
3171
|
-
|
|
3913
|
+
let cached = subscriptionContracts.get(ref);
|
|
3914
|
+
if (!cached || cached.scope !== ref.scope || cached.delivery !== ref.delivery || cached.live !== ref.live) {
|
|
3915
|
+
const contract = {
|
|
3916
|
+
scope: ref.scope ?? "tenant",
|
|
3917
|
+
delivery: ref.delivery ?? "oneShot",
|
|
3918
|
+
live: ref.live
|
|
3919
|
+
? { entity: ref.live.entity, key: ref.live.key, resultPath: [...(ref.live.resultPath ?? [])], plan: ref.live.plan ?? null }
|
|
3920
|
+
: null,
|
|
3921
|
+
};
|
|
3922
|
+
cached = { scope: ref.scope, delivery: ref.delivery, live: ref.live, value: stableStringify(contract), keys: new Map() };
|
|
3923
|
+
subscriptionContracts.set(ref, cached);
|
|
3924
|
+
}
|
|
3925
|
+
const argumentsKey = `${ref.path}\u0000${stableStringify(args)}`;
|
|
3926
|
+
const previous = cached.keys.get(argumentsKey);
|
|
3927
|
+
if (previous !== undefined)
|
|
3928
|
+
return previous;
|
|
3929
|
+
const key = `${argumentsKey}\u0000${cached.value}`;
|
|
3930
|
+
// Search text and pagination may produce unbounded argument combinations.
|
|
3931
|
+
if (cached.keys.size >= 16)
|
|
3932
|
+
cached.keys.delete(cached.keys.keys().next().value);
|
|
3933
|
+
cached.keys.set(argumentsKey, key);
|
|
3934
|
+
return key;
|
|
3172
3935
|
}
|
|
3173
3936
|
function countPendingCalls(calls, kind) {
|
|
3174
3937
|
let count = 0;
|
|
@@ -3178,9 +3941,96 @@ function countPendingCalls(calls, kind) {
|
|
|
3178
3941
|
}
|
|
3179
3942
|
return count;
|
|
3180
3943
|
}
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3944
|
+
/**
|
|
3945
|
+
* How the outbox treats one failed delivery. Runtimes before
|
|
3946
|
+
* 0.5.2-staging.15 send no class: their server errors stay rejections, as
|
|
3947
|
+
* before, except the two cases the legacy runtime could only express in prose.
|
|
3948
|
+
*/
|
|
3949
|
+
function deliveryErrorClass(error) {
|
|
3950
|
+
if (!(error instanceof GonvexClientError))
|
|
3951
|
+
return "transient";
|
|
3952
|
+
switch (error.code) {
|
|
3953
|
+
case "server": return error.errorClass ?? "rejected";
|
|
3954
|
+
case "timeout": return "transient";
|
|
3955
|
+
default: return "network";
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3958
|
+
const legacyUnauthenticatedReducerError = "authenticate with an active tenant before calling a Reducer";
|
|
3959
|
+
function reducerErrorClassFromMessage(message) {
|
|
3960
|
+
if (message.class === "rejected" || message.class === "transient" || message.class === "update_required" || message.class === "unauthenticated") {
|
|
3961
|
+
return message.class;
|
|
3962
|
+
}
|
|
3963
|
+
// Legacy runtimes: the only structured signals were these literal messages.
|
|
3964
|
+
if (/STALE_REDUCER_ARTIFACT|CLIENT_UPDATE_REQUIRED/.test(message.error))
|
|
3965
|
+
return "update_required";
|
|
3966
|
+
if (message.error === legacyUnauthenticatedReducerError)
|
|
3967
|
+
return "unauthenticated";
|
|
3968
|
+
return undefined;
|
|
3969
|
+
}
|
|
3970
|
+
function positiveOr(value, fallback) {
|
|
3971
|
+
return typeof value === "number" && value > 0 ? value : fallback;
|
|
3972
|
+
}
|
|
3973
|
+
function summarizeArgs(args) {
|
|
3974
|
+
let text;
|
|
3975
|
+
try {
|
|
3976
|
+
text = JSON.stringify(args) ?? "";
|
|
3977
|
+
}
|
|
3978
|
+
catch {
|
|
3979
|
+
text = String(args);
|
|
3980
|
+
}
|
|
3981
|
+
return text.length > 160 ? `${text.slice(0, 157)}...` : text;
|
|
3982
|
+
}
|
|
3983
|
+
function outboxIntentFromEntry(entry) {
|
|
3984
|
+
const entities = new Map();
|
|
3985
|
+
for (const patch of entry.patches ?? []) {
|
|
3986
|
+
const entity = patch.entity ?? patch.collection;
|
|
3987
|
+
if (!entity)
|
|
3988
|
+
continue;
|
|
3989
|
+
entities.set(`${entity}\u0000${patch.rowId}`, { entity, id: patch.rowId });
|
|
3990
|
+
}
|
|
3991
|
+
return {
|
|
3992
|
+
id: entry.idempotencyKey,
|
|
3993
|
+
entryId: entry.id,
|
|
3994
|
+
reducer: entry.path,
|
|
3995
|
+
state: entry.state,
|
|
3996
|
+
attempts: entry.attempts,
|
|
3997
|
+
...(entry.lastError !== undefined ? { lastError: entry.lastError } : {}),
|
|
3998
|
+
...(entry.lastErrorClass ? { errorClass: entry.lastErrorClass } : {}),
|
|
3999
|
+
createdAt: entry.createdAt,
|
|
4000
|
+
nextAttemptAt: entry.nextAttemptAt,
|
|
4001
|
+
...(entry.settledAt !== undefined ? { settledAt: entry.settledAt } : {}),
|
|
4002
|
+
args: entry.args,
|
|
4003
|
+
argsSummary: summarizeArgs(entry.args),
|
|
4004
|
+
entities: [...entities.values()],
|
|
4005
|
+
};
|
|
4006
|
+
}
|
|
4007
|
+
function sameIntents(left, right) {
|
|
4008
|
+
if (left.length !== right.length)
|
|
4009
|
+
return false;
|
|
4010
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
4011
|
+
const a = left[index], b = right[index];
|
|
4012
|
+
if (a.id !== b.id || a.entryId !== b.entryId || a.state !== b.state || a.attempts !== b.attempts
|
|
4013
|
+
|| a.lastError !== b.lastError || a.errorClass !== b.errorClass || a.nextAttemptAt !== b.nextAttemptAt
|
|
4014
|
+
|| a.settledAt !== b.settledAt || a.entities.length !== b.entities.length
|
|
4015
|
+
|| a.entities.some((entity, position) => entity.entity !== b.entities[position].entity || entity.id !== b.entities[position].id))
|
|
4016
|
+
return false;
|
|
4017
|
+
}
|
|
4018
|
+
return true;
|
|
4019
|
+
}
|
|
4020
|
+
/** Row status from a list of intents: failed > rejected > syncing. */
|
|
4021
|
+
export function entityStatusFromIntents(intents, entity, id) {
|
|
4022
|
+
let status;
|
|
4023
|
+
for (const intent of intents) {
|
|
4024
|
+
if (!intent.entities.some((candidate) => candidate.entity === entity && candidate.id === id))
|
|
4025
|
+
continue;
|
|
4026
|
+
if (intent.state === "failed")
|
|
4027
|
+
return "failed";
|
|
4028
|
+
if (intent.state === "rejected")
|
|
4029
|
+
status = "rejected";
|
|
4030
|
+
else if (intent.state !== "committed" && status === undefined)
|
|
4031
|
+
status = "syncing";
|
|
4032
|
+
}
|
|
4033
|
+
return status;
|
|
3184
4034
|
}
|
|
3185
4035
|
function reducerErrorMessage(error) {
|
|
3186
4036
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -3202,6 +4052,9 @@ function stableStringify(value) {
|
|
|
3202
4052
|
.join(",")}}`;
|
|
3203
4053
|
}
|
|
3204
4054
|
function utf8KeyCompare(left, right) {
|
|
4055
|
+
// ASCII keys have identical UTF-8 and JavaScript lexical ordering.
|
|
4056
|
+
if (!/[^\x00-\x7f]/.test(left) && !/[^\x00-\x7f]/.test(right))
|
|
4057
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
3205
4058
|
const leftBytes = new TextEncoder().encode(left);
|
|
3206
4059
|
const rightBytes = new TextEncoder().encode(right);
|
|
3207
4060
|
const length = Math.min(leftBytes.length, rightBytes.length);
|
|
@@ -3222,9 +4075,9 @@ function boundReplicaRows(rows, keyField, maxRows, maxBytes, orderBy, orderDirec
|
|
|
3222
4075
|
const key = replicaRowKeyValue(row, keyField);
|
|
3223
4076
|
if (!key || seen.has(key))
|
|
3224
4077
|
continue;
|
|
3225
|
-
const size = replicaJSONSize(row);
|
|
3226
4078
|
if (maxRows && kept.length >= maxRows)
|
|
3227
4079
|
break;
|
|
4080
|
+
const size = maxBytes ? replicaJSONSize(row) : 0;
|
|
3228
4081
|
if (maxBytes && bytes + size > maxBytes)
|
|
3229
4082
|
break;
|
|
3230
4083
|
kept.push(row);
|
|
@@ -3271,7 +4124,10 @@ function replicaRowKeyValue(value, keyField) {
|
|
|
3271
4124
|
return key === null || key === undefined ? "" : String(key);
|
|
3272
4125
|
}
|
|
3273
4126
|
function replicaJSONSize(value) {
|
|
3274
|
-
|
|
4127
|
+
// Property ordering cannot change the encoded length. Native serialization
|
|
4128
|
+
// avoids recursively allocating sorted key arrays and intermediate strings.
|
|
4129
|
+
const json = JSON.stringify(value).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
4130
|
+
return new TextEncoder().encode(json).byteLength;
|
|
3275
4131
|
}
|
|
3276
4132
|
function applyKeyedPatch(previous, patch) {
|
|
3277
4133
|
const rows = new Map();
|
|
@@ -3535,4 +4391,34 @@ function detectDeviceType(userAgent) {
|
|
|
3535
4391
|
return "mobile";
|
|
3536
4392
|
return "desktop";
|
|
3537
4393
|
}
|
|
4394
|
+
/** Sign-in and account-only pages do not need reducer code loaded. */
|
|
4395
|
+
function deferredLocalExecutor(create) {
|
|
4396
|
+
let executor;
|
|
4397
|
+
let closed = false;
|
|
4398
|
+
const start = () => {
|
|
4399
|
+
if (closed)
|
|
4400
|
+
throw new Error("Local reducer executor is closed");
|
|
4401
|
+
return executor ??= create();
|
|
4402
|
+
};
|
|
4403
|
+
return {
|
|
4404
|
+
get ready() {
|
|
4405
|
+
try {
|
|
4406
|
+
return start().ready;
|
|
4407
|
+
}
|
|
4408
|
+
catch (error) {
|
|
4409
|
+
return Promise.reject(error);
|
|
4410
|
+
}
|
|
4411
|
+
},
|
|
4412
|
+
prepare: async (path) => start().prepare?.(path),
|
|
4413
|
+
execute: async (...args) => start().execute(...args),
|
|
4414
|
+
executeRead: async (...args) => {
|
|
4415
|
+
const instance = start();
|
|
4416
|
+
if (!instance.executeRead)
|
|
4417
|
+
throw new Error('Local executor does not support indexed reads');
|
|
4418
|
+
return instance.executeRead(...args);
|
|
4419
|
+
},
|
|
4420
|
+
replay: async (...args) => start().replay(...args),
|
|
4421
|
+
close: () => { closed = true; executor?.close(); },
|
|
4422
|
+
};
|
|
4423
|
+
}
|
|
3538
4424
|
//# sourceMappingURL=index.js.map
|