@gonvex/client 0.5.2-staging.2 → 0.5.2-staging.20
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 +68 -2
- package/dist/browser-upgrades.d.ts +19 -0
- package/dist/browser-upgrades.js +157 -0
- package/dist/browser-upgrades.js.map +1 -0
- package/dist/client-upgrades.d.ts +26 -0
- package/dist/client-upgrades.js +41 -0
- package/dist/client-upgrades.js.map +1 -0
- 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 +344 -7
- package/dist/index.js +1528 -173
- 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 +41 -3
- package/dist/indexeddb-replica.js +402 -101
- package/dist/indexeddb-replica.js.map +1 -1
- package/dist/local-replica.d.ts +125 -4
- package/dist/local-replica.js +831 -85
- package/dist/local-replica.js.map +1 -1
- package/dist/outbox.d.ts +118 -10
- package/dist/outbox.js +391 -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 +4 -2
package/dist/index.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
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";
|
|
11
12
|
export * from "./kv-stores.js";
|
|
13
|
+
export * from "./client-upgrades.js";
|
|
14
|
+
export * from "./browser-upgrades.js";
|
|
12
15
|
export * from "./signals.js";
|
|
16
|
+
export * from "./paged-read-view.js";
|
|
17
|
+
export { mergeReplicaRecord } from './replica-record.js';
|
|
13
18
|
export * from "./external-auth.js";
|
|
14
19
|
// Keep the mutable LocalReplica implementation private to GonvexClient. The
|
|
15
20
|
// public package exposes only the read-only view plus storage/value types.
|
|
@@ -39,6 +44,7 @@ function createLocalReplicaView(replica) {
|
|
|
39
44
|
cursor: () => replica.cursor(),
|
|
40
45
|
freshness: () => replica.freshness(),
|
|
41
46
|
version: () => replica.version(),
|
|
47
|
+
entityVersion: (entity, id) => replica.entityVersion(entity, id),
|
|
42
48
|
subscribe: (listener) => replica.subscribe(listener),
|
|
43
49
|
hasPendingCommand: (commandId) => replica.hasPendingCommand(commandId),
|
|
44
50
|
getWindow: (signature) => replica.getWindow(signature),
|
|
@@ -49,6 +55,7 @@ function createLocalReplicaView(replica) {
|
|
|
49
55
|
entityRows: (entity) => replica.entityRows(entity),
|
|
50
56
|
entityCompleteness: (entity) => replica.entityCompleteness(entity),
|
|
51
57
|
liveQuery: (signature) => replica.liveQuery(signature),
|
|
58
|
+
liveQuerySnapshot: (signature) => replica.liveQuerySnapshot(signature),
|
|
52
59
|
collectionState: (signature) => replica.collectionState(signature),
|
|
53
60
|
hasLiveQuery: (signature) => replica.hasLiveQuery(signature),
|
|
54
61
|
snapshot: () => replica.snapshot(),
|
|
@@ -89,14 +96,30 @@ export class GonvexClientError extends Error {
|
|
|
89
96
|
code;
|
|
90
97
|
path;
|
|
91
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;
|
|
92
107
|
constructor(message, options) {
|
|
93
108
|
super(message);
|
|
94
109
|
this.name = "GonvexClientError";
|
|
95
110
|
this.code = options.code;
|
|
96
111
|
this.path = options.path;
|
|
97
112
|
this.operation = options.operation;
|
|
113
|
+
if (options.errorClass)
|
|
114
|
+
this.errorClass = options.errorClass;
|
|
115
|
+
if (options.retryable !== undefined)
|
|
116
|
+
this.retryable = options.retryable;
|
|
98
117
|
}
|
|
99
118
|
}
|
|
119
|
+
/** Default row bound of `localDependencyResidency: "bounded"`. */
|
|
120
|
+
export const DEFAULT_LOCAL_DEPENDENCY_RESIDENT_MAX_ROWS = 1000;
|
|
121
|
+
export const DEFAULT_OUTBOX_MAX_ATTEMPTS = 10;
|
|
122
|
+
export const DEFAULT_OUTBOX_RETRY_MAX_BACKOFF_MS = 60_000;
|
|
100
123
|
export const DEFAULT_QUERY_TIMEOUT_MS = 20_000;
|
|
101
124
|
export const DEFAULT_REDUCER_TIMEOUT_MS = 20_000;
|
|
102
125
|
export const DEFAULT_ACTION_TIMEOUT_MS = 60_000;
|
|
@@ -115,10 +138,34 @@ const maxReplicaBatchOpens = 256;
|
|
|
115
138
|
// normally settle in a few milliseconds.
|
|
116
139
|
export class GonvexClient {
|
|
117
140
|
url;
|
|
141
|
+
localTableBindings = new Map();
|
|
142
|
+
localBinding;
|
|
143
|
+
localExecutor;
|
|
144
|
+
localStorage;
|
|
145
|
+
localIdentity;
|
|
146
|
+
localLane = Promise.resolve();
|
|
147
|
+
preparationLane = Promise.resolve();
|
|
148
|
+
localReplayScheduled = false;
|
|
149
|
+
waitingLocalEdits = 0;
|
|
150
|
+
replacingLocal = false;
|
|
151
|
+
unsubscribeLocal;
|
|
152
|
+
localCollectionClosers = [];
|
|
153
|
+
localCollectionKeys = new Set();
|
|
154
|
+
localExecutionTables = new Map();
|
|
155
|
+
reducerRejectionHandlers = new Set();
|
|
156
|
+
localFallbackHandlers = new Set();
|
|
157
|
+
residentDependencyTables;
|
|
158
|
+
outboxRetry;
|
|
159
|
+
unauthenticatedRetries = 0;
|
|
160
|
+
intentsSnapshotValue = [];
|
|
161
|
+
intentListeners = new Set();
|
|
162
|
+
intentsRefreshRunning = false;
|
|
163
|
+
intentsRefreshDirty = false;
|
|
118
164
|
socket;
|
|
119
165
|
handlers = new Map();
|
|
120
166
|
querySubscriptions = new Map();
|
|
121
167
|
replicaSubscriptions = new Map();
|
|
168
|
+
replicaMetadataListeners = new WeakSet();
|
|
122
169
|
oneShotQueries = new Map();
|
|
123
170
|
telemetryHandlers = new Set();
|
|
124
171
|
pendingMessages = [];
|
|
@@ -128,6 +175,7 @@ export class GonvexClient {
|
|
|
128
175
|
querySubscribeFlushTimer;
|
|
129
176
|
serverCapabilities = {};
|
|
130
177
|
activeArtifactHashValue = "";
|
|
178
|
+
publicTenant;
|
|
131
179
|
auth = {};
|
|
132
180
|
authInFlight = false;
|
|
133
181
|
// Only the newest auth frame may change socket authorization. The runtime
|
|
@@ -145,6 +193,7 @@ export class GonvexClient {
|
|
|
145
193
|
// or a fresh send cycle starts, so a bad token can't refresh-loop forever.
|
|
146
194
|
authRetriedAfterError = false;
|
|
147
195
|
authErrorHandlers = new Set();
|
|
196
|
+
developerCredentialHandlers = new Set();
|
|
148
197
|
managedAuthAttempt;
|
|
149
198
|
telemetryEnabled = false;
|
|
150
199
|
querySubscriptionRetentionMs;
|
|
@@ -167,8 +216,15 @@ export class GonvexClient {
|
|
|
167
216
|
processedReplicaWatermarkRevision = 0;
|
|
168
217
|
pendingReplicaTransactions = [];
|
|
169
218
|
unsubscribeOutbox;
|
|
219
|
+
sharedOutboxStore;
|
|
220
|
+
unsubscribePeerOutbox;
|
|
221
|
+
unsubscribePeerReplica;
|
|
222
|
+
peerRefreshScheduled = false;
|
|
223
|
+
peerRefreshDirty = false;
|
|
170
224
|
unsubscribeBrowserOnline;
|
|
171
225
|
drainingOutbox = false;
|
|
226
|
+
/** A wake-up (timer, enqueue, reconnect) that arrived while a drain was running. */
|
|
227
|
+
outboxDrainRequested = false;
|
|
172
228
|
outboxDrainTimer;
|
|
173
229
|
sessionScopeHandlers = new Set();
|
|
174
230
|
errorReporter;
|
|
@@ -180,20 +236,68 @@ export class GonvexClient {
|
|
|
180
236
|
pendingCalls = new Map();
|
|
181
237
|
connectionStateHandlers = new Set();
|
|
182
238
|
supportCommandHandlers = new Set();
|
|
239
|
+
pendingNativeErrors = [];
|
|
183
240
|
isWebSocketConnected = false;
|
|
184
241
|
hasEverConnected = false;
|
|
185
242
|
connectionCount = 0;
|
|
186
243
|
timeouts;
|
|
244
|
+
clientContract;
|
|
245
|
+
updateRequiredHandler;
|
|
246
|
+
updateRequired = false;
|
|
247
|
+
lastOnlineAtMs = 0;
|
|
187
248
|
constructor(url, options = {}) {
|
|
188
249
|
this.url = url;
|
|
250
|
+
if (options.public) {
|
|
251
|
+
if (!options.public.tenant.trim() || !options.project?.trim())
|
|
252
|
+
throw new Error("Public connections require project and tenant");
|
|
253
|
+
if (options.token || options.fetchToken || options.identity || options.tenant)
|
|
254
|
+
throw new Error("Public connections cannot include session credentials or a separate tenant");
|
|
255
|
+
this.publicTenant = options.public.tenant.trim();
|
|
256
|
+
options = { ...options, clientContract: options.clientContract ?? options.localRuntime?.clientContract, tenant: this.publicTenant, localRuntime: undefined, localReplica: undefined, outbox: { enabled: false }, telemetry: false };
|
|
257
|
+
}
|
|
258
|
+
this.clientContract = options.clientContract ?? options.localRuntime?.clientContract;
|
|
259
|
+
this.updateRequiredHandler = options.onUpdateRequired;
|
|
260
|
+
this.localBinding = options.localRuntime;
|
|
261
|
+
for (const table of this.localBinding?.tables ?? []) {
|
|
262
|
+
const references = (this.localBinding?.collections ?? []).filter(ref => ref.replica?.table === table
|
|
263
|
+
&& !Object.keys(ref.replica.equalFilters ?? {}).length && !(ref.replica.excludeWhenSet?.length));
|
|
264
|
+
this.localTableBindings.set(table, { signatures: references.map(ref => querySubscriptionKey(ref, {})),
|
|
265
|
+
columns: [...new Set(references.flatMap(ref => ref.replica?.columns ?? []))] });
|
|
266
|
+
}
|
|
267
|
+
this.localExecutor = options.localRuntime ? deferredLocalExecutor(() => options.localRuntime.create()) : undefined;
|
|
268
|
+
this.residentDependencyTables = residentLocalDependencyTables(options.localRuntime, options.localDependencyResidency);
|
|
269
|
+
if (options.onLocalExecutionFallback)
|
|
270
|
+
this.localFallbackHandlers.add(options.onLocalExecutionFallback);
|
|
271
|
+
this.localStorage = options.localReplica?.storage;
|
|
189
272
|
this.auth = authFromOptions(options);
|
|
190
273
|
this.telemetryEnabled = options.telemetry === true;
|
|
191
274
|
this.querySubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.querySubscriptionRetentionMs);
|
|
192
275
|
this.replicaSubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.replicaSubscriptionRetentionMs);
|
|
276
|
+
this.sharedOutboxStore = options.outbox?.store?.shared ? options.outbox.store : undefined;
|
|
193
277
|
this.reducerOutbox = createReducerOutbox(options.outbox);
|
|
194
|
-
this.
|
|
278
|
+
this.outboxRetry = {
|
|
279
|
+
maxAttempts: positiveOr(options.outbox?.retry?.maxAttempts, DEFAULT_OUTBOX_MAX_ATTEMPTS),
|
|
280
|
+
maxBackoffMs: positiveOr(options.outbox?.retry?.maxBackoffMs, DEFAULT_OUTBOX_RETRY_MAX_BACKOFF_MS),
|
|
281
|
+
};
|
|
282
|
+
this.replica = new LocalReplica(options.localReplica?.storage, { maxResidentRows: options.localReplica?.maxResidentRows, maxResidentBytes: options.localReplica?.maxResidentBytes });
|
|
195
283
|
this.replicaView = createLocalReplicaView(this.replica);
|
|
284
|
+
if (this.localExecutor) {
|
|
285
|
+
let executionVersion = this.replica.executionVersion();
|
|
286
|
+
this.unsubscribeLocal = this.replica.subscribe(() => {
|
|
287
|
+
const next = this.replica.executionVersion();
|
|
288
|
+
if (next === executionVersion)
|
|
289
|
+
return;
|
|
290
|
+
executionVersion = next;
|
|
291
|
+
this.scheduleLocalReplay();
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
this.unsubscribePeerOutbox = this.sharedOutboxStore?.subscribePeer?.(() => this.refreshPeerOutbox());
|
|
295
|
+
this.unsubscribePeerReplica = this.localStorage?.subscribePeer?.(scope => {
|
|
296
|
+
if (scope === this.replicaScope)
|
|
297
|
+
this.refreshPeerOutbox();
|
|
298
|
+
});
|
|
196
299
|
this.unsubscribeOutbox = this.reducerOutbox.subscribe(() => {
|
|
300
|
+
this.refreshIntents();
|
|
197
301
|
void this.drainOutbox();
|
|
198
302
|
});
|
|
199
303
|
if (typeof globalThis.addEventListener === "function") {
|
|
@@ -214,7 +318,7 @@ export class GonvexClient {
|
|
|
214
318
|
this.outboxScope = initialScope;
|
|
215
319
|
this.replicaScope = ["awaiting-server-scope", this.url, this.outboxEphemeralScope].join("\u0000");
|
|
216
320
|
this.outboxScopeGeneration += 1;
|
|
217
|
-
this.
|
|
321
|
+
this.setReplicaReady(this.replica.activateScope(this.replicaScope, true));
|
|
218
322
|
this.outboxReady = Promise.resolve();
|
|
219
323
|
this.timeouts = {
|
|
220
324
|
queryTimeoutMs: options.timeouts?.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
|
|
@@ -229,6 +333,729 @@ export class GonvexClient {
|
|
|
229
333
|
transport: (type, payload) => this.sendNativeError(type, payload),
|
|
230
334
|
});
|
|
231
335
|
}
|
|
336
|
+
if (this.localBinding)
|
|
337
|
+
this.setOutboxReady(this.restoreLocalSession(initialScope, this.outboxScopeGeneration));
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Authoritative sync failures arrive after an offline call returned locally.
|
|
341
|
+
* The intent also stays in the outbox as `rejected` until it is discarded
|
|
342
|
+
* or retried, so a UI that mounts later can still show it.
|
|
343
|
+
*/
|
|
344
|
+
onReducerRejection(listener) {
|
|
345
|
+
this.reducerRejectionHandlers.add(listener);
|
|
346
|
+
return () => this.reducerRejectionHandlers.delete(listener);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Observe every point where a locally executing Reducer degrades to a
|
|
350
|
+
* queued-only intent (no local result). The intent is still durable and is
|
|
351
|
+
* delivered to the server; this exists so apps can log and fix the missing
|
|
352
|
+
* local data. Returns an unsubscribe function.
|
|
353
|
+
*/
|
|
354
|
+
onLocalExecutionFallback(listener) {
|
|
355
|
+
this.localFallbackHandlers.add(listener);
|
|
356
|
+
return () => { this.localFallbackHandlers.delete(listener); };
|
|
357
|
+
}
|
|
358
|
+
reportLocalFallback(path, intentId, phase, error) {
|
|
359
|
+
if (!this.localFallbackHandlers.size)
|
|
360
|
+
return;
|
|
361
|
+
const incomplete = error instanceof Error && error.name === "IncompleteReplicaError";
|
|
362
|
+
const table = incomplete
|
|
363
|
+
? error.read?.table ?? /^Local replica for (.+?) is incomplete;/.exec(error.message)?.[1]
|
|
364
|
+
: undefined;
|
|
365
|
+
const event = {
|
|
366
|
+
path, intentId, phase, reason: incomplete ? "incompleteReplica" : "localError",
|
|
367
|
+
...(table ? { table } : {}),
|
|
368
|
+
error: error instanceof Error ? error.message : String(error),
|
|
369
|
+
};
|
|
370
|
+
for (const listener of this.localFallbackHandlers) {
|
|
371
|
+
try {
|
|
372
|
+
listener(event);
|
|
373
|
+
}
|
|
374
|
+
catch { /* A listener must not break delivery. */ }
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/** Every durable intent of the current identity, oldest first. */
|
|
378
|
+
async listIntents() {
|
|
379
|
+
await this.outboxReady;
|
|
380
|
+
return (await this.reducerOutbox.list(this.outboxScope)).map(outboxIntentFromEntry);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Synchronous snapshot for external stores (React `useSyncExternalStore`).
|
|
384
|
+
* Kept current while at least one {@link subscribeIntents} listener exists.
|
|
385
|
+
*/
|
|
386
|
+
intentsSnapshot() {
|
|
387
|
+
return this.intentsSnapshotValue;
|
|
388
|
+
}
|
|
389
|
+
/** Observe intent changes; call {@link intentsSnapshot} for the new value. */
|
|
390
|
+
subscribeIntents(listener) {
|
|
391
|
+
this.intentListeners.add(listener);
|
|
392
|
+
if (this.intentListeners.size === 1)
|
|
393
|
+
this.refreshIntents();
|
|
394
|
+
return () => { this.intentListeners.delete(listener); };
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Delivery status for one row from the current snapshot: `failed` or
|
|
398
|
+
* `rejected` when an intent touching it needs attention, `syncing` while one
|
|
399
|
+
* is still queued, otherwise undefined.
|
|
400
|
+
*/
|
|
401
|
+
entityIntentStatus(entity, id) {
|
|
402
|
+
return entityStatusFromIntents(this.intentsSnapshotValue, entity, id);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Re-arm a `failed` or `rejected` intent with a fresh retry budget. The
|
|
406
|
+
* original idempotency key is reused, so an attempt that had actually
|
|
407
|
+
* committed is replayed by the server rather than applied twice.
|
|
408
|
+
*/
|
|
409
|
+
async retryIntent(id) {
|
|
410
|
+
await this.outboxReady;
|
|
411
|
+
const retried = await this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
|
|
412
|
+
const scope = this.outboxScope;
|
|
413
|
+
const entry = (await this.reducerOutbox.list(scope)).find((candidate) => candidate.idempotencyKey === id);
|
|
414
|
+
if (!entry || scope !== this.outboxScope)
|
|
415
|
+
return false;
|
|
416
|
+
const updated = await this.reducerOutbox.retry(entry.id);
|
|
417
|
+
if (!updated)
|
|
418
|
+
return false;
|
|
419
|
+
this.optimisticOutboxEntryIds.set(id, updated.id);
|
|
420
|
+
if (!outboxEntryIsLive(entry)) {
|
|
421
|
+
// A rejection rolled the prediction back; restore it at its original
|
|
422
|
+
// position in the causal chain.
|
|
423
|
+
if (updated.localExecution && this.localExecutor)
|
|
424
|
+
await this.rebaseLocalEntriesLocked(true);
|
|
425
|
+
else
|
|
426
|
+
this.addOptimisticReducer(id, updated.patches ?? []);
|
|
427
|
+
}
|
|
428
|
+
return true;
|
|
429
|
+
}));
|
|
430
|
+
if (retried) {
|
|
431
|
+
this.unauthenticatedRetries = 0;
|
|
432
|
+
void this.drainOutbox();
|
|
433
|
+
}
|
|
434
|
+
return retried;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Drop a `pending`, `failed` or `rejected` intent: it will never be sent,
|
|
438
|
+
* its optimistic prediction is removed and later local intents are rebased.
|
|
439
|
+
* Inflight and committed intents cannot be discarded. Discarding an intent
|
|
440
|
+
* whose earlier attempt timed out does not undo a commit that the server
|
|
441
|
+
* may already have applied; the replica then shows the server's truth.
|
|
442
|
+
*/
|
|
443
|
+
async discardIntent(id) {
|
|
444
|
+
await this.outboxReady;
|
|
445
|
+
return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
|
|
446
|
+
const scope = this.outboxScope;
|
|
447
|
+
const entry = (await this.reducerOutbox.list(scope)).find((candidate) => candidate.idempotencyKey === id);
|
|
448
|
+
if (!entry || scope !== this.outboxScope)
|
|
449
|
+
return false;
|
|
450
|
+
const removed = await this.reducerOutbox.discard(entry.id, ["pending", "failed", "rejected"]);
|
|
451
|
+
if (!removed)
|
|
452
|
+
return false;
|
|
453
|
+
this.optimisticOutboxEntryIds.delete(id);
|
|
454
|
+
this.optimisticReducerIds.delete(id);
|
|
455
|
+
if (outboxEntryIsLive(removed)) {
|
|
456
|
+
this.replacingLocal = true;
|
|
457
|
+
try {
|
|
458
|
+
this.replica.rejectCommand(id);
|
|
459
|
+
}
|
|
460
|
+
finally {
|
|
461
|
+
this.replacingLocal = false;
|
|
462
|
+
}
|
|
463
|
+
if (removed.localExecution && this.localExecutor)
|
|
464
|
+
await this.rebaseLocalEntriesLocked(true);
|
|
465
|
+
}
|
|
466
|
+
return true;
|
|
467
|
+
}));
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Discard the persisted Local Replica of the active identity (IndexedDB,
|
|
471
|
+
* Expo SQLite or any configured storage) and rehydrate it from the server.
|
|
472
|
+
*
|
|
473
|
+
* Online only: without an authenticated connection the call rejects with a
|
|
474
|
+
* `GonvexClientError` (`code: "disconnected"`) and changes nothing, so the
|
|
475
|
+
* user is never left with an empty cache that cannot be refilled. The saved
|
|
476
|
+
* offline session (identity and replica directive) is kept.
|
|
477
|
+
*
|
|
478
|
+
* By default the outbox is untouched: pending, failed and rejected intents
|
|
479
|
+
* stay durable, and the predictions of every live intent are re-applied
|
|
480
|
+
* immediately and recomputed as server data arrives. Active Replica
|
|
481
|
+
* Collections and Live Queries are re-requested without their old cursors,
|
|
482
|
+
* so the server sends full snapshots.
|
|
483
|
+
*/
|
|
484
|
+
async resetLocalReplica(options = {}) {
|
|
485
|
+
const refuse = (message) => new GonvexClientError(message, { code: "disconnected", operation: "query" });
|
|
486
|
+
if (this.manuallyClosed)
|
|
487
|
+
throw new GonvexClientError("Gonvex client is closed", { code: "closed", operation: "query" });
|
|
488
|
+
await this.outboxReady;
|
|
489
|
+
await this.replicaReady;
|
|
490
|
+
if (!this.canSendReducerNow() || !this.hasAuthoritativeReplicaScope) {
|
|
491
|
+
throw refuse("Cannot reset local data while offline. Reconnect and try again; cached data was kept.");
|
|
492
|
+
}
|
|
493
|
+
const keepOutbox = options.keepOutbox !== false;
|
|
494
|
+
return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
|
|
495
|
+
const scope = this.outboxScope;
|
|
496
|
+
const replicaScope = this.replicaScope;
|
|
497
|
+
// Re-check under the lock: the connection may have dropped while waiting.
|
|
498
|
+
if (!this.canSendReducerNow() || !this.hasAuthoritativeReplicaScope) {
|
|
499
|
+
throw refuse("Cannot reset local data while offline. Reconnect and try again; cached data was kept.");
|
|
500
|
+
}
|
|
501
|
+
// Stop the server streaming into subscription ids we are about to retire.
|
|
502
|
+
for (const subscription of this.replicaSubscriptions.values()) {
|
|
503
|
+
if (subscription.socketGeneration !== undefined)
|
|
504
|
+
this.send({ type: "replica.close", id: subscription.id });
|
|
505
|
+
}
|
|
506
|
+
for (const subscription of this.querySubscriptions.values()) {
|
|
507
|
+
if (subscription.socketGeneration !== undefined)
|
|
508
|
+
this.send({ type: "query.unsubscribe", id: subscription.id });
|
|
509
|
+
}
|
|
510
|
+
this.pendingReplicaTransactions.length = 0;
|
|
511
|
+
await this.replica.clear(replicaScope);
|
|
512
|
+
this.resetReplicaScopeState();
|
|
513
|
+
this.rotateSubscriptionScopes();
|
|
514
|
+
let discardedIntents = 0;
|
|
515
|
+
if (!keepOutbox) {
|
|
516
|
+
for (const entry of await this.reducerOutbox.list(scope)) {
|
|
517
|
+
if (await this.reducerOutbox.discard(entry.id, ["pending", "failed", "rejected"]))
|
|
518
|
+
discardedIntents += 1;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// replica.clear() dropped every overlay. Restore the stored predictions
|
|
522
|
+
// of live intents now; local intents are re-executed by the replay that
|
|
523
|
+
// each arriving snapshot schedules.
|
|
524
|
+
const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
|
|
525
|
+
this.replacingLocal = true;
|
|
526
|
+
try {
|
|
527
|
+
this.replaceLocalPredictions(entries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
|
|
528
|
+
}
|
|
529
|
+
finally {
|
|
530
|
+
this.replacingLocal = false;
|
|
531
|
+
}
|
|
532
|
+
this.optimisticReducerIds.clear();
|
|
533
|
+
this.optimisticOutboxEntryIds.clear();
|
|
534
|
+
for (const entry of entries) {
|
|
535
|
+
this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
|
|
536
|
+
if (entry.state !== "committed" && entry.patches?.length)
|
|
537
|
+
this.optimisticReducerIds.add(entry.idempotencyKey);
|
|
538
|
+
}
|
|
539
|
+
let resubscribed = 0;
|
|
540
|
+
if (scope === this.outboxScope && replicaScope === this.replicaScope) {
|
|
541
|
+
for (const subscription of this.replicaSubscriptions.values())
|
|
542
|
+
if (subscription.listeners.size > 0)
|
|
543
|
+
resubscribed += 1;
|
|
544
|
+
for (const subscription of this.querySubscriptions.values())
|
|
545
|
+
if (subscription.listeners.size > 0)
|
|
546
|
+
resubscribed += 1;
|
|
547
|
+
this.resumeQuerySubscriptions(true);
|
|
548
|
+
this.resumeReplicaSubscriptions();
|
|
549
|
+
}
|
|
550
|
+
this.refreshIntents();
|
|
551
|
+
return { discardedIntents, resubscribed };
|
|
552
|
+
}));
|
|
553
|
+
}
|
|
554
|
+
/** Outbox owners with durable entries, including identities that never returned. */
|
|
555
|
+
async listOutboxScopes() {
|
|
556
|
+
await this.outboxReady;
|
|
557
|
+
const current = this.outboxScope;
|
|
558
|
+
return (await this.reducerOutbox.listScopes()).map((scope) => ({ ...scope, current: scope.scope === current }));
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Permanently delete another identity's durable intents. The active scope
|
|
562
|
+
* cannot be purged this way; use {@link discardIntent} for its entries.
|
|
563
|
+
*/
|
|
564
|
+
async purgeOutboxScope(scope) {
|
|
565
|
+
await this.outboxReady;
|
|
566
|
+
if (scope === this.outboxScope)
|
|
567
|
+
throw new Error("Cannot purge the active outbox scope");
|
|
568
|
+
return this.reducerOutbox.purgeScope(scope);
|
|
569
|
+
}
|
|
570
|
+
/** Purge every outbox scope except the current identity's. Never runs automatically. */
|
|
571
|
+
async purgeForeignOutboxScopes() {
|
|
572
|
+
let removed = 0;
|
|
573
|
+
for (const scope of await this.listOutboxScopes()) {
|
|
574
|
+
if (!scope.current)
|
|
575
|
+
removed += await this.purgeOutboxScope(scope.scope);
|
|
576
|
+
}
|
|
577
|
+
return removed;
|
|
578
|
+
}
|
|
579
|
+
refreshIntents() {
|
|
580
|
+
if (this.intentListeners.size === 0 || this.manuallyClosed)
|
|
581
|
+
return;
|
|
582
|
+
this.intentsRefreshDirty = true;
|
|
583
|
+
if (this.intentsRefreshRunning)
|
|
584
|
+
return;
|
|
585
|
+
this.intentsRefreshRunning = true;
|
|
586
|
+
void (async () => {
|
|
587
|
+
try {
|
|
588
|
+
while (this.intentsRefreshDirty && !this.manuallyClosed) {
|
|
589
|
+
this.intentsRefreshDirty = false;
|
|
590
|
+
await this.outboxReady.catch(() => undefined);
|
|
591
|
+
const scope = this.outboxScope;
|
|
592
|
+
const entries = await this.reducerOutbox.list(scope).catch(() => undefined);
|
|
593
|
+
if (!entries || scope !== this.outboxScope) {
|
|
594
|
+
this.intentsRefreshDirty ||= scope !== this.outboxScope;
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
this.publishIntents(entries.map(outboxIntentFromEntry));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
finally {
|
|
601
|
+
this.intentsRefreshRunning = false;
|
|
602
|
+
}
|
|
603
|
+
})();
|
|
604
|
+
}
|
|
605
|
+
publishIntents(next) {
|
|
606
|
+
if (sameIntents(this.intentsSnapshotValue, next))
|
|
607
|
+
return;
|
|
608
|
+
this.intentsSnapshotValue = next;
|
|
609
|
+
for (const listener of this.intentListeners) {
|
|
610
|
+
try {
|
|
611
|
+
listener();
|
|
612
|
+
}
|
|
613
|
+
catch { /* A listener must not break delivery. */ }
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
async restoreLocalSession(scope, generation) {
|
|
617
|
+
if (!this.localBinding || !scope.startsWith("identity\u0000"))
|
|
618
|
+
return;
|
|
619
|
+
const saved = await this.localStorage?.loadSession?.(scope);
|
|
620
|
+
if (!saved || generation !== this.outboxScopeGeneration || scope !== this.outboxScope || this.hasAuthoritativeReplicaScope)
|
|
621
|
+
return;
|
|
622
|
+
this.lastOnlineAtMs = saved.lastOnlineAtMs ?? 0;
|
|
623
|
+
this.localIdentity = saved.identity;
|
|
624
|
+
void this.localExecutor?.ready.catch(() => undefined);
|
|
625
|
+
await this.activateReplicaDirective(saved.directive);
|
|
626
|
+
this.replica.setFreshness("offline");
|
|
627
|
+
}
|
|
628
|
+
ensureLocalCollections(tables) {
|
|
629
|
+
if (!this.localBinding)
|
|
630
|
+
return;
|
|
631
|
+
const needed = new Set(tables);
|
|
632
|
+
for (const reference of this.localBinding.collections ?? []) {
|
|
633
|
+
if (!reference.replica || !needed.has(reference.replica.table))
|
|
634
|
+
continue;
|
|
635
|
+
const key = querySubscriptionKey(reference, {});
|
|
636
|
+
if (this.localCollectionKeys.has(key))
|
|
637
|
+
continue;
|
|
638
|
+
this.localCollectionKeys.add(key);
|
|
639
|
+
this.localCollectionClosers.push(this.subscribeReplicaTransport(reference, {}, () => undefined, true));
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
hydrateMissingLocalTable(error) {
|
|
643
|
+
if (!(error instanceof Error) || error.name !== "IncompleteReplicaError")
|
|
644
|
+
return;
|
|
645
|
+
// Worker errors preserve the public runtime name/message contract.
|
|
646
|
+
const table = /^Local replica for (.+?) is incomplete;/.exec(error.message)?.[1];
|
|
647
|
+
if (table)
|
|
648
|
+
this.ensureLocalCollections([table]);
|
|
649
|
+
}
|
|
650
|
+
localSnapshot(includePending) {
|
|
651
|
+
const rows = this.replica.captureExecutionRows(includePending);
|
|
652
|
+
const tables = {};
|
|
653
|
+
for (const [table, binding] of this.localTableBindings) {
|
|
654
|
+
const complete = binding.signatures.some(signature => this.replica.windowIsComplete(signature));
|
|
655
|
+
tables[table] = { complete, columns: binding.columns, get rows() { return rows(table); } };
|
|
656
|
+
}
|
|
657
|
+
return { scope: this.replicaScope, tables };
|
|
658
|
+
}
|
|
659
|
+
localReadCoverage() {
|
|
660
|
+
return this.replica.captureReadCoverage(Object.fromEntries([...this.localTableBindings].map(([table, binding]) => [table, {
|
|
661
|
+
key: this.localBinding.collections?.find(ref => ref.replica?.table === table)?.replica?.key ?? '_id',
|
|
662
|
+
complete: binding.signatures.some(signature => this.replica.windowIsComplete(signature)), columns: binding.columns,
|
|
663
|
+
}])));
|
|
664
|
+
}
|
|
665
|
+
isUncoveredLocalRead(error) {
|
|
666
|
+
if (!(error instanceof Error) || error.name !== 'IncompleteReplicaError')
|
|
667
|
+
return false;
|
|
668
|
+
const read = error.read;
|
|
669
|
+
if (!read || read.limit === 0)
|
|
670
|
+
return false;
|
|
671
|
+
const known = this.localReadCoverage()[read.table];
|
|
672
|
+
// Disk can recover evicted primary keys or a complete registered slice.
|
|
673
|
+
// It cannot prove a scan over data that has never been delivered. Queue
|
|
674
|
+
// that intent for replay instead of waiting for unrelated cache writes.
|
|
675
|
+
return !readIsCovered(known, read) && primaryReadKeys(read, known?.key ?? '_id') === undefined;
|
|
676
|
+
}
|
|
677
|
+
async executeLocal(path, args, snapshot, execution, pending = true, residentOnly = false) {
|
|
678
|
+
if (this.localBinding?.mode === 'portable') {
|
|
679
|
+
try {
|
|
680
|
+
const run = (source) => this.localExecutor.executeRead(path, args, source, execution);
|
|
681
|
+
let transaction;
|
|
682
|
+
try {
|
|
683
|
+
transaction = await this.replica.withReadView(() => this.localReadCoverage(), pending, run, true);
|
|
684
|
+
}
|
|
685
|
+
catch (error) {
|
|
686
|
+
if (residentOnly || !(error instanceof Error) || error.name !== 'IncompleteReplicaError' || this.isUncoveredLocalRead(error))
|
|
687
|
+
throw error;
|
|
688
|
+
transaction = await this.replica.withReadView(() => this.localReadCoverage(), pending, run);
|
|
689
|
+
}
|
|
690
|
+
// The read view already proved every read from the available replica
|
|
691
|
+
// windows or cached primary keys. Do not promote those bounded reads
|
|
692
|
+
// to whole-table subscriptions after a successful user edit. Missing
|
|
693
|
+
// coverage still opens its generated collection in the error path.
|
|
694
|
+
return transaction;
|
|
695
|
+
}
|
|
696
|
+
catch (error) {
|
|
697
|
+
// A speculative memory-only read may simply need IndexedDB. Its
|
|
698
|
+
// coordinated retry checks disk before deciding to request more data.
|
|
699
|
+
if (!residentOnly)
|
|
700
|
+
this.hydrateMissingLocalTable(error);
|
|
701
|
+
throw error;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (!snapshot)
|
|
705
|
+
throw new Error('Legacy local executor requires a snapshot');
|
|
706
|
+
const known = this.localExecutionTables.get(path);
|
|
707
|
+
const input = known ? { ...snapshot, tables: Object.fromEntries(Object.entries(snapshot.tables).filter(([table]) => known.has(table))) } : snapshot;
|
|
708
|
+
let transaction;
|
|
709
|
+
try {
|
|
710
|
+
transaction = await this.localExecutor.execute(path, args, input, execution);
|
|
711
|
+
}
|
|
712
|
+
catch (error) {
|
|
713
|
+
this.hydrateMissingLocalTable(error);
|
|
714
|
+
if (!known || !(error instanceof Error) || error.name !== "IncompleteReplicaError")
|
|
715
|
+
throw error;
|
|
716
|
+
// A different argument, tenant or workflow can read different tables.
|
|
717
|
+
// Retry against the SAME captured snapshot, never newer mixed-version rows.
|
|
718
|
+
try {
|
|
719
|
+
transaction = await this.localExecutor.execute(path, args, snapshot, execution);
|
|
720
|
+
}
|
|
721
|
+
catch (retryError) {
|
|
722
|
+
this.hydrateMissingLocalTable(retryError);
|
|
723
|
+
throw retryError;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
this.ensureLocalCollections(transaction.readTables);
|
|
727
|
+
this.localExecutionTables.set(path, new Set([
|
|
728
|
+
...(known ?? []), ...transaction.readTables, ...transaction.patches.map(patch => patch.entity),
|
|
729
|
+
]));
|
|
730
|
+
return transaction;
|
|
731
|
+
}
|
|
732
|
+
inLocalLane(run) {
|
|
733
|
+
const job = this.localLane.then(run);
|
|
734
|
+
this.localLane = job.catch(() => undefined);
|
|
735
|
+
return job;
|
|
736
|
+
}
|
|
737
|
+
replaceLocalPredictions(commands) {
|
|
738
|
+
this.replica.replaceOptimistic(commands);
|
|
739
|
+
}
|
|
740
|
+
coordinateOutbox(lane, run) {
|
|
741
|
+
return this.sharedOutboxStore?.coordinate?.(this.outboxScope, lane, run) ?? run();
|
|
742
|
+
}
|
|
743
|
+
refreshPeerOutbox() {
|
|
744
|
+
if (this.manuallyClosed)
|
|
745
|
+
return;
|
|
746
|
+
this.peerRefreshDirty = true;
|
|
747
|
+
if (this.peerRefreshScheduled)
|
|
748
|
+
return;
|
|
749
|
+
this.peerRefreshScheduled = true;
|
|
750
|
+
void (async () => {
|
|
751
|
+
await this.outboxReady;
|
|
752
|
+
do {
|
|
753
|
+
this.peerRefreshDirty = false;
|
|
754
|
+
await this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
|
|
755
|
+
const scope = this.outboxScope;
|
|
756
|
+
await this.replica.synchronizeStorage();
|
|
757
|
+
const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
|
|
758
|
+
if (this.manuallyClosed || scope !== this.outboxScope)
|
|
759
|
+
return;
|
|
760
|
+
this.replaceLocalPredictions(entries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
|
|
761
|
+
this.optimisticReducerIds.clear();
|
|
762
|
+
this.optimisticOutboxEntryIds.clear();
|
|
763
|
+
for (const entry of entries) {
|
|
764
|
+
this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
|
|
765
|
+
if (entry.patches?.length)
|
|
766
|
+
this.optimisticReducerIds.add(entry.idempotencyKey);
|
|
767
|
+
}
|
|
768
|
+
}));
|
|
769
|
+
} while (this.peerRefreshDirty && !this.manuallyClosed);
|
|
770
|
+
this.refreshIntents();
|
|
771
|
+
void this.drainOutbox();
|
|
772
|
+
})().catch(() => undefined).finally(() => { this.peerRefreshScheduled = false; });
|
|
773
|
+
}
|
|
774
|
+
scheduleLocalReplay() {
|
|
775
|
+
if (!this.localExecutor || this.replacingLocal || this.localReplayScheduled || this.manuallyClosed || !this.localIdentity)
|
|
776
|
+
return;
|
|
777
|
+
// Restore and peer notifications discover durable intents explicitly. A
|
|
778
|
+
// table arriving at startup cannot invalidate a prediction that does not exist.
|
|
779
|
+
if (this.optimisticOutboxEntryIds.size === 0)
|
|
780
|
+
return;
|
|
781
|
+
this.localReplayScheduled = true;
|
|
782
|
+
queueMicrotask(() => {
|
|
783
|
+
void (async () => {
|
|
784
|
+
await this.outboxReady;
|
|
785
|
+
await this.inLocalLane(() => this.rebaseLocalEntries());
|
|
786
|
+
})().catch(() => undefined).finally(() => { this.localReplayScheduled = false; });
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
reportLocalRejection(reducerId, path, error) {
|
|
790
|
+
const message = reducerErrorMessage(error);
|
|
791
|
+
for (const listener of this.reducerRejectionHandlers) {
|
|
792
|
+
try {
|
|
793
|
+
listener({ reducerId, path, error: message, errorClass: "rejected" });
|
|
794
|
+
}
|
|
795
|
+
catch { /* A listener must not break delivery. */ }
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
rebaseLocalEntries(afterRejection = false) {
|
|
799
|
+
return this.coordinateOutbox("intent", () => this.rebaseLocalEntriesLocked(afterRejection));
|
|
800
|
+
}
|
|
801
|
+
async rebaseLocalEntriesLocked(afterRejection = false) {
|
|
802
|
+
if (!this.localExecutor || !this.localBinding || !this.localIdentity || !this.hasAuthoritativeReplicaScope)
|
|
803
|
+
return;
|
|
804
|
+
// The authoritative drain validates the pending chain in order. Keep its
|
|
805
|
+
// predictions while it progresses, then rebase once it settles or loses
|
|
806
|
+
// connectivity instead of replaying N + (N-1) + ... local transactions.
|
|
807
|
+
if (!afterRejection && this.drainingOutbox && this.canSendReducerNow())
|
|
808
|
+
return;
|
|
809
|
+
await this.replica.synchronizeStorage();
|
|
810
|
+
const scope = this.outboxScope;
|
|
811
|
+
const replicaScope = this.replicaScope;
|
|
812
|
+
// Rejected records keep their payload for the UI but are never predicted.
|
|
813
|
+
// Failed (parked) intents stay predicted until retried or discarded.
|
|
814
|
+
const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
|
|
815
|
+
if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
|
|
816
|
+
return;
|
|
817
|
+
if (entries.length === 0) {
|
|
818
|
+
this.replacingLocal = true;
|
|
819
|
+
try {
|
|
820
|
+
this.replaceLocalPredictions([]);
|
|
821
|
+
}
|
|
822
|
+
finally {
|
|
823
|
+
this.replacingLocal = false;
|
|
824
|
+
}
|
|
825
|
+
this.optimisticReducerIds.clear();
|
|
826
|
+
this.optimisticOutboxEntryIds.clear();
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
const baseVersion = this.replica.executionVersion();
|
|
830
|
+
const needsExecution = entries.some(entry => entry.localExecution && entry.state !== "inflight" && entry.state !== "committed");
|
|
831
|
+
const snapshot = needsExecution && this.localBinding.mode !== 'portable' ? this.localSnapshot(false) : undefined;
|
|
832
|
+
const replayPatches = [];
|
|
833
|
+
const commands = [];
|
|
834
|
+
for (const entry of entries) {
|
|
835
|
+
// Predictions can wait; a fresh user intent must not sit behind the
|
|
836
|
+
// entire durable backlog. Publish only a complete, coherent rebase.
|
|
837
|
+
if (this.waitingLocalEdits > 0) {
|
|
838
|
+
setTimeout(() => this.scheduleLocalReplay(), 0);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
let patches = entry.patches ?? [];
|
|
842
|
+
if (entry.localExecution && entry.state !== "inflight" && entry.state !== "committed") {
|
|
843
|
+
const execution = { ...entry.localExecution, scope: replicaScope, identity: this.localIdentity, artifactHash: this.localBinding.artifactHash };
|
|
844
|
+
try {
|
|
845
|
+
const transaction = await this.executeLocal(entry.path, entry.args, snapshot, execution, replayPatches);
|
|
846
|
+
patches = transaction.patches;
|
|
847
|
+
if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
catch (error) {
|
|
851
|
+
// Re-execution is a prediction. Only an authoritative server rejection
|
|
852
|
+
// removes a durable intent; missing data or local validation can make
|
|
853
|
+
// its predicted transaction unavailable without rejecting the intent.
|
|
854
|
+
if (patches.length && scope === this.outboxScope && replicaScope === this.replicaScope)
|
|
855
|
+
this.reportLocalFallback(entry.path, entry.idempotencyKey, "replay", error);
|
|
856
|
+
patches = [];
|
|
857
|
+
}
|
|
858
|
+
if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
|
|
859
|
+
return;
|
|
860
|
+
// Storage failures must abort rebasing, never masquerade as rejection.
|
|
861
|
+
await this.reducerOutbox.updateLocal(entry.id, patches, execution);
|
|
862
|
+
}
|
|
863
|
+
if (snapshot)
|
|
864
|
+
applyLocalPatches(snapshot, patches);
|
|
865
|
+
for (const patch of patches)
|
|
866
|
+
replayPatches.push(patch.op === 'delete'
|
|
867
|
+
? { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: 'delete' }
|
|
868
|
+
: { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: patch.op === 'upsert' ? 'insert' : patch.op, fields: patch.fields });
|
|
869
|
+
commands.push({ commandId: entry.idempotencyKey, patches });
|
|
870
|
+
}
|
|
871
|
+
if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
|
|
872
|
+
return;
|
|
873
|
+
if (baseVersion !== this.replica.executionVersion()) {
|
|
874
|
+
// A changing server base must not recursively occupy the local lane.
|
|
875
|
+
// Release it so user intents already queued can run before the retry.
|
|
876
|
+
setTimeout(() => this.scheduleLocalReplay(), 0);
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
this.replacingLocal = true;
|
|
880
|
+
try {
|
|
881
|
+
this.replaceLocalPredictions(commands);
|
|
882
|
+
}
|
|
883
|
+
finally {
|
|
884
|
+
this.replacingLocal = false;
|
|
885
|
+
}
|
|
886
|
+
this.optimisticReducerIds.clear();
|
|
887
|
+
this.optimisticOutboxEntryIds.clear();
|
|
888
|
+
for (const entry of entries) {
|
|
889
|
+
this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
|
|
890
|
+
}
|
|
891
|
+
for (const command of commands)
|
|
892
|
+
if (command.patches.length)
|
|
893
|
+
this.optimisticReducerIds.add(command.commandId);
|
|
894
|
+
}
|
|
895
|
+
async runLocalReducer(ref, args) {
|
|
896
|
+
args = structuredClone(args);
|
|
897
|
+
const admittedGeneration = this.outboxScopeGeneration;
|
|
898
|
+
await this.outboxReady;
|
|
899
|
+
await this.replicaReady;
|
|
900
|
+
if (!this.localExecutor || !this.localBinding)
|
|
901
|
+
throw new Error("This reducer requires the generated Gonvex client with local execution enabled");
|
|
902
|
+
const reducerId = randomID();
|
|
903
|
+
const execution = this.localIdentity ? {
|
|
904
|
+
scope: this.replicaScope, commandId: reducerId, now: Date.now(),
|
|
905
|
+
intentEntropy: Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join(''),
|
|
906
|
+
artifactHash: this.localBinding.artifactHash, identity: structuredClone(this.localIdentity),
|
|
907
|
+
} : undefined;
|
|
908
|
+
let preparedResult;
|
|
909
|
+
let preparedFailure;
|
|
910
|
+
const prepared = this.preparationLane.then(async () => {
|
|
911
|
+
if (!execution || this.localBinding?.mode !== 'portable' || !this.hasAuthoritativeReplicaScope || this.manuallyClosed || this.updateRequired || admittedGeneration !== this.outboxScopeGeneration)
|
|
912
|
+
return;
|
|
913
|
+
if (this.clientContract && this.clientContract.offlineMaxAgeMs !== null && !this.canSendReducerNow() && (Date.now() - this.lastOnlineAtMs > this.clientContract.offlineMaxAgeMs || Date.now() < this.lastOnlineAtMs))
|
|
914
|
+
return;
|
|
915
|
+
const base = this.replica.executionVersion();
|
|
916
|
+
const pending = this.replica.pendingReducerPatches();
|
|
917
|
+
try {
|
|
918
|
+
const transaction = await this.executeLocal(ref.path, args, undefined, execution, pending, true);
|
|
919
|
+
if (base !== this.replica.executionVersion() || admittedGeneration !== this.outboxScopeGeneration || this.manuallyClosed)
|
|
920
|
+
return;
|
|
921
|
+
preparedResult = { transaction, base, pending: JSON.stringify(pending) };
|
|
922
|
+
}
|
|
923
|
+
catch (error) {
|
|
924
|
+
preparedFailure = error; /* Recheck coverage under coordinated admission. */
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
this.preparationLane = prepared.catch(() => undefined);
|
|
928
|
+
this.waitingLocalEdits += 1;
|
|
929
|
+
let entered = false;
|
|
930
|
+
return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
|
|
931
|
+
entered = true;
|
|
932
|
+
this.waitingLocalEdits -= 1;
|
|
933
|
+
await prepared;
|
|
934
|
+
if (admittedGeneration !== this.outboxScopeGeneration)
|
|
935
|
+
throw new GonvexClientError("Session changed before local reducer execution", { code: "superseded" });
|
|
936
|
+
if (!this.localIdentity || !this.hasAuthoritativeReplicaScope)
|
|
937
|
+
throw new Error("Restore an authenticated local session before editing offline");
|
|
938
|
+
if (this.manuallyClosed)
|
|
939
|
+
throw new Error("Gonvex client is closed");
|
|
940
|
+
if (this.updateRequired)
|
|
941
|
+
throw new Error("Application update required before editing");
|
|
942
|
+
if (this.clientContract && this.clientContract.offlineMaxAgeMs !== null && !this.canSendReducerNow() && (Date.now() - this.lastOnlineAtMs > this.clientContract.offlineMaxAgeMs || Date.now() < this.lastOnlineAtMs))
|
|
943
|
+
throw new Error("Offline editing window expired. Reconnect before making more changes.");
|
|
944
|
+
const scope = this.outboxScope;
|
|
945
|
+
if (!execution || execution.scope !== this.replicaScope)
|
|
946
|
+
throw new GonvexClientError("Session changed before local reducer execution", { code: 'superseded' });
|
|
947
|
+
const sharedEntries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
|
|
948
|
+
const sharedPatches = sharedEntries?.filter(entry => entry.state !== "committed").flatMap(entry => (entry.patches ?? []).map(patch => patch.op === "delete"
|
|
949
|
+
? { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: "delete" }
|
|
950
|
+
: { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: patch.op === "upsert" ? "insert" : patch.op, fields: patch.fields }));
|
|
951
|
+
// A fully resident prediction reads the already published atomic replica.
|
|
952
|
+
// Unrelated snapshots may still be persisting; waiting for those here
|
|
953
|
+
// makes a small edit inherit their disk latency. The shared intent lock
|
|
954
|
+
// and journal still order every edit, and known peer changes must be
|
|
955
|
+
// reconciled before a resident prediction can be reused.
|
|
956
|
+
const reuseResident = preparedResult
|
|
957
|
+
&& preparedResult.base === this.replica.executionVersion()
|
|
958
|
+
&& preparedResult.pending === JSON.stringify(sharedPatches)
|
|
959
|
+
&& !this.peerRefreshDirty && !this.peerRefreshScheduled
|
|
960
|
+
&& !sharedEntries.some(entry => entry.state === "committed");
|
|
961
|
+
const uncoveredRead = this.isUncoveredLocalRead(preparedFailure)
|
|
962
|
+
&& !this.peerRefreshDirty && !this.peerRefreshScheduled
|
|
963
|
+
&& !sharedEntries.some(entry => entry.state === "committed");
|
|
964
|
+
if (!reuseResident && !uncoveredRead)
|
|
965
|
+
await this.replica.synchronizeStorage();
|
|
966
|
+
if (sharedEntries)
|
|
967
|
+
this.replaceLocalPredictions(sharedEntries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
|
|
968
|
+
const executionBaseVersion = this.replica.executionVersion();
|
|
969
|
+
let transaction;
|
|
970
|
+
let fallback;
|
|
971
|
+
try {
|
|
972
|
+
if (preparedResult && preparedResult.base === executionBaseVersion && preparedResult.pending === JSON.stringify(sharedPatches)) {
|
|
973
|
+
transaction = preparedResult.transaction;
|
|
974
|
+
}
|
|
975
|
+
else if (this.localBinding.mode === 'portable') {
|
|
976
|
+
// A commit may invalidate preparation while the intent lock is being
|
|
977
|
+
// acquired. Read the newly published atomic replica again before
|
|
978
|
+
// joining disk work that arrived after synchronizeStorage completed.
|
|
979
|
+
try {
|
|
980
|
+
transaction = await this.executeLocal(ref.path, args, undefined, execution, sharedPatches ?? true, true);
|
|
981
|
+
}
|
|
982
|
+
catch (error) {
|
|
983
|
+
if (!(error instanceof Error) || error.name !== 'IncompleteReplicaError')
|
|
984
|
+
throw error;
|
|
985
|
+
transaction = await this.executeLocal(ref.path, args, undefined, execution, sharedPatches ?? true);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
else {
|
|
989
|
+
transaction = await this.executeLocal(ref.path, args, this.localSnapshot(true), execution, sharedPatches ?? true);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
catch (error) {
|
|
993
|
+
if (!(error instanceof Error) || error.name !== "IncompleteReplicaError")
|
|
994
|
+
throw error;
|
|
995
|
+
fallback = error;
|
|
996
|
+
}
|
|
997
|
+
if (scope !== this.outboxScope || execution.scope !== this.replicaScope)
|
|
998
|
+
throw new GonvexClientError("Session changed during local reducer execution", { code: "superseded" });
|
|
999
|
+
const patches = transaction?.patches ?? [];
|
|
1000
|
+
this.directOutboxReducerIds.add(reducerId);
|
|
1001
|
+
try {
|
|
1002
|
+
// A visible prediction must already have a durable intent. Otherwise a
|
|
1003
|
+
// reload immediately after paint can lose an offline edit. Local
|
|
1004
|
+
// execution still avoids a server round trip; disk admission is bounded
|
|
1005
|
+
// to this intent, rather than persisting the whole replica.
|
|
1006
|
+
const entry = await this.reducerOutbox.enqueue({
|
|
1007
|
+
scope, path: ref.path, args, idempotencyKey: reducerId, state: "pending",
|
|
1008
|
+
// All local intents form one causal chain, including reads of rows
|
|
1009
|
+
// written by a previous intent, not only overlapping write patches.
|
|
1010
|
+
entityKeys: ["__gonvex_local_intents"], patches, localExecution: execution,
|
|
1011
|
+
});
|
|
1012
|
+
if (scope !== this.outboxScope || execution.scope !== this.replicaScope) {
|
|
1013
|
+
throw new GonvexClientError("Session changed while persisting local reducer", { code: "superseded" });
|
|
1014
|
+
}
|
|
1015
|
+
this.optimisticOutboxEntryIds.set(reducerId, entry.id);
|
|
1016
|
+
this.replacingLocal = true;
|
|
1017
|
+
try {
|
|
1018
|
+
this.addOptimisticReducer(reducerId, patches);
|
|
1019
|
+
}
|
|
1020
|
+
finally {
|
|
1021
|
+
this.replacingLocal = false;
|
|
1022
|
+
}
|
|
1023
|
+
if (!transaction) {
|
|
1024
|
+
this.reportLocalFallback(ref.path, reducerId, "call", fallback);
|
|
1025
|
+
return { status: "queued", reducerId };
|
|
1026
|
+
}
|
|
1027
|
+
return transaction.result;
|
|
1028
|
+
}
|
|
1029
|
+
catch (error) {
|
|
1030
|
+
this.optimisticReducerIds.delete(reducerId);
|
|
1031
|
+
this.replica.rejectCommand(reducerId);
|
|
1032
|
+
throw error;
|
|
1033
|
+
}
|
|
1034
|
+
finally {
|
|
1035
|
+
this.directOutboxReducerIds.delete(reducerId);
|
|
1036
|
+
// This transaction already executed against all earlier predictions.
|
|
1037
|
+
// Appending it does not invalidate those predictions. Rebase only if
|
|
1038
|
+
// the authoritative execution snapshot changed during execution.
|
|
1039
|
+
// An uncovered read has no new data to replay against yet. Its
|
|
1040
|
+
// hydration subscription schedules replay when coverage arrives;
|
|
1041
|
+
// immediately retrying here only locks edits behind the same disk work.
|
|
1042
|
+
if (this.replica.executionVersion() !== executionBaseVersion)
|
|
1043
|
+
this.scheduleLocalReplay();
|
|
1044
|
+
void this.drainOutbox();
|
|
1045
|
+
}
|
|
1046
|
+
})).catch(error => {
|
|
1047
|
+
if (!entered)
|
|
1048
|
+
this.waitingLocalEdits -= 1;
|
|
1049
|
+
this.optimisticReducerIds.delete(reducerId);
|
|
1050
|
+
this.replica.rejectCommand(reducerId);
|
|
1051
|
+
throw error;
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
/** Preload code for a mounted control without reading data or executing an intent. */
|
|
1055
|
+
prepareReducer(ref) {
|
|
1056
|
+
if (ref.localExecution !== 1 || this.manuallyClosed)
|
|
1057
|
+
return Promise.resolve();
|
|
1058
|
+
return this.localExecutor?.prepare?.(ref.path) ?? Promise.resolve();
|
|
232
1059
|
}
|
|
233
1060
|
/** The single normalized authoritative + optimistic application data store. */
|
|
234
1061
|
get localReplica() {
|
|
@@ -245,19 +1072,33 @@ export class GonvexClient {
|
|
|
245
1072
|
replicaEntities(entity, ids) {
|
|
246
1073
|
return this.replica.entityBatch(entity, ids);
|
|
247
1074
|
}
|
|
1075
|
+
/** Retain only the data an active view observes; cold rows remain on disk. */
|
|
1076
|
+
retainReplicaEntities(entity, ids) { return this.replica.retainRows(entity, ids); }
|
|
1077
|
+
retainReplicaWindow(signature) { return this.replica.retainWindow(signature); }
|
|
248
1078
|
/** Read rows and server-owned completeness for a persisted Replica Collection. */
|
|
249
1079
|
replicaCollectionState(ref, args = {}) {
|
|
250
1080
|
return this.replica.collectionState(this.replicaSignature(ref, args));
|
|
251
1081
|
}
|
|
252
1082
|
/** Run the generated Live Query plan over the bounded normalized cache. */
|
|
253
|
-
offlineLiveQuery(ref, args = {}) {
|
|
1083
|
+
offlineLiveQuery(ref, args = {}, options = {}) {
|
|
254
1084
|
if (!ref.live?.plan) {
|
|
255
1085
|
return { rows: [], completeness: "partial", supported: false, unsupportedOperator: "missingPlan" };
|
|
256
1086
|
}
|
|
257
1087
|
const queryArgs = isJsonRecord(args) ? args : {};
|
|
258
|
-
return runOfflineLiveQuery(this.replica.entityRows(ref.live.entity), ref.live.plan, queryArgs, this.replica.entityCompleteness(ref.live.entity))
|
|
1088
|
+
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), {
|
|
1089
|
+
context: {
|
|
1090
|
+
...(this.localIdentity?.member?.id ? { 'member.id': this.localIdentity.member.id } : {}),
|
|
1091
|
+
...(this.localIdentity?.auth.account?.id ? { 'account.id': this.localIdentity.auth.account.id } : {}),
|
|
1092
|
+
...(this.localIdentity?.tenant?.id ? { 'tenant.id': this.localIdentity.tenant.id } : {}),
|
|
1093
|
+
},
|
|
1094
|
+
relationRows: (table, where) => {
|
|
1095
|
+
const coverage = this.replica.captureReadCoverage({ [table]: { key: '_id', complete: false } });
|
|
1096
|
+
return { rows: this.replica.entityRows(table), completeness: this.replica.entityCompleteness(table) === 'complete'
|
|
1097
|
+
&& readIsCovered(coverage[table], { table, where: liveScopeCoverage(where, queryArgs) }) ? 'complete' : 'partial' };
|
|
1098
|
+
},
|
|
1099
|
+
});
|
|
259
1100
|
}
|
|
260
|
-
/** Number of reducers
|
|
1101
|
+
/** Number of reducers still queued for delivery (excludes failed and rejected intents). */
|
|
261
1102
|
async outboxCount() {
|
|
262
1103
|
await this.outboxReady;
|
|
263
1104
|
return this.reducerOutbox.count(this.outboxScope);
|
|
@@ -374,7 +1215,24 @@ export class GonvexClient {
|
|
|
374
1215
|
this.authErrorHandlers.delete(handler);
|
|
375
1216
|
};
|
|
376
1217
|
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Subscribe to rotated developer-mode reconnect credentials. The runtime
|
|
1220
|
+
* consumes each developer credential when it accepts a connection and
|
|
1221
|
+
* returns a single-use successor; the client already uses that successor
|
|
1222
|
+
* for its own reconnects. Auth providers subscribe so they can keep the
|
|
1223
|
+
* newest credential in tab-scoped storage and resume developer mode after a
|
|
1224
|
+
* page reload. The value is a secret: never log it or put it in a URL.
|
|
1225
|
+
*/
|
|
1226
|
+
onDeveloperCredential(handler) {
|
|
1227
|
+
this.developerCredentialHandlers.add(handler);
|
|
1228
|
+
return () => {
|
|
1229
|
+
this.developerCredentialHandlers.delete(handler);
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
377
1232
|
applyAuth(auth) {
|
|
1233
|
+
if (this.publicTenant && (auth.token || auth.fetchToken || auth.identity || auth.tenant !== undefined && auth.tenant !== this.publicTenant || auth.project !== undefined && auth.project !== this.auth.project)) {
|
|
1234
|
+
throw new Error("Public connection scope is fixed; create another client to sign in or change tenants");
|
|
1235
|
+
}
|
|
378
1236
|
const nextAuth = { ...this.auth, ...auth };
|
|
379
1237
|
const tokenScopeChanged = hasOwn(auth, "token")
|
|
380
1238
|
&& auth.token !== this.auth.token
|
|
@@ -416,12 +1274,20 @@ export class GonvexClient {
|
|
|
416
1274
|
}
|
|
417
1275
|
}
|
|
418
1276
|
connect() {
|
|
1277
|
+
if (this.updateRequired)
|
|
1278
|
+
return;
|
|
419
1279
|
if (this.socket && this.socket.readyState <= WebSocket.OPEN)
|
|
420
1280
|
return;
|
|
421
1281
|
const isReconnect = this.socket !== undefined;
|
|
422
1282
|
this.manuallyClosed = false;
|
|
423
1283
|
const generation = ++this.socketGeneration;
|
|
424
|
-
|
|
1284
|
+
let transportUrl = this.url;
|
|
1285
|
+
if (this.publicTenant) {
|
|
1286
|
+
const publicUrl = new URL(this.url);
|
|
1287
|
+
publicUrl.searchParams.set("public", "1");
|
|
1288
|
+
transportUrl = publicUrl.toString();
|
|
1289
|
+
}
|
|
1290
|
+
const socket = new WebSocket(transportUrl);
|
|
425
1291
|
this.socket = socket;
|
|
426
1292
|
socket.addEventListener("open", () => {
|
|
427
1293
|
if (this.socket !== socket)
|
|
@@ -504,6 +1370,10 @@ export class GonvexClient {
|
|
|
504
1370
|
}
|
|
505
1371
|
return;
|
|
506
1372
|
}
|
|
1373
|
+
if (message.type === "client.updateRequired") {
|
|
1374
|
+
this.requireClientUpdate(message.reason);
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
507
1377
|
if (message.type === "system.reload") {
|
|
508
1378
|
if (typeof message.artifactHash === "string") {
|
|
509
1379
|
this.activeArtifactHashValue = message.artifactHash;
|
|
@@ -537,7 +1407,7 @@ export class GonvexClient {
|
|
|
537
1407
|
this.sendAuth(true, { useFetcher: false });
|
|
538
1408
|
return;
|
|
539
1409
|
}
|
|
540
|
-
const fetcher = this.auth.fetchToken;
|
|
1410
|
+
const fetcher = this.publicTenant ? undefined : this.auth.fetchToken;
|
|
541
1411
|
if (fetcher) {
|
|
542
1412
|
this.authInFlight = true;
|
|
543
1413
|
this.authRetriedAfterError = true;
|
|
@@ -577,6 +1447,8 @@ export class GonvexClient {
|
|
|
577
1447
|
this.activeAuthFrameId = message.id;
|
|
578
1448
|
else
|
|
579
1449
|
this.activeAuthFrameId = undefined;
|
|
1450
|
+
if (message.type === "auth.result")
|
|
1451
|
+
this.flushPendingNativeErrors();
|
|
580
1452
|
if (this.authWatchdogTimer) {
|
|
581
1453
|
clearTimeout(this.authWatchdogTimer);
|
|
582
1454
|
this.authWatchdogTimer = undefined;
|
|
@@ -584,18 +1456,27 @@ export class GonvexClient {
|
|
|
584
1456
|
if (message.type === "auth.result") {
|
|
585
1457
|
const reauthenticatedSameSocket = this.authenticatedSocketGeneration === this.socketGeneration;
|
|
586
1458
|
this.authenticatedSocketGeneration = this.socketGeneration;
|
|
1459
|
+
this.lastOnlineAtMs = Date.now();
|
|
587
1460
|
this.activeArtifactHashValue = artifactHashFromAuthResult(message.result) ?? this.activeArtifactHashValue;
|
|
588
1461
|
const developerSessionToken = developerSessionTokenFromAuthResult(message.result);
|
|
589
1462
|
if (developerSessionToken) {
|
|
590
|
-
// The activation token is single-use.
|
|
591
|
-
//
|
|
1463
|
+
// The activation token is single-use. Use its rotating successor
|
|
1464
|
+
// for the next reconnect, and hand it to the provider that owns
|
|
1465
|
+
// developer mode so a reload can resume the same grant.
|
|
592
1466
|
this.auth = { ...this.auth, token: developerSessionToken, fetchToken: undefined };
|
|
1467
|
+
for (const handler of Array.from(this.developerCredentialHandlers))
|
|
1468
|
+
handler(developerSessionToken);
|
|
593
1469
|
}
|
|
594
1470
|
this.authRetriedAfterError = false;
|
|
595
1471
|
const directive = replicaDirectiveFromAuthResult(message.result);
|
|
1472
|
+
const localIdentity = localIdentityFromAuthResult(message.result);
|
|
1473
|
+
if (localIdentity) {
|
|
1474
|
+
this.localIdentity = localIdentity;
|
|
1475
|
+
void this.localExecutor?.ready.catch(() => undefined);
|
|
1476
|
+
}
|
|
596
1477
|
if (!directive) {
|
|
597
1478
|
this.authInFlight = false;
|
|
598
|
-
if (!this.auth.tenant) {
|
|
1479
|
+
if (!this.auth.tenant || this.publicTenant) {
|
|
599
1480
|
this.resumeQuerySubscriptions(reauthenticatedSameSocket);
|
|
600
1481
|
this.settleManagedAuthAttempt(message.id);
|
|
601
1482
|
}
|
|
@@ -612,6 +1493,12 @@ export class GonvexClient {
|
|
|
612
1493
|
}
|
|
613
1494
|
void this.activateReplicaDirective(directive)
|
|
614
1495
|
.then(() => this.activateOutboxScope())
|
|
1496
|
+
.then(async () => {
|
|
1497
|
+
if (localIdentity && this.localBinding)
|
|
1498
|
+
await this.localStorage?.saveSession?.(this.outboxScope, {
|
|
1499
|
+
directive, identity: localIdentity, artifactHash: this.activeArtifactHashValue ?? this.localBinding.artifactHash, lastOnlineAtMs: this.lastOnlineAtMs,
|
|
1500
|
+
});
|
|
1501
|
+
})
|
|
615
1502
|
.then(() => {
|
|
616
1503
|
// The accepted server identity is not usable until its durable
|
|
617
1504
|
// Replica partition has been activated locally. Only now may
|
|
@@ -725,6 +1612,11 @@ export class GonvexClient {
|
|
|
725
1612
|
}
|
|
726
1613
|
close() {
|
|
727
1614
|
this.manuallyClosed = true;
|
|
1615
|
+
this.unsubscribeLocal?.();
|
|
1616
|
+
for (const close of this.localCollectionClosers.splice(0))
|
|
1617
|
+
close();
|
|
1618
|
+
this.localCollectionKeys.clear();
|
|
1619
|
+
this.localExecutor?.close();
|
|
728
1620
|
this.cancelManagedAuthAttempt("Gonvex client was closed during authentication.");
|
|
729
1621
|
if (isEphemeralOutboxScope(this.outboxScope)) {
|
|
730
1622
|
void this.reducerOutbox.clear(this.outboxScope);
|
|
@@ -760,12 +1652,15 @@ export class GonvexClient {
|
|
|
760
1652
|
this.outboxDrainTimer = undefined;
|
|
761
1653
|
}
|
|
762
1654
|
this.unsubscribeOutbox();
|
|
1655
|
+
this.unsubscribePeerOutbox?.();
|
|
1656
|
+
this.unsubscribePeerReplica?.();
|
|
763
1657
|
this.unsubscribeBrowserOnline?.();
|
|
764
1658
|
this.handlers.clear();
|
|
765
1659
|
this.querySubscriptions.clear();
|
|
766
1660
|
this.replicaSubscriptions.clear();
|
|
767
1661
|
this.sessionScopeHandlers.clear();
|
|
768
1662
|
this.authErrorHandlers.clear();
|
|
1663
|
+
this.developerCredentialHandlers.clear();
|
|
769
1664
|
// Invalidate any token fetch still in flight so its resolve can't touch
|
|
770
1665
|
// the closed client's caches.
|
|
771
1666
|
this.authFetchGeneration += 1;
|
|
@@ -777,6 +1672,10 @@ export class GonvexClient {
|
|
|
777
1672
|
this.notifyConnectionState();
|
|
778
1673
|
this.connectionStateHandlers.clear();
|
|
779
1674
|
this.supportCommandHandlers.clear();
|
|
1675
|
+
this.replica.dispose();
|
|
1676
|
+
const closedError = new Error("Gonvex client is closed");
|
|
1677
|
+
for (const pending of this.pendingNativeErrors.splice(0))
|
|
1678
|
+
pending.reject(closedError);
|
|
780
1679
|
if (!socket)
|
|
781
1680
|
return;
|
|
782
1681
|
socket.close();
|
|
@@ -1110,7 +2009,9 @@ export class GonvexClient {
|
|
|
1110
2009
|
// could overwrite a result already accepted on the same connection.
|
|
1111
2010
|
return subscription.revisionSocketGeneration !== this.socketGeneration;
|
|
1112
2011
|
}
|
|
1113
|
-
subscribeReplicaTransport(ref, args = {}, onMessage) {
|
|
2012
|
+
subscribeReplicaTransport(ref, args = {}, onMessage, metadataOnly = false) {
|
|
2013
|
+
if (metadataOnly)
|
|
2014
|
+
this.replicaMetadataListeners.add(onMessage);
|
|
1114
2015
|
this.connect();
|
|
1115
2016
|
const key = querySubscriptionKey(ref, args);
|
|
1116
2017
|
const existing = this.replicaSubscriptions.get(key);
|
|
@@ -1120,10 +2021,14 @@ export class GonvexClient {
|
|
|
1120
2021
|
existing.unsubscribeTimer = undefined;
|
|
1121
2022
|
}
|
|
1122
2023
|
existing.listeners.add(onMessage);
|
|
2024
|
+
// A reset may arrive during the retention grace period with no listeners.
|
|
2025
|
+
// Its reopen was correctly deferred; the next subscriber must resume it.
|
|
2026
|
+
if (!existing.isUpToDate && !existing.opening)
|
|
2027
|
+
this.sendReplicaOpen(existing);
|
|
1123
2028
|
if (existing.lastMessage) {
|
|
1124
2029
|
queueMicrotask(() => {
|
|
1125
2030
|
if (existing.listeners.has(onMessage) && existing.lastMessage) {
|
|
1126
|
-
onMessage(this.materializeReplicaMessage(existing, existing.lastMessage));
|
|
2031
|
+
onMessage(metadataOnly ? existing.lastMessage : this.materializeReplicaMessage(existing, existing.lastMessage));
|
|
1127
2032
|
}
|
|
1128
2033
|
});
|
|
1129
2034
|
}
|
|
@@ -1161,12 +2066,16 @@ export class GonvexClient {
|
|
|
1161
2066
|
return this.subscribeReplicaTransport(ref, args, onMessage);
|
|
1162
2067
|
}
|
|
1163
2068
|
/** Watch a bounded Replica Collection through the normalized Local Replica. */
|
|
1164
|
-
watchReplica(ref, args = {}) {
|
|
2069
|
+
watchReplica(ref, args = {}, options = {}) {
|
|
1165
2070
|
const key = querySubscriptionKey(ref, args);
|
|
1166
2071
|
const updateHandlers = new Set();
|
|
1167
2072
|
let latestError;
|
|
1168
2073
|
let snapshotVersion = -1;
|
|
1169
2074
|
let snapshotRows;
|
|
2075
|
+
const readRows = () => {
|
|
2076
|
+
const next = this.replica.liveQuerySnapshot(key).rows;
|
|
2077
|
+
return snapshotRows?.length === next.length && next.every((row, index) => row === snapshotRows[index]) ? snapshotRows : next;
|
|
2078
|
+
};
|
|
1170
2079
|
let stateVersion = -1;
|
|
1171
2080
|
let stateFreshness;
|
|
1172
2081
|
let stateIsUpToDate;
|
|
@@ -1178,45 +2087,57 @@ export class GonvexClient {
|
|
|
1178
2087
|
};
|
|
1179
2088
|
// Keep the ReplicaSubscription as transport/reconciliation state only. The
|
|
1180
2089
|
// value returned by this watch always comes from normalized LocalReplica.
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
}
|
|
1186
|
-
else if (message.type === "replica.syncing" || message.type === "replica.reset") {
|
|
1187
|
-
latestError = undefined;
|
|
1188
|
-
notify();
|
|
1189
|
-
}
|
|
1190
|
-
else if (message.type === "replica.ready") {
|
|
1191
|
-
latestError = undefined;
|
|
1192
|
-
notify();
|
|
1193
|
-
}
|
|
1194
|
-
else if (message.type === "replica.snapshot") {
|
|
1195
|
-
latestError = undefined;
|
|
1196
|
-
}
|
|
1197
|
-
});
|
|
1198
|
-
const unsubscribeReplica = this.replica.subscribe(notify);
|
|
1199
|
-
const unsubscribeScope = this.onSessionScopeChange(() => {
|
|
1200
|
-
latestError = undefined;
|
|
2090
|
+
let stop;
|
|
2091
|
+
const start = () => {
|
|
2092
|
+
if (stop)
|
|
2093
|
+
return;
|
|
1201
2094
|
snapshotVersion = -1;
|
|
1202
|
-
snapshotRows = undefined;
|
|
1203
2095
|
stateVersion = -1;
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
2096
|
+
const releaseRows = this.replica.retainWindow(key);
|
|
2097
|
+
const unsubscribeTransport = this.subscribeReplicaTransport(ref, args, (message) => {
|
|
2098
|
+
if (message.type === "replica.error") {
|
|
2099
|
+
latestError = new Error(message.error);
|
|
2100
|
+
notify();
|
|
2101
|
+
}
|
|
2102
|
+
else if (message.type === "replica.syncing" || message.type === "replica.reset") {
|
|
2103
|
+
latestError = undefined;
|
|
2104
|
+
notify();
|
|
2105
|
+
}
|
|
2106
|
+
else if (message.type === "replica.ready") {
|
|
2107
|
+
latestError = undefined;
|
|
2108
|
+
notify();
|
|
2109
|
+
}
|
|
2110
|
+
else if (message.type === "replica.snapshot") {
|
|
2111
|
+
latestError = undefined;
|
|
2112
|
+
}
|
|
2113
|
+
}, true);
|
|
2114
|
+
const unsubscribeReplica = this.replica.subscribe(notify);
|
|
2115
|
+
const unsubscribeScope = this.onSessionScopeChange(() => {
|
|
2116
|
+
latestError = undefined;
|
|
2117
|
+
snapshotVersion = -1;
|
|
2118
|
+
snapshotRows = undefined;
|
|
2119
|
+
stateVersion = -1;
|
|
2120
|
+
stateFreshness = undefined;
|
|
2121
|
+
stateIsUpToDate = undefined;
|
|
2122
|
+
snapshotState = undefined;
|
|
2123
|
+
notify();
|
|
2124
|
+
});
|
|
2125
|
+
stop = () => { unsubscribeTransport(); unsubscribeReplica(); unsubscribeScope(); releaseRows(); stop = undefined; };
|
|
2126
|
+
};
|
|
2127
|
+
// React can discard a render before subscribing. Such watches must own no resources.
|
|
2128
|
+
if (!options.deferStart)
|
|
2129
|
+
start();
|
|
1209
2130
|
return {
|
|
1210
2131
|
localReplicaResult: () => {
|
|
1211
2132
|
if (latestError)
|
|
1212
2133
|
throw latestError;
|
|
1213
2134
|
if (!this.replica.hasLiveQuery(key))
|
|
1214
2135
|
return undefined;
|
|
1215
|
-
const version = this.replica.
|
|
2136
|
+
const version = this.replica.windowRowsVersion(key);
|
|
1216
2137
|
if (snapshotVersion === version)
|
|
1217
2138
|
return snapshotRows;
|
|
1218
2139
|
snapshotVersion = version;
|
|
1219
|
-
snapshotRows =
|
|
2140
|
+
snapshotRows = readRows();
|
|
1220
2141
|
return snapshotRows;
|
|
1221
2142
|
},
|
|
1222
2143
|
localReplicaState: () => {
|
|
@@ -1234,7 +2155,12 @@ export class GonvexClient {
|
|
|
1234
2155
|
stateVersion = version;
|
|
1235
2156
|
stateFreshness = freshness;
|
|
1236
2157
|
stateIsUpToDate = isUpToDate;
|
|
1237
|
-
const
|
|
2158
|
+
const rowsVersion = this.replica.windowRowsVersion(key);
|
|
2159
|
+
if (snapshotVersion !== rowsVersion) {
|
|
2160
|
+
snapshotVersion = rowsVersion;
|
|
2161
|
+
snapshotRows = readRows();
|
|
2162
|
+
}
|
|
2163
|
+
const state = this.replica.collectionState(key, snapshotRows);
|
|
1238
2164
|
snapshotState = {
|
|
1239
2165
|
...state,
|
|
1240
2166
|
isUpToDate,
|
|
@@ -1250,6 +2176,7 @@ export class GonvexClient {
|
|
|
1250
2176
|
isUpToDate: this.replicaSubscriptions.get(key)?.isUpToDate === true,
|
|
1251
2177
|
}),
|
|
1252
2178
|
onUpdate(handler) {
|
|
2179
|
+
start();
|
|
1253
2180
|
if (releaseTimer) {
|
|
1254
2181
|
clearTimeout(releaseTimer);
|
|
1255
2182
|
releaseTimer = undefined;
|
|
@@ -1267,9 +2194,7 @@ export class GonvexClient {
|
|
|
1267
2194
|
releaseTimer = undefined;
|
|
1268
2195
|
if (updateHandlers.size > 0)
|
|
1269
2196
|
return;
|
|
1270
|
-
|
|
1271
|
-
unsubscribeReplica();
|
|
1272
|
-
unsubscribeScope();
|
|
2197
|
+
stop?.();
|
|
1273
2198
|
}, 0);
|
|
1274
2199
|
};
|
|
1275
2200
|
},
|
|
@@ -1280,7 +2205,7 @@ export class GonvexClient {
|
|
|
1280
2205
|
* latest query result is retained only as the transport-shaped skeleton;
|
|
1281
2206
|
* its row window is always rebuilt from LocalReplica membership/entities.
|
|
1282
2207
|
*/
|
|
1283
|
-
watchLiveQuery(ref, args = {}) {
|
|
2208
|
+
watchLiveQuery(ref, args = {}, options = {}) {
|
|
1284
2209
|
const key = querySubscriptionKey(ref, args);
|
|
1285
2210
|
const updateHandlers = new Set();
|
|
1286
2211
|
let transportResult;
|
|
@@ -1300,39 +2225,51 @@ export class GonvexClient {
|
|
|
1300
2225
|
handler();
|
|
1301
2226
|
});
|
|
1302
2227
|
};
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
2228
|
+
let stop;
|
|
2229
|
+
const start = () => {
|
|
2230
|
+
if (stop)
|
|
2231
|
+
return;
|
|
2232
|
+
transportResult = undefined;
|
|
2233
|
+
snapshotToken = "";
|
|
2234
|
+
const releaseRows = this.replica.retainWindow(key);
|
|
2235
|
+
const unsubscribeQuery = this.subscribeLiveQuery(ref, args, (message) => {
|
|
2236
|
+
if (message.type === "query.result") {
|
|
2237
|
+
transportResult = message.result;
|
|
2238
|
+
transportGeneration += 1;
|
|
2239
|
+
latestError = undefined;
|
|
2240
|
+
// LocalReplica has already published its atomic window swap before
|
|
2241
|
+
// this callback is emitted, so this is the single initial UI wake-up.
|
|
2242
|
+
notify();
|
|
2243
|
+
}
|
|
2244
|
+
else if (message.type === "query.error") {
|
|
2245
|
+
latestError = new GonvexClientError(`Query ${ref.path} failed: ${message.error}`, {
|
|
2246
|
+
code: "server", path: ref.path, operation: "query",
|
|
2247
|
+
});
|
|
2248
|
+
notify();
|
|
2249
|
+
}
|
|
2250
|
+
});
|
|
2251
|
+
// During the initial query result, LocalReplica notifies before the
|
|
2252
|
+
// transport-shaped skeleton is installed above. Suppress that empty
|
|
2253
|
+
// intermediate wake-up; later transactions notify directly from the
|
|
2254
|
+
// normalized store.
|
|
2255
|
+
const unsubscribeReplica = this.replica.subscribe(() => {
|
|
2256
|
+
if (transportResult !== undefined || this.replica.hasLiveQuery(key))
|
|
2257
|
+
notify();
|
|
2258
|
+
});
|
|
2259
|
+
void this.replicaReady.then(() => notify());
|
|
2260
|
+
const unsubscribeScope = this.onSessionScopeChange(() => {
|
|
2261
|
+
transportResult = undefined;
|
|
1306
2262
|
transportGeneration += 1;
|
|
2263
|
+
snapshotToken = "";
|
|
2264
|
+
snapshotResult = undefined;
|
|
1307
2265
|
latestError = undefined;
|
|
1308
|
-
// LocalReplica has already published its atomic window swap before
|
|
1309
|
-
// this callback is emitted, so this is the single initial UI wake-up.
|
|
1310
|
-
notify();
|
|
1311
|
-
}
|
|
1312
|
-
else if (message.type === "query.error") {
|
|
1313
|
-
latestError = new GonvexClientError(`Query ${ref.path} failed: ${message.error}`, {
|
|
1314
|
-
code: "server", path: ref.path, operation: "query",
|
|
1315
|
-
});
|
|
1316
|
-
notify();
|
|
1317
|
-
}
|
|
1318
|
-
});
|
|
1319
|
-
// During the initial query result, LocalReplica notifies before the
|
|
1320
|
-
// transport-shaped skeleton is installed above. Suppress that empty
|
|
1321
|
-
// intermediate wake-up; later transactions notify directly from the
|
|
1322
|
-
// normalized store.
|
|
1323
|
-
const unsubscribeReplica = this.replica.subscribe(() => {
|
|
1324
|
-
if (transportResult !== undefined || this.replica.hasLiveQuery(key))
|
|
1325
2266
|
notify();
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
snapshotResult = undefined;
|
|
1333
|
-
latestError = undefined;
|
|
1334
|
-
notify();
|
|
1335
|
-
});
|
|
2267
|
+
});
|
|
2268
|
+
stop = () => { unsubscribeQuery(); unsubscribeReplica(); unsubscribeScope(); releaseRows(); stop = undefined; };
|
|
2269
|
+
};
|
|
2270
|
+
// React can discard a render before subscribing. Such watches must own no resources.
|
|
2271
|
+
if (!options.deferStart)
|
|
2272
|
+
start();
|
|
1336
2273
|
return {
|
|
1337
2274
|
localLiveQueryResult: () => {
|
|
1338
2275
|
if (latestError)
|
|
@@ -1367,6 +2304,7 @@ export class GonvexClient {
|
|
|
1367
2304
|
return snapshotResult;
|
|
1368
2305
|
},
|
|
1369
2306
|
onUpdate(handler) {
|
|
2307
|
+
start();
|
|
1370
2308
|
if (releaseTimer) {
|
|
1371
2309
|
clearTimeout(releaseTimer);
|
|
1372
2310
|
releaseTimer = undefined;
|
|
@@ -1384,9 +2322,7 @@ export class GonvexClient {
|
|
|
1384
2322
|
releaseTimer = undefined;
|
|
1385
2323
|
if (updateHandlers.size > 0)
|
|
1386
2324
|
return;
|
|
1387
|
-
|
|
1388
|
-
unsubscribeReplica();
|
|
1389
|
-
unsubscribeScope();
|
|
2325
|
+
stop?.();
|
|
1390
2326
|
}, 0);
|
|
1391
2327
|
};
|
|
1392
2328
|
},
|
|
@@ -1431,7 +2367,7 @@ export class GonvexClient {
|
|
|
1431
2367
|
scope,
|
|
1432
2368
|
};
|
|
1433
2369
|
await this.replica.replaceWindow(window);
|
|
1434
|
-
const snapshot = { ...message, result:
|
|
2370
|
+
const snapshot = { ...message, result: [] };
|
|
1435
2371
|
subscription.lastMessage = snapshot;
|
|
1436
2372
|
this.emitReplicaMessage(subscription, snapshot, scope);
|
|
1437
2373
|
return;
|
|
@@ -1442,12 +2378,20 @@ export class GonvexClient {
|
|
|
1442
2378
|
return;
|
|
1443
2379
|
this.clearReplicaRetry(subscription, true);
|
|
1444
2380
|
raiseReplicaCursorFloor(subscription, message.cursor);
|
|
2381
|
+
const upserts = (message.upserts ?? []).filter((row) => asReplicaRow(row) !== undefined).map(row => asReplicaRow(row));
|
|
2382
|
+
// Incremental integrity is safe only for full projected row images. Older
|
|
2383
|
+
// partial-row protocols keep the full normalized rehash on replica.ready.
|
|
2384
|
+
const canCarryHashes = prior?.hashes && subscription.columns?.length
|
|
2385
|
+
&& upserts.every(row => subscription.columns.every(column => hasOwn(row, column)));
|
|
2386
|
+
const hashes = message.hashes ?? (canCarryHashes
|
|
2387
|
+
? await applyReplicaHashDelta(prior.hashes, projectReplicaIntegrityRows(upserts, subscription.columns), message.deleted ?? [], prior?.key ?? "id")
|
|
2388
|
+
: undefined);
|
|
1445
2389
|
await this.replica.applyWindowDelta({
|
|
1446
2390
|
signature: subscription.key,
|
|
1447
2391
|
kind: "replica",
|
|
1448
2392
|
entity: subscription.entity,
|
|
1449
2393
|
key: prior?.key ?? "id",
|
|
1450
|
-
upserts
|
|
2394
|
+
upserts,
|
|
1451
2395
|
deleted: message.deleted ?? [],
|
|
1452
2396
|
completeness: prior?.completeness ?? "partial",
|
|
1453
2397
|
source: "server",
|
|
@@ -1458,13 +2402,11 @@ export class GonvexClient {
|
|
|
1458
2402
|
orderDirection: prior?.orderDirection,
|
|
1459
2403
|
maxRows: prior?.maxRows,
|
|
1460
2404
|
maxBytes: prior?.maxBytes,
|
|
1461
|
-
|
|
1462
|
-
// supplied a complete replacement map with this frame.
|
|
1463
|
-
hashes: message.hashes,
|
|
2405
|
+
hashes,
|
|
1464
2406
|
});
|
|
1465
2407
|
const snapshot = {
|
|
1466
2408
|
type: "replica.snapshot", id: subscription.id, path: subscription.path,
|
|
1467
|
-
result:
|
|
2409
|
+
result: [], cursor: message.cursor,
|
|
1468
2410
|
key: prior?.key ?? "id", mode: prior?.mode, orderBy: prior?.orderBy,
|
|
1469
2411
|
orderDirection: prior?.orderDirection, maxRows: prior?.maxRows, maxBytes: prior?.maxBytes,
|
|
1470
2412
|
};
|
|
@@ -1614,9 +2556,15 @@ export class GonvexClient {
|
|
|
1614
2556
|
emitReplicaMessage(subscription, message, scope = this.replicaScope) {
|
|
1615
2557
|
if (scope !== this.replicaScope || subscription.scope !== scope)
|
|
1616
2558
|
return;
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
2559
|
+
// Hook observers consume metadata only; their rows come from Local Replica.
|
|
2560
|
+
// Materialize a payload only when a public transport consumer needs it.
|
|
2561
|
+
let outgoing;
|
|
2562
|
+
for (const listener of Array.from(subscription.listeners)) {
|
|
2563
|
+
if (this.replicaMetadataListeners.has(listener))
|
|
2564
|
+
listener(message);
|
|
2565
|
+
else
|
|
2566
|
+
listener(outgoing ??= this.materializeReplicaMessage(subscription, message));
|
|
2567
|
+
}
|
|
1620
2568
|
}
|
|
1621
2569
|
materializeReplicaMessage(subscription, message) {
|
|
1622
2570
|
if (message.type !== "replica.snapshot")
|
|
@@ -1662,7 +2610,7 @@ export class GonvexClient {
|
|
|
1662
2610
|
subscription.isUpToDate = false;
|
|
1663
2611
|
const message = {
|
|
1664
2612
|
type: "replica.snapshot", id: subscription.id, path: subscription.path,
|
|
1665
|
-
result:
|
|
2613
|
+
result: [], cursor: cached.cursor ?? { epoch: "cache", revision: 0 },
|
|
1666
2614
|
key: cached.key, mode: cached.mode, orderBy: cached.orderBy,
|
|
1667
2615
|
orderDirection: cached.orderDirection, maxRows: cached.maxRows, maxBytes: cached.maxBytes,
|
|
1668
2616
|
};
|
|
@@ -1771,8 +2719,11 @@ export class GonvexClient {
|
|
|
1771
2719
|
this.outboxScope = scope;
|
|
1772
2720
|
const ready = this.hasAuthoritativeReplicaScope
|
|
1773
2721
|
? this.restoreOutbox(scope, generation)
|
|
1774
|
-
:
|
|
1775
|
-
this.
|
|
2722
|
+
: this.restoreLocalSession(scope, generation);
|
|
2723
|
+
this.setOutboxReady(ready);
|
|
2724
|
+
// Never show the previous identity's intents under the new one.
|
|
2725
|
+
this.publishIntents([]);
|
|
2726
|
+
this.refreshIntents();
|
|
1776
2727
|
return ready;
|
|
1777
2728
|
}
|
|
1778
2729
|
async activateReplicaDirective(directive) {
|
|
@@ -1795,8 +2746,11 @@ export class GonvexClient {
|
|
|
1795
2746
|
this.resetReplicaScopeState();
|
|
1796
2747
|
this.replicaScope = scope;
|
|
1797
2748
|
this.hasAuthoritativeReplicaScope = true;
|
|
1798
|
-
this.
|
|
2749
|
+
this.setReplicaReady(this.replica.activateScope(scope));
|
|
1799
2750
|
this.rotateSubscriptionScopes();
|
|
2751
|
+
// Subscriptions follow scope rotation, so this opens each resident
|
|
2752
|
+
// dependency collection once per client, from cache first when offline.
|
|
2753
|
+
this.ensureLocalCollections(this.residentDependencyTables);
|
|
1800
2754
|
const generation = this.outboxScopeGeneration;
|
|
1801
2755
|
// Publish the recovery barrier before yielding to Replica storage. A
|
|
1802
2756
|
// reducer may be invoked as soon as the auth result arrives, while the
|
|
@@ -1804,10 +2758,24 @@ export class GonvexClient {
|
|
|
1804
2758
|
// points at the old resolved promise, that reducer can enqueue an inflight
|
|
1805
2759
|
// row which the concurrent recovery then mistakes for an abandoned call
|
|
1806
2760
|
// and sends a second time with the same command ID.
|
|
1807
|
-
this.
|
|
2761
|
+
this.setOutboxReady(this.restoreOutbox(this.outboxScope, generation));
|
|
1808
2762
|
await this.outboxReady;
|
|
1809
2763
|
}
|
|
2764
|
+
// Scope activation rejects once the replica is disposed (for example when
|
|
2765
|
+
// an auth error quarantines a client that was already closed). Callers that
|
|
2766
|
+
// await replicaReady still see the rejection; the stored promise itself must
|
|
2767
|
+
// not surface as an unhandled rejection that kills Node processes.
|
|
2768
|
+
setReplicaReady(ready) {
|
|
2769
|
+
this.replicaReady = ready;
|
|
2770
|
+
ready.catch(() => undefined);
|
|
2771
|
+
}
|
|
2772
|
+
// Outbox restore awaits replicaReady, so it rejects the same way.
|
|
2773
|
+
setOutboxReady(ready) {
|
|
2774
|
+
this.outboxReady = ready;
|
|
2775
|
+
ready.catch(() => undefined);
|
|
2776
|
+
}
|
|
1810
2777
|
quarantineReplicaScope() {
|
|
2778
|
+
this.localIdentity = undefined;
|
|
1811
2779
|
// Keep the durable prior identity scope intact for an authorized future
|
|
1812
2780
|
// login, but make every synchronous selector fail closed immediately.
|
|
1813
2781
|
// The random suffix prevents a denied scope from ever restoring rows.
|
|
@@ -1819,7 +2787,7 @@ export class GonvexClient {
|
|
|
1819
2787
|
this.resetReplicaScopeState();
|
|
1820
2788
|
this.replicaScope = scope;
|
|
1821
2789
|
this.hasAuthoritativeReplicaScope = false;
|
|
1822
|
-
this.
|
|
2790
|
+
this.setReplicaReady(this.replica.activateScope(scope, true));
|
|
1823
2791
|
this.rotateSubscriptionScopes();
|
|
1824
2792
|
}
|
|
1825
2793
|
rejectMissingReplicaDirective() {
|
|
@@ -1832,12 +2800,26 @@ export class GonvexClient {
|
|
|
1832
2800
|
}
|
|
1833
2801
|
async restoreOutbox(scope, generation) {
|
|
1834
2802
|
await this.replicaReady;
|
|
2803
|
+
await this.replica.synchronizeStorage();
|
|
1835
2804
|
const entries = await this.reducerOutbox.loadAll(scope);
|
|
1836
2805
|
if (this.manuallyClosed
|
|
1837
2806
|
|| generation !== this.outboxScopeGeneration
|
|
1838
2807
|
|| scope !== this.outboxScope)
|
|
1839
2808
|
return;
|
|
1840
2809
|
for (const entry of entries) {
|
|
2810
|
+
// markCommitted follows the durable replica watermark barrier. Its
|
|
2811
|
+
// transaction is already in the restored base; never predict it twice.
|
|
2812
|
+
if (entry.localExecution && entry.state === "committed")
|
|
2813
|
+
await this.reducerOutbox.ack(entry.id);
|
|
2814
|
+
}
|
|
2815
|
+
if (entries.some((entry) => entry.localExecution) && this.localExecutor) {
|
|
2816
|
+
await this.inLocalLane(() => this.rebaseLocalEntries());
|
|
2817
|
+
}
|
|
2818
|
+
for (const entry of entries) {
|
|
2819
|
+
if (entry.localExecution && this.localExecutor)
|
|
2820
|
+
continue;
|
|
2821
|
+
if (!outboxEntryIsLive(entry))
|
|
2822
|
+
continue;
|
|
1841
2823
|
if (entry.state === "committed" && (entry.patches?.length ?? 0) === 0) {
|
|
1842
2824
|
await this.reducerOutbox.ack(entry.id);
|
|
1843
2825
|
continue;
|
|
@@ -1854,11 +2836,15 @@ export class GonvexClient {
|
|
|
1854
2836
|
// If this scope was installed after the socket authenticated, no reconnect
|
|
1855
2837
|
// or new enqueue may occur to wake the queue. The await inside drainOutbox
|
|
1856
2838
|
// yields until this restore promise resolves, then safely resumes it.
|
|
2839
|
+
this.refreshIntents();
|
|
1857
2840
|
void this.drainOutbox();
|
|
1858
2841
|
}
|
|
1859
2842
|
addOptimisticReducer(reducerId, patches, accepted = false) {
|
|
1860
|
-
if (patches.length === 0
|
|
2843
|
+
if (patches.length === 0) {
|
|
2844
|
+
this.optimisticReducerIds.delete(reducerId);
|
|
2845
|
+
this.replica.rejectCommand(reducerId);
|
|
1861
2846
|
return;
|
|
2847
|
+
}
|
|
1862
2848
|
this.optimisticReducerIds.add(reducerId);
|
|
1863
2849
|
this.replica.applyOptimistic(reducerId, patches);
|
|
1864
2850
|
}
|
|
@@ -1878,69 +2864,195 @@ export class GonvexClient {
|
|
|
1878
2864
|
await this.reducerOutbox.ack(entryId);
|
|
1879
2865
|
}
|
|
1880
2866
|
async drainOutbox() {
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
2867
|
+
if (this.publicTenant)
|
|
2868
|
+
return;
|
|
2869
|
+
// A failed restore is reported to whoever awaits the scope transition;
|
|
2870
|
+
// there is nothing to drain from a scope that never became ready.
|
|
2871
|
+
try {
|
|
2872
|
+
await this.outboxReady;
|
|
2873
|
+
}
|
|
2874
|
+
catch {
|
|
2875
|
+
return;
|
|
2876
|
+
}
|
|
2877
|
+
if (this.drainingOutbox) {
|
|
2878
|
+
// The running drain may already be past the entry this wake-up is for
|
|
2879
|
+
// (for example a short backoff timer firing before the drain returned).
|
|
2880
|
+
this.outboxDrainRequested = true;
|
|
2881
|
+
return;
|
|
2882
|
+
}
|
|
2883
|
+
if (this.manuallyClosed || !this.canSendReducerNow())
|
|
1885
2884
|
return;
|
|
1886
2885
|
const drainScope = this.outboxScope;
|
|
1887
2886
|
this.drainingOutbox = true;
|
|
2887
|
+
this.outboxDrainRequested = false;
|
|
1888
2888
|
try {
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
const entry = await this.reducerOutbox.nextReady(scope, Date.now());
|
|
1892
|
-
if (!entry)
|
|
1893
|
-
return;
|
|
1894
|
-
if (scope !== this.outboxScope)
|
|
1895
|
-
return;
|
|
1896
|
-
if (this.directOutboxReducerIds.has(entry.idempotencyKey)) {
|
|
1897
|
-
// Scope recovery may observe an inflight row created by this live
|
|
1898
|
-
// process and reset it to pending under the assumption that a prior
|
|
1899
|
-
// process crashed. The foreground call still owns that command ID.
|
|
1900
|
-
// Restore the durable marker and wait for its real result instead of
|
|
1901
|
-
// registering a second response handler for the same command.
|
|
1902
|
-
await this.reducerOutbox.markInflight(entry.id);
|
|
2889
|
+
await this.coordinateOutbox("delivery", async () => {
|
|
2890
|
+
if (this.manuallyClosed || drainScope !== this.outboxScope || !this.canSendReducerNow())
|
|
1903
2891
|
return;
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
2892
|
+
if (this.sharedOutboxStore)
|
|
2893
|
+
await this.reducerOutbox.recoverInflight?.(drainScope);
|
|
2894
|
+
while (!this.manuallyClosed && this.socket?.readyState === WebSocket.OPEN) {
|
|
2895
|
+
const scope = this.outboxScope;
|
|
2896
|
+
const entry = await this.inLocalLane(async () => {
|
|
2897
|
+
const next = await this.reducerOutbox.nextReady(scope, Date.now());
|
|
2898
|
+
// A concurrent discard (this or another tab) fences the entry; a
|
|
2899
|
+
// refused transition means it must not be sent.
|
|
2900
|
+
if (next && (await this.reducerOutbox.markInflight(next.id)) === false)
|
|
2901
|
+
return null;
|
|
2902
|
+
return next;
|
|
2903
|
+
});
|
|
2904
|
+
if (entry === null)
|
|
2905
|
+
continue;
|
|
2906
|
+
if (!entry) {
|
|
2907
|
+
await this.scheduleNextOutboxAttempt(scope);
|
|
2908
|
+
return;
|
|
1921
2909
|
}
|
|
1922
|
-
|
|
1923
|
-
|
|
2910
|
+
if (scope !== this.outboxScope)
|
|
2911
|
+
return;
|
|
2912
|
+
if (this.directOutboxReducerIds.has(entry.idempotencyKey)) {
|
|
2913
|
+
// Scope recovery may observe an inflight row created by this live
|
|
2914
|
+
// process and reset it to pending under the assumption that a prior
|
|
2915
|
+
// process crashed. The foreground call still owns that command ID.
|
|
2916
|
+
// Restore the durable marker and wait for its real result instead of
|
|
2917
|
+
// registering a second response handler for the same command.
|
|
2918
|
+
await this.reducerOutbox.markInflight(entry.id);
|
|
2919
|
+
return;
|
|
1924
2920
|
}
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
2921
|
+
if (!this.canSendReducerNow()) {
|
|
2922
|
+
await this.reducerOutbox.markPending(entry.id);
|
|
2923
|
+
return;
|
|
2924
|
+
}
|
|
2925
|
+
if ((await this.reducerOutbox.markInflight(entry.id)) === false)
|
|
1929
2926
|
continue;
|
|
2927
|
+
if (scope !== this.outboxScope)
|
|
2928
|
+
return;
|
|
2929
|
+
if (!this.canSendReducerNow()) {
|
|
2930
|
+
await this.reducerOutbox.markPending(entry.id);
|
|
2931
|
+
return;
|
|
2932
|
+
}
|
|
2933
|
+
try {
|
|
2934
|
+
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);
|
|
2935
|
+
this.unauthenticatedRetries = 0;
|
|
2936
|
+
await this.reducerOutbox.markCommitted(entry.id);
|
|
2937
|
+
if (entry.localExecution && this.localExecutor) {
|
|
2938
|
+
await this.inLocalLane(async () => {
|
|
2939
|
+
await this.reducerOutbox.ack(entry.id);
|
|
2940
|
+
if (scope === this.outboxScope)
|
|
2941
|
+
await this.rebaseLocalEntries();
|
|
2942
|
+
});
|
|
2943
|
+
}
|
|
2944
|
+
else if ((entry.patches?.length ?? 0) > 0) {
|
|
2945
|
+
await this.settleOptimisticReducer(entry.idempotencyKey);
|
|
2946
|
+
}
|
|
2947
|
+
else {
|
|
2948
|
+
await this.ackOptimisticReducer(entry.idempotencyKey, entry.id);
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
catch (error) {
|
|
2952
|
+
const disposition = deliveryErrorClass(error);
|
|
2953
|
+
if (disposition === "rejected") {
|
|
2954
|
+
// Keep today's rollback and rebase, but record the rejection
|
|
2955
|
+
// durably instead of deleting the intent without a trace.
|
|
2956
|
+
if (entry.localExecution && this.localExecutor) {
|
|
2957
|
+
await this.inLocalLane(async () => {
|
|
2958
|
+
await this.reducerOutbox.reject(entry.id, reducerErrorMessage(error));
|
|
2959
|
+
if (scope === this.outboxScope) {
|
|
2960
|
+
await this.rebaseLocalEntries(true);
|
|
2961
|
+
this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
|
|
2962
|
+
}
|
|
2963
|
+
});
|
|
2964
|
+
}
|
|
2965
|
+
else {
|
|
2966
|
+
this.optimisticReducerIds.delete(entry.idempotencyKey);
|
|
2967
|
+
this.optimisticOutboxEntryIds.delete(entry.idempotencyKey);
|
|
2968
|
+
this.replica.rejectCommand(entry.idempotencyKey);
|
|
2969
|
+
await this.reducerOutbox.reject(entry.id, reducerErrorMessage(error));
|
|
2970
|
+
if (scope === this.outboxScope)
|
|
2971
|
+
this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
|
|
2972
|
+
}
|
|
2973
|
+
continue;
|
|
2974
|
+
}
|
|
2975
|
+
const outcome = await this.recordDeliveryFailure(entry, error, disposition);
|
|
2976
|
+
// A parked intent keeps its prediction but no longer blocks the
|
|
2977
|
+
// chain; later intents proceed and the server validates them.
|
|
2978
|
+
if (outcome === "parked")
|
|
2979
|
+
continue;
|
|
2980
|
+
return;
|
|
1930
2981
|
}
|
|
1931
|
-
await this.reducerOutbox.fail(entry.id, reducerErrorMessage(error));
|
|
1932
|
-
this.scheduleOutboxDrain(Math.min(30_000, 1_000 * (2 ** (entry.attempts + 1))));
|
|
1933
|
-
return;
|
|
1934
2982
|
}
|
|
1935
|
-
}
|
|
2983
|
+
});
|
|
1936
2984
|
}
|
|
1937
2985
|
finally {
|
|
1938
2986
|
this.drainingOutbox = false;
|
|
1939
|
-
|
|
2987
|
+
this.scheduleLocalReplay();
|
|
2988
|
+
const requested = this.outboxDrainRequested;
|
|
2989
|
+
this.outboxDrainRequested = false;
|
|
2990
|
+
if (!this.manuallyClosed && (drainScope !== this.outboxScope || requested)) {
|
|
1940
2991
|
void this.drainOutbox();
|
|
1941
2992
|
}
|
|
1942
2993
|
}
|
|
1943
2994
|
}
|
|
2995
|
+
/**
|
|
2996
|
+
* Record a non-rejection delivery failure and arm the next attempt.
|
|
2997
|
+
* - update_required: keep the intent pending and stop for an app update.
|
|
2998
|
+
* - unauthenticated: keep the intent, re-authenticate, retry with backoff
|
|
2999
|
+
* that never spends the retry budget.
|
|
3000
|
+
* - network: connectivity loss; retried on reconnect without spending budget.
|
|
3001
|
+
* - transient (and timeouts): exponential backoff, parked as `failed` once
|
|
3002
|
+
* the retry budget is spent.
|
|
3003
|
+
*/
|
|
3004
|
+
async recordDeliveryFailure(entry, error, disposition) {
|
|
3005
|
+
const message = reducerErrorMessage(error);
|
|
3006
|
+
if (disposition === "update_required") {
|
|
3007
|
+
await this.reducerOutbox.fail(entry.id, message, { errorClass: disposition, countAttempt: false, delayMs: 0 });
|
|
3008
|
+
this.requireClientUpdate(message);
|
|
3009
|
+
return "update_required";
|
|
3010
|
+
}
|
|
3011
|
+
if (disposition === "unauthenticated") {
|
|
3012
|
+
const delay = outboxBackoffMs(this.unauthenticatedRetries++, this.outboxRetry.maxBackoffMs);
|
|
3013
|
+
await this.reducerOutbox.fail(entry.id, message, { errorClass: disposition, countAttempt: false, delayMs: delay });
|
|
3014
|
+
this.requestReauthentication();
|
|
3015
|
+
this.scheduleOutboxDrain(delay);
|
|
3016
|
+
return "retrying";
|
|
3017
|
+
}
|
|
3018
|
+
const counted = disposition === "transient";
|
|
3019
|
+
const updated = await this.reducerOutbox.fail(entry.id, message, {
|
|
3020
|
+
errorClass: disposition,
|
|
3021
|
+
countAttempt: counted,
|
|
3022
|
+
maxAttempts: this.outboxRetry.maxAttempts,
|
|
3023
|
+
maxBackoffMs: this.outboxRetry.maxBackoffMs,
|
|
3024
|
+
...(counted ? {} : { delayMs: 1_000 }),
|
|
3025
|
+
});
|
|
3026
|
+
if (updated && updated.state === "failed")
|
|
3027
|
+
return "parked";
|
|
3028
|
+
const delay = updated ? Math.max(0, updated.nextAttemptAt - Date.now()) : DEFAULT_OUTBOX_MAX_BACKOFF_MS;
|
|
3029
|
+
this.scheduleOutboxDrain(delay);
|
|
3030
|
+
return "retrying";
|
|
3031
|
+
}
|
|
3032
|
+
/**
|
|
3033
|
+
* Arm a timer for the earliest backed-off entry. Timer clocks and
|
|
3034
|
+
* `Date.now()` can disagree by a millisecond, so a wake-up may find its
|
|
3035
|
+
* entry not quite due; without this the queue would wait for an unrelated
|
|
3036
|
+
* event to resume.
|
|
3037
|
+
*/
|
|
3038
|
+
async scheduleNextOutboxAttempt(scope) {
|
|
3039
|
+
const now = Date.now();
|
|
3040
|
+
let next = Infinity;
|
|
3041
|
+
for (const entry of await this.reducerOutbox.list(scope)) {
|
|
3042
|
+
if (entry.state === "pending" && entry.nextAttemptAt > now)
|
|
3043
|
+
next = Math.min(next, entry.nextAttemptAt);
|
|
3044
|
+
}
|
|
3045
|
+
if (Number.isFinite(next) && scope === this.outboxScope)
|
|
3046
|
+
this.scheduleOutboxDrain(next - now + 1);
|
|
3047
|
+
}
|
|
3048
|
+
/** Re-send auth on the open socket so a lost tenant session is restored. */
|
|
3049
|
+
requestReauthentication() {
|
|
3050
|
+
if (this.manuallyClosed || this.authInFlight || this.socket?.readyState !== WebSocket.OPEN)
|
|
3051
|
+
return;
|
|
3052
|
+
if (!this.auth.project && !this.auth.tenant && !this.auth.token && !this.auth.fetchToken)
|
|
3053
|
+
return;
|
|
3054
|
+
this.sendAuth(false);
|
|
3055
|
+
}
|
|
1944
3056
|
scheduleOutboxDrain(delay) {
|
|
1945
3057
|
if (this.manuallyClosed)
|
|
1946
3058
|
return;
|
|
@@ -1952,6 +3064,13 @@ export class GonvexClient {
|
|
|
1952
3064
|
}, delay);
|
|
1953
3065
|
}
|
|
1954
3066
|
reducer(ref, args = {}, options = {}) {
|
|
3067
|
+
if (this.publicTenant) {
|
|
3068
|
+
if (options.offline === "queue" || options.optimistic?.length)
|
|
3069
|
+
return Promise.reject(new GonvexClientError("Public reducers are server-only and cannot queue or execute optimistically", { code: "disconnected", path: ref.path, operation: "reducer" }));
|
|
3070
|
+
return this.call("reducer", ref, args, options.timeoutMs ?? this.timeouts.reducerTimeoutMs);
|
|
3071
|
+
}
|
|
3072
|
+
if (ref.localExecution === 1)
|
|
3073
|
+
return this.runLocalReducer(ref, args);
|
|
1955
3074
|
if (options.offline === "queue" && ref.offline?.mode !== "allowed") {
|
|
1956
3075
|
return Promise.reject(new GonvexClientError(`Reducer ${ref.path} does not allow offline queueing.`, { code: "disconnected", path: ref.path, operation: "reducer" }));
|
|
1957
3076
|
}
|
|
@@ -1978,7 +3097,6 @@ export class GonvexClient {
|
|
|
1978
3097
|
}
|
|
1979
3098
|
this.directOutboxReducerIds.add(reducerId);
|
|
1980
3099
|
let entryId;
|
|
1981
|
-
let entryAttempts = 0;
|
|
1982
3100
|
try {
|
|
1983
3101
|
const scope = this.outboxScope;
|
|
1984
3102
|
const entry = await this.reducerOutbox.enqueue({
|
|
@@ -1991,7 +3109,6 @@ export class GonvexClient {
|
|
|
1991
3109
|
state: "inflight",
|
|
1992
3110
|
});
|
|
1993
3111
|
entryId = entry.id;
|
|
1994
|
-
entryAttempts = entry.attempts;
|
|
1995
3112
|
if (this.manuallyClosed) {
|
|
1996
3113
|
await this.reducerOutbox.ack(entry.id);
|
|
1997
3114
|
throw new GonvexClientError(`Gonvex client was closed before reducer ${ref.path} could be sent.`, { code: "closed", path: ref.path, operation: "reducer" });
|
|
@@ -2019,14 +3136,13 @@ export class GonvexClient {
|
|
|
2019
3136
|
return result;
|
|
2020
3137
|
}
|
|
2021
3138
|
catch (error) {
|
|
2022
|
-
|
|
3139
|
+
const disposition = deliveryErrorClass(error);
|
|
3140
|
+
if (disposition !== "rejected" && options.offline === "queue") {
|
|
2023
3141
|
const queuedEntryId = this.optimisticOutboxEntryIds.get(reducerId) ?? entryId;
|
|
2024
3142
|
if (queuedEntryId !== undefined) {
|
|
2025
|
-
|
|
2026
|
-
//
|
|
2027
|
-
|
|
2028
|
-
// next deterministic drain just like the background drain path.
|
|
2029
|
-
this.scheduleOutboxDrain(Math.min(30_000, 1_000 * (2 ** (entryAttempts + 1))));
|
|
3143
|
+
// Records backoff (or parks the entry) and arms the client's timer,
|
|
3144
|
+
// exactly like the background drain path.
|
|
3145
|
+
await this.recordDeliveryFailure({ id: queuedEntryId }, error, disposition);
|
|
2030
3146
|
}
|
|
2031
3147
|
return { status: "queued", reducerId };
|
|
2032
3148
|
}
|
|
@@ -2038,7 +3154,17 @@ export class GonvexClient {
|
|
|
2038
3154
|
void this.drainOutbox();
|
|
2039
3155
|
}
|
|
2040
3156
|
}
|
|
3157
|
+
requireClientUpdate(reason) {
|
|
3158
|
+
if (this.updateRequired)
|
|
3159
|
+
return;
|
|
3160
|
+
this.updateRequired = true;
|
|
3161
|
+
// Keep durable intents intact. Closing suspends subscriptions and future sends.
|
|
3162
|
+
this.close();
|
|
3163
|
+
this.updateRequiredHandler?.(reason);
|
|
3164
|
+
}
|
|
2041
3165
|
canSendReducerNow() {
|
|
3166
|
+
if (this.updateRequired)
|
|
3167
|
+
return false;
|
|
2042
3168
|
if (globalThis.navigator?.onLine === false)
|
|
2043
3169
|
return false;
|
|
2044
3170
|
const socket = this.socket;
|
|
@@ -2183,6 +3309,10 @@ export class GonvexClient {
|
|
|
2183
3309
|
...(ref.scope === "control" ? { scope: "control" } : {}),
|
|
2184
3310
|
trace: { clientSentAtMs: entry.clientSentAtMs },
|
|
2185
3311
|
...(effectiveIdempotencyKey ? { idempotencyKey: effectiveIdempotencyKey } : {}),
|
|
3312
|
+
...(ref.artifactHash ? { artifactHash: ref.artifactHash } : {}),
|
|
3313
|
+
...(this.clientContract ? { clientContract: this.clientContract.version } : {}),
|
|
3314
|
+
...(ref.receiptPath ? { receiptPath: ref.receiptPath } : {}),
|
|
3315
|
+
...(ref.intentEntropy ? { intentEntropy: ref.intentEntropy } : {}),
|
|
2186
3316
|
});
|
|
2187
3317
|
}
|
|
2188
3318
|
else {
|
|
@@ -2233,8 +3363,11 @@ export class GonvexClient {
|
|
|
2233
3363
|
return;
|
|
2234
3364
|
}
|
|
2235
3365
|
if (kind === "reducer" && message.type === "reducer.result") {
|
|
2236
|
-
this.replica.acknowledgeCommand(message.originCommandId, message.committedRevision);
|
|
2237
3366
|
const complete = () => {
|
|
3367
|
+
// A collection frame can advance the shared cursor before another
|
|
3368
|
+
// collection's rows and membership have arrived. Retire the local
|
|
3369
|
+
// prediction only after the commit's durable watermark barrier.
|
|
3370
|
+
this.replica.acknowledgeCommand(message.originCommandId, message.committedRevision);
|
|
2238
3371
|
settle();
|
|
2239
3372
|
this.emitTelemetryFromCall(kind, id, ref.path, "ok", clientSentAtMs, message.trace);
|
|
2240
3373
|
resolve(message.result);
|
|
@@ -2258,7 +3391,11 @@ export class GonvexClient {
|
|
|
2258
3391
|
if (kind === "reducer" && message.type === "reducer.error") {
|
|
2259
3392
|
settle();
|
|
2260
3393
|
this.emitTelemetryFromCall(kind, id, ref.path, "error", clientSentAtMs, message.trace, message.error);
|
|
2261
|
-
|
|
3394
|
+
const errorClass = reducerErrorClassFromMessage(message);
|
|
3395
|
+
reject(new GonvexClientError(message.error, {
|
|
3396
|
+
code: "server", path: ref.path, operation: kind,
|
|
3397
|
+
...(errorClass ? { errorClass, retryable: message.retryable ?? (errorClass === "transient" || errorClass === "unauthenticated") } : {}),
|
|
3398
|
+
}));
|
|
2262
3399
|
}
|
|
2263
3400
|
if (kind === "action" && message.type === "action.result") {
|
|
2264
3401
|
const complete = () => {
|
|
@@ -2485,6 +3622,9 @@ export class GonvexClient {
|
|
|
2485
3622
|
this.send(message);
|
|
2486
3623
|
}
|
|
2487
3624
|
hasControlPlaneWork() {
|
|
3625
|
+
for (const query of this.querySubscriptions.values())
|
|
3626
|
+
if (query.executionScope === "control")
|
|
3627
|
+
return true;
|
|
2488
3628
|
for (const query of this.oneShotQueries.values())
|
|
2489
3629
|
if (query.scope === "control")
|
|
2490
3630
|
return true;
|
|
@@ -2615,6 +3755,21 @@ export class GonvexClient {
|
|
|
2615
3755
|
if (this.manuallyClosed)
|
|
2616
3756
|
return Promise.reject(new Error("Gonvex client is closed"));
|
|
2617
3757
|
this.connect();
|
|
3758
|
+
if (!this.activeAuthFrameId) {
|
|
3759
|
+
return new Promise((resolve, reject) => {
|
|
3760
|
+
this.pendingNativeErrors.push({ type, payload, resolve, reject });
|
|
3761
|
+
});
|
|
3762
|
+
}
|
|
3763
|
+
return this.sendNativeErrorFrame(type, payload);
|
|
3764
|
+
}
|
|
3765
|
+
flushPendingNativeErrors() {
|
|
3766
|
+
if (!this.activeAuthFrameId || this.pendingNativeErrors.length === 0)
|
|
3767
|
+
return;
|
|
3768
|
+
for (const pending of this.pendingNativeErrors.splice(0)) {
|
|
3769
|
+
void this.sendNativeErrorFrame(pending.type, pending.payload).then(pending.resolve, pending.reject);
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
sendNativeErrorFrame(type, payload) {
|
|
2618
3773
|
const id = randomID();
|
|
2619
3774
|
return new Promise((resolve, reject) => {
|
|
2620
3775
|
const timer = setTimeout(() => {
|
|
@@ -2663,11 +3818,13 @@ export class GonvexClient {
|
|
|
2663
3818
|
this.managedAuthAttempt?.ids.add(id);
|
|
2664
3819
|
this.sendNow({
|
|
2665
3820
|
type: "auth",
|
|
3821
|
+
...(this.clientContract ? { clientContract: this.clientContract.version } : {}),
|
|
2666
3822
|
id,
|
|
2667
3823
|
token: this.auth.token,
|
|
2668
3824
|
project: this.auth.project,
|
|
2669
3825
|
tenant: this.auth.tenant,
|
|
2670
3826
|
controlOnly: !this.auth.tenant,
|
|
3827
|
+
...(this.publicTenant ? { public: true } : {}),
|
|
2671
3828
|
device: browserTelemetryInfo(),
|
|
2672
3829
|
capabilities: { replicaReadyMany: 1, replicaWatermark: 1, queryPagePatch: 1, queryObjectPatch: 1, queryOrderDelta: 1, queryFanout: 1, queryResultBatch: 1 },
|
|
2673
3830
|
});
|
|
@@ -2805,7 +3962,19 @@ export class GonvexClient {
|
|
|
2805
3962
|
socket.send(JSON.stringify(message));
|
|
2806
3963
|
return;
|
|
2807
3964
|
}
|
|
2808
|
-
this
|
|
3965
|
+
// A replaced socket re-sends its own work; don't route this message
|
|
3966
|
+
// onto the newer connection a second time.
|
|
3967
|
+
if (this.socket !== socket)
|
|
3968
|
+
return;
|
|
3969
|
+
// Public control calls (such as the token exchange) renew the
|
|
3970
|
+
// session that the pending auth is waiting for. Queueing them behind
|
|
3971
|
+
// that auth deadlocks the connection, so keep their auth bypass.
|
|
3972
|
+
const id = "id" in message ? message.id : undefined;
|
|
3973
|
+
const pending = id ? this.pendingCalls.get(id) ?? this.oneShotQueries.get(id) : undefined;
|
|
3974
|
+
if ("scope" in message && message.scope === "control" && pending?.authorization === "public")
|
|
3975
|
+
this.sendNow(message);
|
|
3976
|
+
else
|
|
3977
|
+
this.send(message);
|
|
2809
3978
|
}, { once: true });
|
|
2810
3979
|
return;
|
|
2811
3980
|
}
|
|
@@ -2860,15 +4029,33 @@ function replaceOfflineLiveQueryMetadata(result, path, offline) {
|
|
|
2860
4029
|
const current = result[head];
|
|
2861
4030
|
return { ...result, [head]: replaceOfflineLiveQueryMetadata(current ?? null, tail, offline) };
|
|
2862
4031
|
}
|
|
4032
|
+
// Generated references and their plans are immutable module metadata. Retain
|
|
4033
|
+
// one canonical contract per reference, not a fresh sorted serialization for
|
|
4034
|
+
// every React watch/read. Weak keys do not retain replaced modules or callers.
|
|
4035
|
+
const subscriptionContracts = new WeakMap();
|
|
2863
4036
|
function querySubscriptionKey(ref, args) {
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
:
|
|
2870
|
-
|
|
2871
|
-
|
|
4037
|
+
let cached = subscriptionContracts.get(ref);
|
|
4038
|
+
if (!cached || cached.scope !== ref.scope || cached.delivery !== ref.delivery || cached.live !== ref.live) {
|
|
4039
|
+
const contract = {
|
|
4040
|
+
scope: ref.scope ?? "tenant",
|
|
4041
|
+
delivery: ref.delivery ?? "oneShot",
|
|
4042
|
+
live: ref.live
|
|
4043
|
+
? { entity: ref.live.entity, key: ref.live.key, resultPath: [...(ref.live.resultPath ?? [])], plan: ref.live.plan ?? null }
|
|
4044
|
+
: null,
|
|
4045
|
+
};
|
|
4046
|
+
cached = { scope: ref.scope, delivery: ref.delivery, live: ref.live, value: stableStringify(contract), keys: new Map() };
|
|
4047
|
+
subscriptionContracts.set(ref, cached);
|
|
4048
|
+
}
|
|
4049
|
+
const argumentsKey = `${ref.path}\u0000${stableStringify(args)}`;
|
|
4050
|
+
const previous = cached.keys.get(argumentsKey);
|
|
4051
|
+
if (previous !== undefined)
|
|
4052
|
+
return previous;
|
|
4053
|
+
const key = `${argumentsKey}\u0000${cached.value}`;
|
|
4054
|
+
// Search text and pagination may produce unbounded argument combinations.
|
|
4055
|
+
if (cached.keys.size >= 16)
|
|
4056
|
+
cached.keys.delete(cached.keys.keys().next().value);
|
|
4057
|
+
cached.keys.set(argumentsKey, key);
|
|
4058
|
+
return key;
|
|
2872
4059
|
}
|
|
2873
4060
|
function countPendingCalls(calls, kind) {
|
|
2874
4061
|
let count = 0;
|
|
@@ -2878,9 +4065,96 @@ function countPendingCalls(calls, kind) {
|
|
|
2878
4065
|
}
|
|
2879
4066
|
return count;
|
|
2880
4067
|
}
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
4068
|
+
/**
|
|
4069
|
+
* How the outbox treats one failed delivery. Runtimes before
|
|
4070
|
+
* 0.5.2-staging.15 send no class: their server errors stay rejections, as
|
|
4071
|
+
* before, except the two cases the legacy runtime could only express in prose.
|
|
4072
|
+
*/
|
|
4073
|
+
function deliveryErrorClass(error) {
|
|
4074
|
+
if (!(error instanceof GonvexClientError))
|
|
4075
|
+
return "transient";
|
|
4076
|
+
switch (error.code) {
|
|
4077
|
+
case "server": return error.errorClass ?? "rejected";
|
|
4078
|
+
case "timeout": return "transient";
|
|
4079
|
+
default: return "network";
|
|
4080
|
+
}
|
|
4081
|
+
}
|
|
4082
|
+
const legacyUnauthenticatedReducerError = "authenticate with an active tenant before calling a Reducer";
|
|
4083
|
+
function reducerErrorClassFromMessage(message) {
|
|
4084
|
+
if (message.class === "rejected" || message.class === "transient" || message.class === "update_required" || message.class === "unauthenticated") {
|
|
4085
|
+
return message.class;
|
|
4086
|
+
}
|
|
4087
|
+
// Legacy runtimes: the only structured signals were these literal messages.
|
|
4088
|
+
if (/STALE_REDUCER_ARTIFACT|CLIENT_UPDATE_REQUIRED/.test(message.error))
|
|
4089
|
+
return "update_required";
|
|
4090
|
+
if (message.error === legacyUnauthenticatedReducerError)
|
|
4091
|
+
return "unauthenticated";
|
|
4092
|
+
return undefined;
|
|
4093
|
+
}
|
|
4094
|
+
function positiveOr(value, fallback) {
|
|
4095
|
+
return typeof value === "number" && value > 0 ? value : fallback;
|
|
4096
|
+
}
|
|
4097
|
+
function summarizeArgs(args) {
|
|
4098
|
+
let text;
|
|
4099
|
+
try {
|
|
4100
|
+
text = JSON.stringify(args) ?? "";
|
|
4101
|
+
}
|
|
4102
|
+
catch {
|
|
4103
|
+
text = String(args);
|
|
4104
|
+
}
|
|
4105
|
+
return text.length > 160 ? `${text.slice(0, 157)}...` : text;
|
|
4106
|
+
}
|
|
4107
|
+
function outboxIntentFromEntry(entry) {
|
|
4108
|
+
const entities = new Map();
|
|
4109
|
+
for (const patch of entry.patches ?? []) {
|
|
4110
|
+
const entity = patch.entity ?? patch.collection;
|
|
4111
|
+
if (!entity)
|
|
4112
|
+
continue;
|
|
4113
|
+
entities.set(`${entity}\u0000${patch.rowId}`, { entity, id: patch.rowId });
|
|
4114
|
+
}
|
|
4115
|
+
return {
|
|
4116
|
+
id: entry.idempotencyKey,
|
|
4117
|
+
entryId: entry.id,
|
|
4118
|
+
reducer: entry.path,
|
|
4119
|
+
state: entry.state,
|
|
4120
|
+
attempts: entry.attempts,
|
|
4121
|
+
...(entry.lastError !== undefined ? { lastError: entry.lastError } : {}),
|
|
4122
|
+
...(entry.lastErrorClass ? { errorClass: entry.lastErrorClass } : {}),
|
|
4123
|
+
createdAt: entry.createdAt,
|
|
4124
|
+
nextAttemptAt: entry.nextAttemptAt,
|
|
4125
|
+
...(entry.settledAt !== undefined ? { settledAt: entry.settledAt } : {}),
|
|
4126
|
+
args: entry.args,
|
|
4127
|
+
argsSummary: summarizeArgs(entry.args),
|
|
4128
|
+
entities: [...entities.values()],
|
|
4129
|
+
};
|
|
4130
|
+
}
|
|
4131
|
+
function sameIntents(left, right) {
|
|
4132
|
+
if (left.length !== right.length)
|
|
4133
|
+
return false;
|
|
4134
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
4135
|
+
const a = left[index], b = right[index];
|
|
4136
|
+
if (a.id !== b.id || a.entryId !== b.entryId || a.state !== b.state || a.attempts !== b.attempts
|
|
4137
|
+
|| a.lastError !== b.lastError || a.errorClass !== b.errorClass || a.nextAttemptAt !== b.nextAttemptAt
|
|
4138
|
+
|| a.settledAt !== b.settledAt || a.entities.length !== b.entities.length
|
|
4139
|
+
|| a.entities.some((entity, position) => entity.entity !== b.entities[position].entity || entity.id !== b.entities[position].id))
|
|
4140
|
+
return false;
|
|
4141
|
+
}
|
|
4142
|
+
return true;
|
|
4143
|
+
}
|
|
4144
|
+
/** Row status from a list of intents: failed > rejected > syncing. */
|
|
4145
|
+
export function entityStatusFromIntents(intents, entity, id) {
|
|
4146
|
+
let status;
|
|
4147
|
+
for (const intent of intents) {
|
|
4148
|
+
if (!intent.entities.some((candidate) => candidate.entity === entity && candidate.id === id))
|
|
4149
|
+
continue;
|
|
4150
|
+
if (intent.state === "failed")
|
|
4151
|
+
return "failed";
|
|
4152
|
+
if (intent.state === "rejected")
|
|
4153
|
+
status = "rejected";
|
|
4154
|
+
else if (intent.state !== "committed" && status === undefined)
|
|
4155
|
+
status = "syncing";
|
|
4156
|
+
}
|
|
4157
|
+
return status;
|
|
2884
4158
|
}
|
|
2885
4159
|
function reducerErrorMessage(error) {
|
|
2886
4160
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -2902,6 +4176,9 @@ function stableStringify(value) {
|
|
|
2902
4176
|
.join(",")}}`;
|
|
2903
4177
|
}
|
|
2904
4178
|
function utf8KeyCompare(left, right) {
|
|
4179
|
+
// ASCII keys have identical UTF-8 and JavaScript lexical ordering.
|
|
4180
|
+
if (!/[^\x00-\x7f]/.test(left) && !/[^\x00-\x7f]/.test(right))
|
|
4181
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
2905
4182
|
const leftBytes = new TextEncoder().encode(left);
|
|
2906
4183
|
const rightBytes = new TextEncoder().encode(right);
|
|
2907
4184
|
const length = Math.min(leftBytes.length, rightBytes.length);
|
|
@@ -2922,9 +4199,9 @@ function boundReplicaRows(rows, keyField, maxRows, maxBytes, orderBy, orderDirec
|
|
|
2922
4199
|
const key = replicaRowKeyValue(row, keyField);
|
|
2923
4200
|
if (!key || seen.has(key))
|
|
2924
4201
|
continue;
|
|
2925
|
-
const size = replicaJSONSize(row);
|
|
2926
4202
|
if (maxRows && kept.length >= maxRows)
|
|
2927
4203
|
break;
|
|
4204
|
+
const size = maxBytes ? replicaJSONSize(row) : 0;
|
|
2928
4205
|
if (maxBytes && bytes + size > maxBytes)
|
|
2929
4206
|
break;
|
|
2930
4207
|
kept.push(row);
|
|
@@ -2971,7 +4248,10 @@ function replicaRowKeyValue(value, keyField) {
|
|
|
2971
4248
|
return key === null || key === undefined ? "" : String(key);
|
|
2972
4249
|
}
|
|
2973
4250
|
function replicaJSONSize(value) {
|
|
2974
|
-
|
|
4251
|
+
// Property ordering cannot change the encoded length. Native serialization
|
|
4252
|
+
// avoids recursively allocating sorted key arrays and intermediate strings.
|
|
4253
|
+
const json = JSON.stringify(value).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
4254
|
+
return new TextEncoder().encode(json).byteLength;
|
|
2975
4255
|
}
|
|
2976
4256
|
function applyKeyedPatch(previous, patch) {
|
|
2977
4257
|
const rows = new Map();
|
|
@@ -3038,6 +4318,31 @@ function queryPatchRowKey(value) {
|
|
|
3038
4318
|
const candidate = value._id ?? value.id;
|
|
3039
4319
|
return typeof candidate === "string" || typeof candidate === "number" ? String(candidate) : "";
|
|
3040
4320
|
}
|
|
4321
|
+
function applyLocalPatches(snapshot, patches) {
|
|
4322
|
+
for (const patch of patches) {
|
|
4323
|
+
const entity = patch.entity ?? patch.collection;
|
|
4324
|
+
if (!entity)
|
|
4325
|
+
continue;
|
|
4326
|
+
const table = snapshot.tables[entity] ??= { complete: false, rows: [] };
|
|
4327
|
+
const index = table.rows.findIndex((row) => String(row._id ?? row.id) === patch.rowId);
|
|
4328
|
+
if (patch.op === "delete") {
|
|
4329
|
+
if (index >= 0)
|
|
4330
|
+
table.rows.splice(index, 1);
|
|
4331
|
+
}
|
|
4332
|
+
else if (index >= 0)
|
|
4333
|
+
table.rows[index] = { ...table.rows[index], ...structuredClone(patch.fields) };
|
|
4334
|
+
else if (patch.op !== "patch")
|
|
4335
|
+
table.rows.push(structuredClone(patch.fields));
|
|
4336
|
+
}
|
|
4337
|
+
}
|
|
4338
|
+
function localIdentityFromAuthResult(result) {
|
|
4339
|
+
if (!result || typeof result !== "object" || Array.isArray(result))
|
|
4340
|
+
return;
|
|
4341
|
+
const identity = result.localIdentity;
|
|
4342
|
+
if (!identity?.auth?.account?.id || !identity.tenant?.id || !identity.member?.id)
|
|
4343
|
+
return;
|
|
4344
|
+
return structuredClone(identity);
|
|
4345
|
+
}
|
|
3041
4346
|
function authFromOptions(options) {
|
|
3042
4347
|
return {
|
|
3043
4348
|
project: options.project,
|
|
@@ -3056,8 +4361,6 @@ function normalizeQuerySubscriptionRetentionMs(value) {
|
|
|
3056
4361
|
return Math.max(0, Math.min(5 * 60_000, Math.floor(value)));
|
|
3057
4362
|
}
|
|
3058
4363
|
function authIdentityKey(auth) {
|
|
3059
|
-
if (!auth.tenant)
|
|
3060
|
-
return "";
|
|
3061
4364
|
if (auth.token) {
|
|
3062
4365
|
const tokenIdentity = authIdentityKeyFromToken(auth);
|
|
3063
4366
|
if (tokenIdentity)
|
|
@@ -3067,12 +4370,12 @@ function authIdentityKey(auth) {
|
|
|
3067
4370
|
// token would supply, so both paths derive the same key for the same Account.
|
|
3068
4371
|
const hint = auth.identity;
|
|
3069
4372
|
if (hint && typeof hint.sub === "string" && hint.sub.trim()) {
|
|
3070
|
-
return [auth.project ?? "", auth.tenant, hint.iss ?? "", hint.sub].join("\u0000");
|
|
4373
|
+
return [auth.project ?? "", auth.tenant ?? "", hint.iss ?? "", hint.sub].join("\u0000");
|
|
3071
4374
|
}
|
|
3072
4375
|
return "";
|
|
3073
4376
|
}
|
|
3074
4377
|
function authIdentityKeyFromToken(auth) {
|
|
3075
|
-
if (!auth.token
|
|
4378
|
+
if (!auth.token)
|
|
3076
4379
|
return "";
|
|
3077
4380
|
const parts = auth.token.split(".");
|
|
3078
4381
|
if (parts.length < 2)
|
|
@@ -3085,7 +4388,7 @@ function authIdentityKeyFromToken(auth) {
|
|
|
3085
4388
|
return "";
|
|
3086
4389
|
return [
|
|
3087
4390
|
auth.project ?? "",
|
|
3088
|
-
auth.tenant,
|
|
4391
|
+
auth.tenant ?? "",
|
|
3089
4392
|
typeof payload.iss === "string" ? payload.iss : "",
|
|
3090
4393
|
payload.sub,
|
|
3091
4394
|
].join("\u0000");
|
|
@@ -3212,4 +4515,56 @@ function detectDeviceType(userAgent) {
|
|
|
3212
4515
|
return "mobile";
|
|
3213
4516
|
return "desktop";
|
|
3214
4517
|
}
|
|
4518
|
+
/**
|
|
4519
|
+
* Dependency tables of locally executing Reducers that stay resident. A
|
|
4520
|
+
* table qualifies when its unfiltered generated collection declares a small
|
|
4521
|
+
* enough `maxRows` (and `maxBytes`, when bounded). Collections without a
|
|
4522
|
+
* declared bound are never resident: their size is unknown.
|
|
4523
|
+
*/
|
|
4524
|
+
export function residentLocalDependencyTables(binding, policy = "bounded") {
|
|
4525
|
+
if (!binding?.localDependencies || policy === "off")
|
|
4526
|
+
return [];
|
|
4527
|
+
const bounds = policy === "bounded" ? {} : policy;
|
|
4528
|
+
const maxRows = bounds.maxRows ?? DEFAULT_LOCAL_DEPENDENCY_RESIDENT_MAX_ROWS;
|
|
4529
|
+
const maxBytes = bounds.maxBytes;
|
|
4530
|
+
const tables = new Set(Object.values(binding.localDependencies).flat());
|
|
4531
|
+
return [...tables].filter((table) => (binding.collections ?? []).some((reference) => {
|
|
4532
|
+
const replica = reference.replica;
|
|
4533
|
+
if (replica?.table !== table || Object.keys(replica.equalFilters ?? {}).length || replica.excludeWhenSet?.length)
|
|
4534
|
+
return false;
|
|
4535
|
+
if (!replica.maxRows || replica.maxRows > maxRows)
|
|
4536
|
+
return false;
|
|
4537
|
+
return maxBytes === undefined || (!!replica.maxBytes && replica.maxBytes <= maxBytes);
|
|
4538
|
+
})).sort();
|
|
4539
|
+
}
|
|
4540
|
+
/** Sign-in and account-only pages do not need reducer code loaded. */
|
|
4541
|
+
function deferredLocalExecutor(create) {
|
|
4542
|
+
let executor;
|
|
4543
|
+
let closed = false;
|
|
4544
|
+
const start = () => {
|
|
4545
|
+
if (closed)
|
|
4546
|
+
throw new Error("Local reducer executor is closed");
|
|
4547
|
+
return executor ??= create();
|
|
4548
|
+
};
|
|
4549
|
+
return {
|
|
4550
|
+
get ready() {
|
|
4551
|
+
try {
|
|
4552
|
+
return start().ready;
|
|
4553
|
+
}
|
|
4554
|
+
catch (error) {
|
|
4555
|
+
return Promise.reject(error);
|
|
4556
|
+
}
|
|
4557
|
+
},
|
|
4558
|
+
prepare: async (path) => start().prepare?.(path),
|
|
4559
|
+
execute: async (...args) => start().execute(...args),
|
|
4560
|
+
executeRead: async (...args) => {
|
|
4561
|
+
const instance = start();
|
|
4562
|
+
if (!instance.executeRead)
|
|
4563
|
+
throw new Error('Local executor does not support indexed reads');
|
|
4564
|
+
return instance.executeRead(...args);
|
|
4565
|
+
},
|
|
4566
|
+
replay: async (...args) => start().replay(...args),
|
|
4567
|
+
close: () => { closed = true; executor?.close(); },
|
|
4568
|
+
};
|
|
4569
|
+
}
|
|
3215
4570
|
//# sourceMappingURL=index.js.map
|