@gonvex/client 0.5.2-staging.2 → 0.5.2-staging.21
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 +348 -9
- package/dist/index.js +1576 -192
- package/dist/index.js.map +1 -1
- package/dist/indexeddb-read-view.d.ts +24 -0
- package/dist/indexeddb-read-view.js +164 -0
- package/dist/indexeddb-read-view.js.map +1 -0
- package/dist/indexeddb-replica.d.ts +41 -3
- package/dist/indexeddb-replica.js +405 -111
- package/dist/indexeddb-replica.js.map +1 -1
- package/dist/local-replica.d.ts +127 -4
- package/dist/local-replica.js +836 -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;
|
|
@@ -164,11 +213,19 @@ export class GonvexClient {
|
|
|
164
213
|
hasAuthoritativeReplicaScope = false;
|
|
165
214
|
replicaReady = Promise.resolve();
|
|
166
215
|
replicaFrames = Promise.resolve();
|
|
216
|
+
queuedReplicaWatermark;
|
|
167
217
|
processedReplicaWatermarkRevision = 0;
|
|
168
|
-
|
|
218
|
+
pendingReplicaFrames = [];
|
|
169
219
|
unsubscribeOutbox;
|
|
220
|
+
sharedOutboxStore;
|
|
221
|
+
unsubscribePeerOutbox;
|
|
222
|
+
unsubscribePeerReplica;
|
|
223
|
+
peerRefreshScheduled = false;
|
|
224
|
+
peerRefreshDirty = false;
|
|
170
225
|
unsubscribeBrowserOnline;
|
|
171
226
|
drainingOutbox = false;
|
|
227
|
+
/** A wake-up (timer, enqueue, reconnect) that arrived while a drain was running. */
|
|
228
|
+
outboxDrainRequested = false;
|
|
172
229
|
outboxDrainTimer;
|
|
173
230
|
sessionScopeHandlers = new Set();
|
|
174
231
|
errorReporter;
|
|
@@ -180,20 +237,68 @@ export class GonvexClient {
|
|
|
180
237
|
pendingCalls = new Map();
|
|
181
238
|
connectionStateHandlers = new Set();
|
|
182
239
|
supportCommandHandlers = new Set();
|
|
240
|
+
pendingNativeErrors = [];
|
|
183
241
|
isWebSocketConnected = false;
|
|
184
242
|
hasEverConnected = false;
|
|
185
243
|
connectionCount = 0;
|
|
186
244
|
timeouts;
|
|
245
|
+
clientContract;
|
|
246
|
+
updateRequiredHandler;
|
|
247
|
+
updateRequired = false;
|
|
248
|
+
lastOnlineAtMs = 0;
|
|
187
249
|
constructor(url, options = {}) {
|
|
188
250
|
this.url = url;
|
|
251
|
+
if (options.public) {
|
|
252
|
+
if (!options.public.tenant.trim() || !options.project?.trim())
|
|
253
|
+
throw new Error("Public connections require project and tenant");
|
|
254
|
+
if (options.token || options.fetchToken || options.identity || options.tenant)
|
|
255
|
+
throw new Error("Public connections cannot include session credentials or a separate tenant");
|
|
256
|
+
this.publicTenant = options.public.tenant.trim();
|
|
257
|
+
options = { ...options, clientContract: options.clientContract ?? options.localRuntime?.clientContract, tenant: this.publicTenant, localRuntime: undefined, localReplica: undefined, outbox: { enabled: false }, telemetry: false };
|
|
258
|
+
}
|
|
259
|
+
this.clientContract = options.clientContract ?? options.localRuntime?.clientContract;
|
|
260
|
+
this.updateRequiredHandler = options.onUpdateRequired;
|
|
261
|
+
this.localBinding = options.localRuntime;
|
|
262
|
+
for (const table of this.localBinding?.tables ?? []) {
|
|
263
|
+
const references = (this.localBinding?.collections ?? []).filter(ref => ref.replica?.table === table
|
|
264
|
+
&& !Object.keys(ref.replica.equalFilters ?? {}).length && !(ref.replica.excludeWhenSet?.length));
|
|
265
|
+
this.localTableBindings.set(table, { signatures: references.map(ref => querySubscriptionKey(ref, {})),
|
|
266
|
+
columns: [...new Set(references.flatMap(ref => ref.replica?.columns ?? []))] });
|
|
267
|
+
}
|
|
268
|
+
this.localExecutor = options.localRuntime ? deferredLocalExecutor(() => options.localRuntime.create()) : undefined;
|
|
269
|
+
this.residentDependencyTables = residentLocalDependencyTables(options.localRuntime, options.localDependencyResidency);
|
|
270
|
+
if (options.onLocalExecutionFallback)
|
|
271
|
+
this.localFallbackHandlers.add(options.onLocalExecutionFallback);
|
|
272
|
+
this.localStorage = options.localReplica?.storage;
|
|
189
273
|
this.auth = authFromOptions(options);
|
|
190
274
|
this.telemetryEnabled = options.telemetry === true;
|
|
191
275
|
this.querySubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.querySubscriptionRetentionMs);
|
|
192
276
|
this.replicaSubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.replicaSubscriptionRetentionMs);
|
|
277
|
+
this.sharedOutboxStore = options.outbox?.store?.shared ? options.outbox.store : undefined;
|
|
193
278
|
this.reducerOutbox = createReducerOutbox(options.outbox);
|
|
194
|
-
this.
|
|
279
|
+
this.outboxRetry = {
|
|
280
|
+
maxAttempts: positiveOr(options.outbox?.retry?.maxAttempts, DEFAULT_OUTBOX_MAX_ATTEMPTS),
|
|
281
|
+
maxBackoffMs: positiveOr(options.outbox?.retry?.maxBackoffMs, DEFAULT_OUTBOX_RETRY_MAX_BACKOFF_MS),
|
|
282
|
+
};
|
|
283
|
+
this.replica = new LocalReplica(options.localReplica?.storage, { maxResidentRows: options.localReplica?.maxResidentRows, maxResidentBytes: options.localReplica?.maxResidentBytes });
|
|
195
284
|
this.replicaView = createLocalReplicaView(this.replica);
|
|
285
|
+
if (this.localExecutor) {
|
|
286
|
+
let executionVersion = this.replica.executionVersion();
|
|
287
|
+
this.unsubscribeLocal = this.replica.subscribe(() => {
|
|
288
|
+
const next = this.replica.executionVersion();
|
|
289
|
+
if (next === executionVersion)
|
|
290
|
+
return;
|
|
291
|
+
executionVersion = next;
|
|
292
|
+
this.scheduleLocalReplay();
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
this.unsubscribePeerOutbox = this.sharedOutboxStore?.subscribePeer?.(() => this.refreshPeerOutbox());
|
|
296
|
+
this.unsubscribePeerReplica = this.localStorage?.subscribePeer?.(scope => {
|
|
297
|
+
if (scope === this.replicaScope)
|
|
298
|
+
this.refreshPeerOutbox();
|
|
299
|
+
});
|
|
196
300
|
this.unsubscribeOutbox = this.reducerOutbox.subscribe(() => {
|
|
301
|
+
this.refreshIntents();
|
|
197
302
|
void this.drainOutbox();
|
|
198
303
|
});
|
|
199
304
|
if (typeof globalThis.addEventListener === "function") {
|
|
@@ -214,7 +319,7 @@ export class GonvexClient {
|
|
|
214
319
|
this.outboxScope = initialScope;
|
|
215
320
|
this.replicaScope = ["awaiting-server-scope", this.url, this.outboxEphemeralScope].join("\u0000");
|
|
216
321
|
this.outboxScopeGeneration += 1;
|
|
217
|
-
this.
|
|
322
|
+
this.setReplicaReady(this.replica.activateScope(this.replicaScope, true));
|
|
218
323
|
this.outboxReady = Promise.resolve();
|
|
219
324
|
this.timeouts = {
|
|
220
325
|
queryTimeoutMs: options.timeouts?.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
|
|
@@ -229,6 +334,730 @@ export class GonvexClient {
|
|
|
229
334
|
transport: (type, payload) => this.sendNativeError(type, payload),
|
|
230
335
|
});
|
|
231
336
|
}
|
|
337
|
+
if (this.localBinding)
|
|
338
|
+
this.setOutboxReady(this.restoreLocalSession(initialScope, this.outboxScopeGeneration));
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Authoritative sync failures arrive after an offline call returned locally.
|
|
342
|
+
* The intent also stays in the outbox as `rejected` until it is discarded
|
|
343
|
+
* or retried, so a UI that mounts later can still show it.
|
|
344
|
+
*/
|
|
345
|
+
onReducerRejection(listener) {
|
|
346
|
+
this.reducerRejectionHandlers.add(listener);
|
|
347
|
+
return () => this.reducerRejectionHandlers.delete(listener);
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Observe every point where a locally executing Reducer degrades to a
|
|
351
|
+
* queued-only intent (no local result). The intent is still durable and is
|
|
352
|
+
* delivered to the server; this exists so apps can log and fix the missing
|
|
353
|
+
* local data. Returns an unsubscribe function.
|
|
354
|
+
*/
|
|
355
|
+
onLocalExecutionFallback(listener) {
|
|
356
|
+
this.localFallbackHandlers.add(listener);
|
|
357
|
+
return () => { this.localFallbackHandlers.delete(listener); };
|
|
358
|
+
}
|
|
359
|
+
reportLocalFallback(path, intentId, phase, error) {
|
|
360
|
+
if (!this.localFallbackHandlers.size)
|
|
361
|
+
return;
|
|
362
|
+
const incomplete = error instanceof Error && error.name === "IncompleteReplicaError";
|
|
363
|
+
const table = incomplete
|
|
364
|
+
? error.read?.table ?? /^Local replica for (.+?) is incomplete;/.exec(error.message)?.[1]
|
|
365
|
+
: undefined;
|
|
366
|
+
const event = {
|
|
367
|
+
path, intentId, phase, reason: incomplete ? "incompleteReplica" : "localError",
|
|
368
|
+
...(table ? { table } : {}),
|
|
369
|
+
error: error instanceof Error ? error.message : String(error),
|
|
370
|
+
};
|
|
371
|
+
for (const listener of this.localFallbackHandlers) {
|
|
372
|
+
try {
|
|
373
|
+
listener(event);
|
|
374
|
+
}
|
|
375
|
+
catch { /* A listener must not break delivery. */ }
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/** Every durable intent of the current identity, oldest first. */
|
|
379
|
+
async listIntents() {
|
|
380
|
+
await this.outboxReady;
|
|
381
|
+
return (await this.reducerOutbox.list(this.outboxScope)).map(outboxIntentFromEntry);
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Synchronous snapshot for external stores (React `useSyncExternalStore`).
|
|
385
|
+
* Kept current while at least one {@link subscribeIntents} listener exists.
|
|
386
|
+
*/
|
|
387
|
+
intentsSnapshot() {
|
|
388
|
+
return this.intentsSnapshotValue;
|
|
389
|
+
}
|
|
390
|
+
/** Observe intent changes; call {@link intentsSnapshot} for the new value. */
|
|
391
|
+
subscribeIntents(listener) {
|
|
392
|
+
this.intentListeners.add(listener);
|
|
393
|
+
if (this.intentListeners.size === 1)
|
|
394
|
+
this.refreshIntents();
|
|
395
|
+
return () => { this.intentListeners.delete(listener); };
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Delivery status for one row from the current snapshot: `failed` or
|
|
399
|
+
* `rejected` when an intent touching it needs attention, `syncing` while one
|
|
400
|
+
* is still queued, otherwise undefined.
|
|
401
|
+
*/
|
|
402
|
+
entityIntentStatus(entity, id) {
|
|
403
|
+
return entityStatusFromIntents(this.intentsSnapshotValue, entity, id);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Re-arm a `failed` or `rejected` intent with a fresh retry budget. The
|
|
407
|
+
* original idempotency key is reused, so an attempt that had actually
|
|
408
|
+
* committed is replayed by the server rather than applied twice.
|
|
409
|
+
*/
|
|
410
|
+
async retryIntent(id) {
|
|
411
|
+
await this.outboxReady;
|
|
412
|
+
const retried = await this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
|
|
413
|
+
const scope = this.outboxScope;
|
|
414
|
+
const entry = (await this.reducerOutbox.list(scope)).find((candidate) => candidate.idempotencyKey === id);
|
|
415
|
+
if (!entry || scope !== this.outboxScope)
|
|
416
|
+
return false;
|
|
417
|
+
const updated = await this.reducerOutbox.retry(entry.id);
|
|
418
|
+
if (!updated)
|
|
419
|
+
return false;
|
|
420
|
+
this.optimisticOutboxEntryIds.set(id, updated.id);
|
|
421
|
+
if (!outboxEntryIsLive(entry)) {
|
|
422
|
+
// A rejection rolled the prediction back; restore it at its original
|
|
423
|
+
// position in the causal chain.
|
|
424
|
+
if (updated.localExecution && this.localExecutor)
|
|
425
|
+
await this.rebaseLocalEntriesLocked(true);
|
|
426
|
+
else
|
|
427
|
+
this.addOptimisticReducer(id, updated.patches ?? []);
|
|
428
|
+
}
|
|
429
|
+
return true;
|
|
430
|
+
}));
|
|
431
|
+
if (retried) {
|
|
432
|
+
this.unauthenticatedRetries = 0;
|
|
433
|
+
void this.drainOutbox();
|
|
434
|
+
}
|
|
435
|
+
return retried;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Drop a `pending`, `failed` or `rejected` intent: it will never be sent,
|
|
439
|
+
* its optimistic prediction is removed and later local intents are rebased.
|
|
440
|
+
* Inflight and committed intents cannot be discarded. Discarding an intent
|
|
441
|
+
* whose earlier attempt timed out does not undo a commit that the server
|
|
442
|
+
* may already have applied; the replica then shows the server's truth.
|
|
443
|
+
*/
|
|
444
|
+
async discardIntent(id) {
|
|
445
|
+
await this.outboxReady;
|
|
446
|
+
return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
|
|
447
|
+
const scope = this.outboxScope;
|
|
448
|
+
const entry = (await this.reducerOutbox.list(scope)).find((candidate) => candidate.idempotencyKey === id);
|
|
449
|
+
if (!entry || scope !== this.outboxScope)
|
|
450
|
+
return false;
|
|
451
|
+
const removed = await this.reducerOutbox.discard(entry.id, ["pending", "failed", "rejected"]);
|
|
452
|
+
if (!removed)
|
|
453
|
+
return false;
|
|
454
|
+
this.optimisticOutboxEntryIds.delete(id);
|
|
455
|
+
this.optimisticReducerIds.delete(id);
|
|
456
|
+
if (outboxEntryIsLive(removed)) {
|
|
457
|
+
this.replacingLocal = true;
|
|
458
|
+
try {
|
|
459
|
+
this.replica.rejectCommand(id);
|
|
460
|
+
}
|
|
461
|
+
finally {
|
|
462
|
+
this.replacingLocal = false;
|
|
463
|
+
}
|
|
464
|
+
if (removed.localExecution && this.localExecutor)
|
|
465
|
+
await this.rebaseLocalEntriesLocked(true);
|
|
466
|
+
}
|
|
467
|
+
return true;
|
|
468
|
+
}));
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Discard the persisted Local Replica of the active identity (IndexedDB,
|
|
472
|
+
* Expo SQLite or any configured storage) and rehydrate it from the server.
|
|
473
|
+
*
|
|
474
|
+
* Online only: without an authenticated connection the call rejects with a
|
|
475
|
+
* `GonvexClientError` (`code: "disconnected"`) and changes nothing, so the
|
|
476
|
+
* user is never left with an empty cache that cannot be refilled. The saved
|
|
477
|
+
* offline session (identity and replica directive) is kept.
|
|
478
|
+
*
|
|
479
|
+
* By default the outbox is untouched: pending, failed and rejected intents
|
|
480
|
+
* stay durable, and the predictions of every live intent are re-applied
|
|
481
|
+
* immediately and recomputed as server data arrives. Active Replica
|
|
482
|
+
* Collections and Live Queries are re-requested without their old cursors,
|
|
483
|
+
* so the server sends full snapshots.
|
|
484
|
+
*/
|
|
485
|
+
async resetLocalReplica(options = {}) {
|
|
486
|
+
const refuse = (message) => new GonvexClientError(message, { code: "disconnected", operation: "query" });
|
|
487
|
+
if (this.manuallyClosed)
|
|
488
|
+
throw new GonvexClientError("Gonvex client is closed", { code: "closed", operation: "query" });
|
|
489
|
+
await this.outboxReady;
|
|
490
|
+
await this.replicaReady;
|
|
491
|
+
if (!this.canSendReducerNow() || !this.hasAuthoritativeReplicaScope) {
|
|
492
|
+
throw refuse("Cannot reset local data while offline. Reconnect and try again; cached data was kept.");
|
|
493
|
+
}
|
|
494
|
+
const keepOutbox = options.keepOutbox !== false;
|
|
495
|
+
return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
|
|
496
|
+
const scope = this.outboxScope;
|
|
497
|
+
const replicaScope = this.replicaScope;
|
|
498
|
+
// Re-check under the lock: the connection may have dropped while waiting.
|
|
499
|
+
if (!this.canSendReducerNow() || !this.hasAuthoritativeReplicaScope) {
|
|
500
|
+
throw refuse("Cannot reset local data while offline. Reconnect and try again; cached data was kept.");
|
|
501
|
+
}
|
|
502
|
+
// Stop the server streaming into subscription ids we are about to retire.
|
|
503
|
+
for (const subscription of this.replicaSubscriptions.values()) {
|
|
504
|
+
if (subscription.socketGeneration !== undefined)
|
|
505
|
+
this.send({ type: "replica.close", id: subscription.id });
|
|
506
|
+
}
|
|
507
|
+
for (const subscription of this.querySubscriptions.values()) {
|
|
508
|
+
if (subscription.socketGeneration !== undefined)
|
|
509
|
+
this.send({ type: "query.unsubscribe", id: subscription.id });
|
|
510
|
+
}
|
|
511
|
+
this.pendingReplicaFrames.length = 0;
|
|
512
|
+
await this.replica.clear(replicaScope);
|
|
513
|
+
this.resetReplicaScopeState();
|
|
514
|
+
this.rotateSubscriptionScopes();
|
|
515
|
+
let discardedIntents = 0;
|
|
516
|
+
if (!keepOutbox) {
|
|
517
|
+
for (const entry of await this.reducerOutbox.list(scope)) {
|
|
518
|
+
if (await this.reducerOutbox.discard(entry.id, ["pending", "failed", "rejected"]))
|
|
519
|
+
discardedIntents += 1;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
// replica.clear() dropped every overlay. Restore the stored predictions
|
|
523
|
+
// of live intents now; local intents are re-executed by the replay that
|
|
524
|
+
// each arriving snapshot schedules.
|
|
525
|
+
const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
|
|
526
|
+
this.replacingLocal = true;
|
|
527
|
+
try {
|
|
528
|
+
this.replaceLocalPredictions(entries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
|
|
529
|
+
}
|
|
530
|
+
finally {
|
|
531
|
+
this.replacingLocal = false;
|
|
532
|
+
}
|
|
533
|
+
this.optimisticReducerIds.clear();
|
|
534
|
+
this.optimisticOutboxEntryIds.clear();
|
|
535
|
+
for (const entry of entries) {
|
|
536
|
+
this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
|
|
537
|
+
if (entry.state !== "committed" && entry.patches?.length)
|
|
538
|
+
this.optimisticReducerIds.add(entry.idempotencyKey);
|
|
539
|
+
}
|
|
540
|
+
let resubscribed = 0;
|
|
541
|
+
if (scope === this.outboxScope && replicaScope === this.replicaScope) {
|
|
542
|
+
for (const subscription of this.replicaSubscriptions.values())
|
|
543
|
+
if (subscription.listeners.size > 0)
|
|
544
|
+
resubscribed += 1;
|
|
545
|
+
for (const subscription of this.querySubscriptions.values())
|
|
546
|
+
if (subscription.listeners.size > 0)
|
|
547
|
+
resubscribed += 1;
|
|
548
|
+
this.resumeQuerySubscriptions(true);
|
|
549
|
+
this.resumeReplicaSubscriptions();
|
|
550
|
+
}
|
|
551
|
+
this.refreshIntents();
|
|
552
|
+
return { discardedIntents, resubscribed };
|
|
553
|
+
}));
|
|
554
|
+
}
|
|
555
|
+
/** Outbox owners with durable entries, including identities that never returned. */
|
|
556
|
+
async listOutboxScopes() {
|
|
557
|
+
await this.outboxReady;
|
|
558
|
+
const current = this.outboxScope;
|
|
559
|
+
return (await this.reducerOutbox.listScopes()).map((scope) => ({ ...scope, current: scope.scope === current }));
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Permanently delete another identity's durable intents. The active scope
|
|
563
|
+
* cannot be purged this way; use {@link discardIntent} for its entries.
|
|
564
|
+
*/
|
|
565
|
+
async purgeOutboxScope(scope) {
|
|
566
|
+
await this.outboxReady;
|
|
567
|
+
if (scope === this.outboxScope)
|
|
568
|
+
throw new Error("Cannot purge the active outbox scope");
|
|
569
|
+
return this.reducerOutbox.purgeScope(scope);
|
|
570
|
+
}
|
|
571
|
+
/** Purge every outbox scope except the current identity's. Never runs automatically. */
|
|
572
|
+
async purgeForeignOutboxScopes() {
|
|
573
|
+
let removed = 0;
|
|
574
|
+
for (const scope of await this.listOutboxScopes()) {
|
|
575
|
+
if (!scope.current)
|
|
576
|
+
removed += await this.purgeOutboxScope(scope.scope);
|
|
577
|
+
}
|
|
578
|
+
return removed;
|
|
579
|
+
}
|
|
580
|
+
refreshIntents() {
|
|
581
|
+
if (this.intentListeners.size === 0 || this.manuallyClosed)
|
|
582
|
+
return;
|
|
583
|
+
this.intentsRefreshDirty = true;
|
|
584
|
+
if (this.intentsRefreshRunning)
|
|
585
|
+
return;
|
|
586
|
+
this.intentsRefreshRunning = true;
|
|
587
|
+
void (async () => {
|
|
588
|
+
try {
|
|
589
|
+
while (this.intentsRefreshDirty && !this.manuallyClosed) {
|
|
590
|
+
this.intentsRefreshDirty = false;
|
|
591
|
+
await this.outboxReady.catch(() => undefined);
|
|
592
|
+
const scope = this.outboxScope;
|
|
593
|
+
const entries = await this.reducerOutbox.list(scope).catch(() => undefined);
|
|
594
|
+
if (!entries || scope !== this.outboxScope) {
|
|
595
|
+
this.intentsRefreshDirty ||= scope !== this.outboxScope;
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
this.publishIntents(entries.map(outboxIntentFromEntry));
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
finally {
|
|
602
|
+
this.intentsRefreshRunning = false;
|
|
603
|
+
}
|
|
604
|
+
})();
|
|
605
|
+
}
|
|
606
|
+
publishIntents(next) {
|
|
607
|
+
if (sameIntents(this.intentsSnapshotValue, next))
|
|
608
|
+
return;
|
|
609
|
+
this.intentsSnapshotValue = next;
|
|
610
|
+
for (const listener of this.intentListeners) {
|
|
611
|
+
try {
|
|
612
|
+
listener();
|
|
613
|
+
}
|
|
614
|
+
catch { /* A listener must not break delivery. */ }
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
async restoreLocalSession(scope, generation) {
|
|
618
|
+
if (!this.localBinding || !scope.startsWith("identity\u0000"))
|
|
619
|
+
return;
|
|
620
|
+
const saved = await this.localStorage?.loadSession?.(scope);
|
|
621
|
+
if (!saved || generation !== this.outboxScopeGeneration || scope !== this.outboxScope || this.hasAuthoritativeReplicaScope)
|
|
622
|
+
return;
|
|
623
|
+
this.lastOnlineAtMs = saved.lastOnlineAtMs ?? 0;
|
|
624
|
+
this.localIdentity = saved.identity;
|
|
625
|
+
void this.localExecutor?.ready.catch(() => undefined);
|
|
626
|
+
await this.activateReplicaDirective(saved.directive);
|
|
627
|
+
this.replica.setFreshness("offline");
|
|
628
|
+
}
|
|
629
|
+
ensureLocalCollections(tables) {
|
|
630
|
+
if (!this.localBinding)
|
|
631
|
+
return;
|
|
632
|
+
const needed = new Set(tables);
|
|
633
|
+
for (const reference of this.localBinding.collections ?? []) {
|
|
634
|
+
if (!reference.replica || !needed.has(reference.replica.table))
|
|
635
|
+
continue;
|
|
636
|
+
const key = querySubscriptionKey(reference, {});
|
|
637
|
+
if (this.localCollectionKeys.has(key))
|
|
638
|
+
continue;
|
|
639
|
+
this.localCollectionKeys.add(key);
|
|
640
|
+
this.localCollectionClosers.push(this.subscribeReplicaTransport(reference, {}, () => undefined, true));
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
hydrateMissingLocalTable(error) {
|
|
644
|
+
if (!(error instanceof Error) || error.name !== "IncompleteReplicaError")
|
|
645
|
+
return;
|
|
646
|
+
// Worker errors preserve the public runtime name/message contract.
|
|
647
|
+
const table = /^Local replica for (.+?) is incomplete;/.exec(error.message)?.[1];
|
|
648
|
+
if (table)
|
|
649
|
+
this.ensureLocalCollections([table]);
|
|
650
|
+
}
|
|
651
|
+
localSnapshot(includePending) {
|
|
652
|
+
const rows = this.replica.captureExecutionRows(includePending);
|
|
653
|
+
const tables = {};
|
|
654
|
+
for (const [table, binding] of this.localTableBindings) {
|
|
655
|
+
const complete = binding.signatures.some(signature => this.replica.windowIsComplete(signature));
|
|
656
|
+
tables[table] = { complete, columns: binding.columns, get rows() { return rows(table); } };
|
|
657
|
+
}
|
|
658
|
+
return { scope: this.replicaScope, tables };
|
|
659
|
+
}
|
|
660
|
+
localReadCoverage() {
|
|
661
|
+
return this.replica.captureReadCoverage(Object.fromEntries([...this.localTableBindings].map(([table, binding]) => [table, {
|
|
662
|
+
key: this.localBinding.collections?.find(ref => ref.replica?.table === table)?.replica?.key ?? '_id',
|
|
663
|
+
complete: binding.signatures.some(signature => this.replica.windowIsComplete(signature)), columns: binding.columns,
|
|
664
|
+
}])));
|
|
665
|
+
}
|
|
666
|
+
isUncoveredLocalRead(error) {
|
|
667
|
+
if (!(error instanceof Error) || error.name !== 'IncompleteReplicaError')
|
|
668
|
+
return false;
|
|
669
|
+
const read = error.read;
|
|
670
|
+
if (!read || read.limit === 0)
|
|
671
|
+
return false;
|
|
672
|
+
const known = this.localReadCoverage()[read.table];
|
|
673
|
+
// Disk can recover evicted primary keys or a complete registered slice.
|
|
674
|
+
// It cannot prove a scan over data that has never been delivered. Queue
|
|
675
|
+
// that intent for replay instead of waiting for unrelated cache writes.
|
|
676
|
+
return !readIsCovered(known, read) && primaryReadKeys(read, known?.key ?? '_id') === undefined;
|
|
677
|
+
}
|
|
678
|
+
async executeLocal(path, args, snapshot, execution, pending = true, residentOnly = false) {
|
|
679
|
+
if (this.localBinding?.mode === 'portable') {
|
|
680
|
+
try {
|
|
681
|
+
const run = (source) => this.localExecutor.executeRead(path, args, source, execution);
|
|
682
|
+
let transaction;
|
|
683
|
+
try {
|
|
684
|
+
transaction = await this.replica.withReadView(() => this.localReadCoverage(), pending, run, true);
|
|
685
|
+
}
|
|
686
|
+
catch (error) {
|
|
687
|
+
if (residentOnly || !(error instanceof Error) || error.name !== 'IncompleteReplicaError' || this.isUncoveredLocalRead(error))
|
|
688
|
+
throw error;
|
|
689
|
+
transaction = await this.replica.withReadView(() => this.localReadCoverage(), pending, run);
|
|
690
|
+
}
|
|
691
|
+
// The read view already proved every read from the available replica
|
|
692
|
+
// windows or cached primary keys. Do not promote those bounded reads
|
|
693
|
+
// to whole-table subscriptions after a successful user edit. Missing
|
|
694
|
+
// coverage still opens its generated collection in the error path.
|
|
695
|
+
return transaction;
|
|
696
|
+
}
|
|
697
|
+
catch (error) {
|
|
698
|
+
// A speculative memory-only read may simply need IndexedDB. Its
|
|
699
|
+
// coordinated retry checks disk before deciding to request more data.
|
|
700
|
+
if (!residentOnly)
|
|
701
|
+
this.hydrateMissingLocalTable(error);
|
|
702
|
+
throw error;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
if (!snapshot)
|
|
706
|
+
throw new Error('Legacy local executor requires a snapshot');
|
|
707
|
+
const known = this.localExecutionTables.get(path);
|
|
708
|
+
const input = known ? { ...snapshot, tables: Object.fromEntries(Object.entries(snapshot.tables).filter(([table]) => known.has(table))) } : snapshot;
|
|
709
|
+
let transaction;
|
|
710
|
+
try {
|
|
711
|
+
transaction = await this.localExecutor.execute(path, args, input, execution);
|
|
712
|
+
}
|
|
713
|
+
catch (error) {
|
|
714
|
+
this.hydrateMissingLocalTable(error);
|
|
715
|
+
if (!known || !(error instanceof Error) || error.name !== "IncompleteReplicaError")
|
|
716
|
+
throw error;
|
|
717
|
+
// A different argument, tenant or workflow can read different tables.
|
|
718
|
+
// Retry against the SAME captured snapshot, never newer mixed-version rows.
|
|
719
|
+
try {
|
|
720
|
+
transaction = await this.localExecutor.execute(path, args, snapshot, execution);
|
|
721
|
+
}
|
|
722
|
+
catch (retryError) {
|
|
723
|
+
this.hydrateMissingLocalTable(retryError);
|
|
724
|
+
throw retryError;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
this.ensureLocalCollections(transaction.readTables);
|
|
728
|
+
this.localExecutionTables.set(path, new Set([
|
|
729
|
+
...(known ?? []), ...transaction.readTables, ...transaction.patches.map(patch => patch.entity),
|
|
730
|
+
]));
|
|
731
|
+
return transaction;
|
|
732
|
+
}
|
|
733
|
+
inLocalLane(run) {
|
|
734
|
+
const job = this.localLane.then(run);
|
|
735
|
+
this.localLane = job.catch(() => undefined);
|
|
736
|
+
return job;
|
|
737
|
+
}
|
|
738
|
+
replaceLocalPredictions(commands) {
|
|
739
|
+
this.replica.replaceOptimistic(commands);
|
|
740
|
+
}
|
|
741
|
+
coordinateOutbox(lane, run) {
|
|
742
|
+
return this.sharedOutboxStore?.coordinate?.(this.outboxScope, lane, run) ?? run();
|
|
743
|
+
}
|
|
744
|
+
refreshPeerOutbox() {
|
|
745
|
+
if (this.manuallyClosed)
|
|
746
|
+
return;
|
|
747
|
+
this.peerRefreshDirty = true;
|
|
748
|
+
if (this.peerRefreshScheduled)
|
|
749
|
+
return;
|
|
750
|
+
this.peerRefreshScheduled = true;
|
|
751
|
+
void (async () => {
|
|
752
|
+
await this.outboxReady;
|
|
753
|
+
do {
|
|
754
|
+
this.peerRefreshDirty = false;
|
|
755
|
+
await this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
|
|
756
|
+
const scope = this.outboxScope;
|
|
757
|
+
await this.replica.synchronizeStorage();
|
|
758
|
+
const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
|
|
759
|
+
if (this.manuallyClosed || scope !== this.outboxScope)
|
|
760
|
+
return;
|
|
761
|
+
this.replaceLocalPredictions(entries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
|
|
762
|
+
this.optimisticReducerIds.clear();
|
|
763
|
+
this.optimisticOutboxEntryIds.clear();
|
|
764
|
+
for (const entry of entries) {
|
|
765
|
+
this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
|
|
766
|
+
if (entry.patches?.length)
|
|
767
|
+
this.optimisticReducerIds.add(entry.idempotencyKey);
|
|
768
|
+
}
|
|
769
|
+
}));
|
|
770
|
+
} while (this.peerRefreshDirty && !this.manuallyClosed);
|
|
771
|
+
this.refreshIntents();
|
|
772
|
+
void this.drainOutbox();
|
|
773
|
+
})().catch(() => undefined).finally(() => { this.peerRefreshScheduled = false; });
|
|
774
|
+
}
|
|
775
|
+
scheduleLocalReplay() {
|
|
776
|
+
if (!this.localExecutor || this.replacingLocal || this.localReplayScheduled || this.manuallyClosed || !this.localIdentity)
|
|
777
|
+
return;
|
|
778
|
+
// Restore and peer notifications discover durable intents explicitly. A
|
|
779
|
+
// table arriving at startup cannot invalidate a prediction that does not exist.
|
|
780
|
+
if (this.optimisticOutboxEntryIds.size === 0)
|
|
781
|
+
return;
|
|
782
|
+
this.localReplayScheduled = true;
|
|
783
|
+
queueMicrotask(() => {
|
|
784
|
+
void (async () => {
|
|
785
|
+
await this.outboxReady;
|
|
786
|
+
await this.inLocalLane(() => this.rebaseLocalEntries());
|
|
787
|
+
})().catch(() => undefined).finally(() => { this.localReplayScheduled = false; });
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
reportLocalRejection(reducerId, path, error) {
|
|
791
|
+
const message = reducerErrorMessage(error);
|
|
792
|
+
for (const listener of this.reducerRejectionHandlers) {
|
|
793
|
+
try {
|
|
794
|
+
listener({ reducerId, path, error: message, errorClass: "rejected" });
|
|
795
|
+
}
|
|
796
|
+
catch { /* A listener must not break delivery. */ }
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
rebaseLocalEntries(afterRejection = false) {
|
|
800
|
+
return this.coordinateOutbox("intent", () => this.rebaseLocalEntriesLocked(afterRejection));
|
|
801
|
+
}
|
|
802
|
+
async rebaseLocalEntriesLocked(afterRejection = false) {
|
|
803
|
+
if (!this.localExecutor || !this.localBinding || !this.localIdentity || !this.hasAuthoritativeReplicaScope)
|
|
804
|
+
return;
|
|
805
|
+
// The authoritative drain validates the pending chain in order. Keep its
|
|
806
|
+
// predictions while it progresses, then rebase once it settles or loses
|
|
807
|
+
// connectivity instead of replaying N + (N-1) + ... local transactions.
|
|
808
|
+
if (!afterRejection && this.drainingOutbox && this.canSendReducerNow())
|
|
809
|
+
return;
|
|
810
|
+
await this.replica.synchronizeStorage();
|
|
811
|
+
const scope = this.outboxScope;
|
|
812
|
+
const replicaScope = this.replicaScope;
|
|
813
|
+
// Rejected records keep their payload for the UI but are never predicted.
|
|
814
|
+
// Failed (parked) intents stay predicted until retried or discarded.
|
|
815
|
+
const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
|
|
816
|
+
if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
|
|
817
|
+
return;
|
|
818
|
+
if (entries.length === 0) {
|
|
819
|
+
this.replacingLocal = true;
|
|
820
|
+
try {
|
|
821
|
+
this.replaceLocalPredictions([]);
|
|
822
|
+
}
|
|
823
|
+
finally {
|
|
824
|
+
this.replacingLocal = false;
|
|
825
|
+
}
|
|
826
|
+
this.optimisticReducerIds.clear();
|
|
827
|
+
this.optimisticOutboxEntryIds.clear();
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const baseVersion = this.replica.executionVersion();
|
|
831
|
+
const needsExecution = entries.some(entry => entry.localExecution && entry.state !== "inflight" && entry.state !== "committed");
|
|
832
|
+
const snapshot = needsExecution && this.localBinding.mode !== 'portable' ? this.localSnapshot(false) : undefined;
|
|
833
|
+
const replayPatches = [];
|
|
834
|
+
const commands = [];
|
|
835
|
+
for (const entry of entries) {
|
|
836
|
+
// Predictions can wait; a fresh user intent must not sit behind the
|
|
837
|
+
// entire durable backlog. Publish only a complete, coherent rebase.
|
|
838
|
+
if (this.waitingLocalEdits > 0) {
|
|
839
|
+
setTimeout(() => this.scheduleLocalReplay(), 0);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
let patches = entry.patches ?? [];
|
|
843
|
+
if (entry.localExecution && entry.state !== "inflight" && entry.state !== "committed") {
|
|
844
|
+
const execution = { ...entry.localExecution, scope: replicaScope, identity: this.localIdentity, artifactHash: this.localBinding.artifactHash };
|
|
845
|
+
try {
|
|
846
|
+
const transaction = await this.executeLocal(entry.path, entry.args, snapshot, execution, replayPatches);
|
|
847
|
+
patches = transaction.patches;
|
|
848
|
+
if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
catch (error) {
|
|
852
|
+
// Re-execution is a prediction. Only an authoritative server rejection
|
|
853
|
+
// removes a durable intent; missing data or local validation can make
|
|
854
|
+
// its predicted transaction unavailable without rejecting the intent.
|
|
855
|
+
if (patches.length && scope === this.outboxScope && replicaScope === this.replicaScope)
|
|
856
|
+
this.reportLocalFallback(entry.path, entry.idempotencyKey, "replay", error);
|
|
857
|
+
patches = [];
|
|
858
|
+
}
|
|
859
|
+
if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
|
|
860
|
+
return;
|
|
861
|
+
// Storage failures must abort rebasing, never masquerade as rejection.
|
|
862
|
+
await this.reducerOutbox.updateLocal(entry.id, patches, execution);
|
|
863
|
+
}
|
|
864
|
+
if (snapshot)
|
|
865
|
+
applyLocalPatches(snapshot, patches);
|
|
866
|
+
for (const patch of patches)
|
|
867
|
+
replayPatches.push(patch.op === 'delete'
|
|
868
|
+
? { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: 'delete' }
|
|
869
|
+
: { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: patch.op === 'upsert' ? 'insert' : patch.op, fields: patch.fields });
|
|
870
|
+
commands.push({ commandId: entry.idempotencyKey, patches });
|
|
871
|
+
}
|
|
872
|
+
if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
|
|
873
|
+
return;
|
|
874
|
+
if (baseVersion !== this.replica.executionVersion()) {
|
|
875
|
+
// A changing server base must not recursively occupy the local lane.
|
|
876
|
+
// Release it so user intents already queued can run before the retry.
|
|
877
|
+
setTimeout(() => this.scheduleLocalReplay(), 0);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
this.replacingLocal = true;
|
|
881
|
+
try {
|
|
882
|
+
this.replaceLocalPredictions(commands);
|
|
883
|
+
}
|
|
884
|
+
finally {
|
|
885
|
+
this.replacingLocal = false;
|
|
886
|
+
}
|
|
887
|
+
this.optimisticReducerIds.clear();
|
|
888
|
+
this.optimisticOutboxEntryIds.clear();
|
|
889
|
+
for (const entry of entries) {
|
|
890
|
+
this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
|
|
891
|
+
}
|
|
892
|
+
for (const command of commands)
|
|
893
|
+
if (command.patches.length)
|
|
894
|
+
this.optimisticReducerIds.add(command.commandId);
|
|
895
|
+
}
|
|
896
|
+
async runLocalReducer(ref, args) {
|
|
897
|
+
args = structuredClone(args);
|
|
898
|
+
const admittedGeneration = this.outboxScopeGeneration;
|
|
899
|
+
await this.outboxReady;
|
|
900
|
+
await this.replicaReady;
|
|
901
|
+
if (!this.localExecutor || !this.localBinding)
|
|
902
|
+
throw new Error("This reducer requires the generated Gonvex client with local execution enabled");
|
|
903
|
+
const reducerId = randomID();
|
|
904
|
+
const execution = this.localIdentity ? {
|
|
905
|
+
scope: this.replicaScope, commandId: reducerId, now: Date.now(),
|
|
906
|
+
intentEntropy: Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join(''),
|
|
907
|
+
artifactHash: this.localBinding.artifactHash, identity: structuredClone(this.localIdentity),
|
|
908
|
+
} : undefined;
|
|
909
|
+
let preparedResult;
|
|
910
|
+
let preparedFailure;
|
|
911
|
+
const prepared = this.preparationLane.then(async () => {
|
|
912
|
+
if (!execution || this.localBinding?.mode !== 'portable' || !this.hasAuthoritativeReplicaScope || this.manuallyClosed || this.updateRequired || admittedGeneration !== this.outboxScopeGeneration)
|
|
913
|
+
return;
|
|
914
|
+
if (this.clientContract && this.clientContract.offlineMaxAgeMs !== null && !this.canSendReducerNow() && (Date.now() - this.lastOnlineAtMs > this.clientContract.offlineMaxAgeMs || Date.now() < this.lastOnlineAtMs))
|
|
915
|
+
return;
|
|
916
|
+
const base = this.replica.executionVersion();
|
|
917
|
+
const pending = this.replica.pendingReducerPatches();
|
|
918
|
+
try {
|
|
919
|
+
const transaction = await this.executeLocal(ref.path, args, undefined, execution, pending, true);
|
|
920
|
+
if (base !== this.replica.executionVersion() || admittedGeneration !== this.outboxScopeGeneration || this.manuallyClosed)
|
|
921
|
+
return;
|
|
922
|
+
preparedResult = { transaction, base, pending: JSON.stringify(pending) };
|
|
923
|
+
}
|
|
924
|
+
catch (error) {
|
|
925
|
+
preparedFailure = error; /* Recheck coverage under coordinated admission. */
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
this.preparationLane = prepared.catch(() => undefined);
|
|
929
|
+
this.waitingLocalEdits += 1;
|
|
930
|
+
let entered = false;
|
|
931
|
+
return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
|
|
932
|
+
entered = true;
|
|
933
|
+
this.waitingLocalEdits -= 1;
|
|
934
|
+
await prepared;
|
|
935
|
+
if (admittedGeneration !== this.outboxScopeGeneration)
|
|
936
|
+
throw new GonvexClientError("Session changed before local reducer execution", { code: "superseded" });
|
|
937
|
+
if (!this.localIdentity || !this.hasAuthoritativeReplicaScope)
|
|
938
|
+
throw new Error("Restore an authenticated local session before editing offline");
|
|
939
|
+
if (this.manuallyClosed)
|
|
940
|
+
throw new Error("Gonvex client is closed");
|
|
941
|
+
if (this.updateRequired)
|
|
942
|
+
throw new Error("Application update required before editing");
|
|
943
|
+
if (this.clientContract && this.clientContract.offlineMaxAgeMs !== null && !this.canSendReducerNow() && (Date.now() - this.lastOnlineAtMs > this.clientContract.offlineMaxAgeMs || Date.now() < this.lastOnlineAtMs))
|
|
944
|
+
throw new Error("Offline editing window expired. Reconnect before making more changes.");
|
|
945
|
+
const scope = this.outboxScope;
|
|
946
|
+
if (!execution || execution.scope !== this.replicaScope)
|
|
947
|
+
throw new GonvexClientError("Session changed before local reducer execution", { code: 'superseded' });
|
|
948
|
+
const sharedEntries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
|
|
949
|
+
const sharedPatches = sharedEntries?.filter(entry => entry.state !== "committed").flatMap(entry => (entry.patches ?? []).map(patch => patch.op === "delete"
|
|
950
|
+
? { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: "delete" }
|
|
951
|
+
: { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: patch.op === "upsert" ? "insert" : patch.op, fields: patch.fields }));
|
|
952
|
+
// A fully resident prediction reads the already published atomic replica.
|
|
953
|
+
// Unrelated snapshots may still be persisting; waiting for those here
|
|
954
|
+
// makes a small edit inherit their disk latency. The shared intent lock
|
|
955
|
+
// and journal still order every edit. Preparation proves resident
|
|
956
|
+
// coverage, even when a published commit invalidates its result while
|
|
957
|
+
// acquiring the lock. Re-execute against the latest atomic resident
|
|
958
|
+
// snapshot below; only known peer changes or committed shared intents
|
|
959
|
+
// require synchronizing storage first.
|
|
960
|
+
const canExecuteResident = preparedResult
|
|
961
|
+
&& !this.peerRefreshDirty && !this.peerRefreshScheduled
|
|
962
|
+
&& !sharedEntries.some(entry => entry.state === "committed");
|
|
963
|
+
const uncoveredRead = this.isUncoveredLocalRead(preparedFailure)
|
|
964
|
+
&& !this.peerRefreshDirty && !this.peerRefreshScheduled
|
|
965
|
+
&& !sharedEntries.some(entry => entry.state === "committed");
|
|
966
|
+
if (!canExecuteResident && !uncoveredRead)
|
|
967
|
+
await this.replica.synchronizeStorage();
|
|
968
|
+
if (sharedEntries)
|
|
969
|
+
this.replaceLocalPredictions(sharedEntries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
|
|
970
|
+
const executionBaseVersion = this.replica.executionVersion();
|
|
971
|
+
let transaction;
|
|
972
|
+
let fallback;
|
|
973
|
+
try {
|
|
974
|
+
if (preparedResult && preparedResult.base === executionBaseVersion && preparedResult.pending === JSON.stringify(sharedPatches)) {
|
|
975
|
+
transaction = preparedResult.transaction;
|
|
976
|
+
}
|
|
977
|
+
else if (this.localBinding.mode === 'portable') {
|
|
978
|
+
// A commit may invalidate preparation while the intent lock is being
|
|
979
|
+
// acquired. Read the newly published atomic replica again before
|
|
980
|
+
// joining disk work that arrived after synchronizeStorage completed.
|
|
981
|
+
try {
|
|
982
|
+
transaction = await this.executeLocal(ref.path, args, undefined, execution, sharedPatches ?? true, true);
|
|
983
|
+
}
|
|
984
|
+
catch (error) {
|
|
985
|
+
if (!(error instanceof Error) || error.name !== 'IncompleteReplicaError')
|
|
986
|
+
throw error;
|
|
987
|
+
transaction = await this.executeLocal(ref.path, args, undefined, execution, sharedPatches ?? true);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
else {
|
|
991
|
+
transaction = await this.executeLocal(ref.path, args, this.localSnapshot(true), execution, sharedPatches ?? true);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
catch (error) {
|
|
995
|
+
if (!(error instanceof Error) || error.name !== "IncompleteReplicaError")
|
|
996
|
+
throw error;
|
|
997
|
+
fallback = error;
|
|
998
|
+
}
|
|
999
|
+
if (scope !== this.outboxScope || execution.scope !== this.replicaScope)
|
|
1000
|
+
throw new GonvexClientError("Session changed during local reducer execution", { code: "superseded" });
|
|
1001
|
+
const patches = transaction?.patches ?? [];
|
|
1002
|
+
this.directOutboxReducerIds.add(reducerId);
|
|
1003
|
+
try {
|
|
1004
|
+
// A visible prediction must already have a durable intent. Otherwise a
|
|
1005
|
+
// reload immediately after paint can lose an offline edit. Local
|
|
1006
|
+
// execution still avoids a server round trip; disk admission is bounded
|
|
1007
|
+
// to this intent, rather than persisting the whole replica.
|
|
1008
|
+
const entry = await this.reducerOutbox.enqueue({
|
|
1009
|
+
scope, path: ref.path, args, idempotencyKey: reducerId, state: "pending",
|
|
1010
|
+
// All local intents form one causal chain, including reads of rows
|
|
1011
|
+
// written by a previous intent, not only overlapping write patches.
|
|
1012
|
+
entityKeys: ["__gonvex_local_intents"], patches, localExecution: execution,
|
|
1013
|
+
});
|
|
1014
|
+
if (scope !== this.outboxScope || execution.scope !== this.replicaScope) {
|
|
1015
|
+
throw new GonvexClientError("Session changed while persisting local reducer", { code: "superseded" });
|
|
1016
|
+
}
|
|
1017
|
+
this.optimisticOutboxEntryIds.set(reducerId, entry.id);
|
|
1018
|
+
this.replacingLocal = true;
|
|
1019
|
+
try {
|
|
1020
|
+
this.addOptimisticReducer(reducerId, patches);
|
|
1021
|
+
}
|
|
1022
|
+
finally {
|
|
1023
|
+
this.replacingLocal = false;
|
|
1024
|
+
}
|
|
1025
|
+
if (!transaction) {
|
|
1026
|
+
this.reportLocalFallback(ref.path, reducerId, "call", fallback);
|
|
1027
|
+
return { status: "queued", reducerId };
|
|
1028
|
+
}
|
|
1029
|
+
return transaction.result;
|
|
1030
|
+
}
|
|
1031
|
+
catch (error) {
|
|
1032
|
+
this.optimisticReducerIds.delete(reducerId);
|
|
1033
|
+
this.replica.rejectCommand(reducerId);
|
|
1034
|
+
throw error;
|
|
1035
|
+
}
|
|
1036
|
+
finally {
|
|
1037
|
+
this.directOutboxReducerIds.delete(reducerId);
|
|
1038
|
+
// This transaction already executed against all earlier predictions.
|
|
1039
|
+
// Appending it does not invalidate those predictions. Rebase only if
|
|
1040
|
+
// the authoritative execution snapshot changed during execution.
|
|
1041
|
+
// An uncovered read has no new data to replay against yet. Its
|
|
1042
|
+
// hydration subscription schedules replay when coverage arrives;
|
|
1043
|
+
// immediately retrying here only locks edits behind the same disk work.
|
|
1044
|
+
if (this.replica.executionVersion() !== executionBaseVersion)
|
|
1045
|
+
this.scheduleLocalReplay();
|
|
1046
|
+
void this.drainOutbox();
|
|
1047
|
+
}
|
|
1048
|
+
})).catch(error => {
|
|
1049
|
+
if (!entered)
|
|
1050
|
+
this.waitingLocalEdits -= 1;
|
|
1051
|
+
this.optimisticReducerIds.delete(reducerId);
|
|
1052
|
+
this.replica.rejectCommand(reducerId);
|
|
1053
|
+
throw error;
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
/** Preload code for a mounted control without reading data or executing an intent. */
|
|
1057
|
+
prepareReducer(ref) {
|
|
1058
|
+
if (ref.localExecution !== 1 || this.manuallyClosed)
|
|
1059
|
+
return Promise.resolve();
|
|
1060
|
+
return this.localExecutor?.prepare?.(ref.path) ?? Promise.resolve();
|
|
232
1061
|
}
|
|
233
1062
|
/** The single normalized authoritative + optimistic application data store. */
|
|
234
1063
|
get localReplica() {
|
|
@@ -245,19 +1074,33 @@ export class GonvexClient {
|
|
|
245
1074
|
replicaEntities(entity, ids) {
|
|
246
1075
|
return this.replica.entityBatch(entity, ids);
|
|
247
1076
|
}
|
|
1077
|
+
/** Retain only the data an active view observes; cold rows remain on disk. */
|
|
1078
|
+
retainReplicaEntities(entity, ids) { return this.replica.retainRows(entity, ids); }
|
|
1079
|
+
retainReplicaWindow(signature) { return this.replica.retainWindow(signature); }
|
|
248
1080
|
/** Read rows and server-owned completeness for a persisted Replica Collection. */
|
|
249
1081
|
replicaCollectionState(ref, args = {}) {
|
|
250
1082
|
return this.replica.collectionState(this.replicaSignature(ref, args));
|
|
251
1083
|
}
|
|
252
1084
|
/** Run the generated Live Query plan over the bounded normalized cache. */
|
|
253
|
-
offlineLiveQuery(ref, args = {}) {
|
|
1085
|
+
offlineLiveQuery(ref, args = {}, options = {}) {
|
|
254
1086
|
if (!ref.live?.plan) {
|
|
255
1087
|
return { rows: [], completeness: "partial", supported: false, unsupportedOperator: "missingPlan" };
|
|
256
1088
|
}
|
|
257
1089
|
const queryArgs = isJsonRecord(args) ? args : {};
|
|
258
|
-
return runOfflineLiveQuery(this.replica.entityRows(ref.live.entity), ref.live.plan, queryArgs, this.replica.entityCompleteness(ref.live.entity))
|
|
1090
|
+
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), {
|
|
1091
|
+
context: {
|
|
1092
|
+
...(this.localIdentity?.member?.id ? { 'member.id': this.localIdentity.member.id } : {}),
|
|
1093
|
+
...(this.localIdentity?.auth.account?.id ? { 'account.id': this.localIdentity.auth.account.id } : {}),
|
|
1094
|
+
...(this.localIdentity?.tenant?.id ? { 'tenant.id': this.localIdentity.tenant.id } : {}),
|
|
1095
|
+
},
|
|
1096
|
+
relationRows: (table, where) => {
|
|
1097
|
+
const coverage = this.replica.captureReadCoverage({ [table]: { key: '_id', complete: false } });
|
|
1098
|
+
return { rows: this.replica.entityRows(table), completeness: this.replica.entityCompleteness(table) === 'complete'
|
|
1099
|
+
&& readIsCovered(coverage[table], { table, where: liveScopeCoverage(where, queryArgs) }) ? 'complete' : 'partial' };
|
|
1100
|
+
},
|
|
1101
|
+
});
|
|
259
1102
|
}
|
|
260
|
-
/** Number of reducers
|
|
1103
|
+
/** Number of reducers still queued for delivery (excludes failed and rejected intents). */
|
|
261
1104
|
async outboxCount() {
|
|
262
1105
|
await this.outboxReady;
|
|
263
1106
|
return this.reducerOutbox.count(this.outboxScope);
|
|
@@ -374,7 +1217,24 @@ export class GonvexClient {
|
|
|
374
1217
|
this.authErrorHandlers.delete(handler);
|
|
375
1218
|
};
|
|
376
1219
|
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Subscribe to rotated developer-mode reconnect credentials. The runtime
|
|
1222
|
+
* consumes each developer credential when it accepts a connection and
|
|
1223
|
+
* returns a single-use successor; the client already uses that successor
|
|
1224
|
+
* for its own reconnects. Auth providers subscribe so they can keep the
|
|
1225
|
+
* newest credential in tab-scoped storage and resume developer mode after a
|
|
1226
|
+
* page reload. The value is a secret: never log it or put it in a URL.
|
|
1227
|
+
*/
|
|
1228
|
+
onDeveloperCredential(handler) {
|
|
1229
|
+
this.developerCredentialHandlers.add(handler);
|
|
1230
|
+
return () => {
|
|
1231
|
+
this.developerCredentialHandlers.delete(handler);
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
377
1234
|
applyAuth(auth) {
|
|
1235
|
+
if (this.publicTenant && (auth.token || auth.fetchToken || auth.identity || auth.tenant !== undefined && auth.tenant !== this.publicTenant || auth.project !== undefined && auth.project !== this.auth.project)) {
|
|
1236
|
+
throw new Error("Public connection scope is fixed; create another client to sign in or change tenants");
|
|
1237
|
+
}
|
|
378
1238
|
const nextAuth = { ...this.auth, ...auth };
|
|
379
1239
|
const tokenScopeChanged = hasOwn(auth, "token")
|
|
380
1240
|
&& auth.token !== this.auth.token
|
|
@@ -388,7 +1248,7 @@ export class GonvexClient {
|
|
|
388
1248
|
|| (hasOwn(auth, "identity") && !sameAuthTokenIdentity(this.auth, nextAuth));
|
|
389
1249
|
if (scopeMayChange) {
|
|
390
1250
|
this.pendingMessages.length = 0;
|
|
391
|
-
this.
|
|
1251
|
+
this.pendingReplicaFrames.length = 0;
|
|
392
1252
|
this.rejectPendingCalls((call) => new GonvexClientError(`Authentication scope changed while waiting for ${call.kind} ${call.path}`, { code: "superseded", path: call.path, operation: call.kind }));
|
|
393
1253
|
for (const query of this.oneShotQueries.values()) {
|
|
394
1254
|
if (query.timeoutTimer)
|
|
@@ -416,12 +1276,20 @@ export class GonvexClient {
|
|
|
416
1276
|
}
|
|
417
1277
|
}
|
|
418
1278
|
connect() {
|
|
1279
|
+
if (this.updateRequired)
|
|
1280
|
+
return;
|
|
419
1281
|
if (this.socket && this.socket.readyState <= WebSocket.OPEN)
|
|
420
1282
|
return;
|
|
421
1283
|
const isReconnect = this.socket !== undefined;
|
|
422
1284
|
this.manuallyClosed = false;
|
|
423
1285
|
const generation = ++this.socketGeneration;
|
|
424
|
-
|
|
1286
|
+
let transportUrl = this.url;
|
|
1287
|
+
if (this.publicTenant) {
|
|
1288
|
+
const publicUrl = new URL(this.url);
|
|
1289
|
+
publicUrl.searchParams.set("public", "1");
|
|
1290
|
+
transportUrl = publicUrl.toString();
|
|
1291
|
+
}
|
|
1292
|
+
const socket = new WebSocket(transportUrl);
|
|
425
1293
|
this.socket = socket;
|
|
426
1294
|
socket.addEventListener("open", () => {
|
|
427
1295
|
if (this.socket !== socket)
|
|
@@ -461,7 +1329,7 @@ export class GonvexClient {
|
|
|
461
1329
|
// drop them too — flushing them after reconnect would fire writes whose
|
|
462
1330
|
// callers already saw a rejection.
|
|
463
1331
|
this.pendingMessages.length = 0;
|
|
464
|
-
this.
|
|
1332
|
+
this.pendingReplicaFrames.length = 0;
|
|
465
1333
|
// Reducers/actions must fail closed on transport loss: silently
|
|
466
1334
|
// replaying a non-idempotent write after reconnect is unsafe, and
|
|
467
1335
|
// leaving the promise pending hangs the caller forever.
|
|
@@ -504,6 +1372,10 @@ export class GonvexClient {
|
|
|
504
1372
|
}
|
|
505
1373
|
return;
|
|
506
1374
|
}
|
|
1375
|
+
if (message.type === "client.updateRequired") {
|
|
1376
|
+
this.requireClientUpdate(message.reason);
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
507
1379
|
if (message.type === "system.reload") {
|
|
508
1380
|
if (typeof message.artifactHash === "string") {
|
|
509
1381
|
this.activeArtifactHashValue = message.artifactHash;
|
|
@@ -520,7 +1392,7 @@ export class GonvexClient {
|
|
|
520
1392
|
// subscription. Rotate their routing IDs immediately so delayed
|
|
521
1393
|
// Replica errors from that discarded scope cannot reach current
|
|
522
1394
|
// React listeners.
|
|
523
|
-
this.
|
|
1395
|
+
this.pendingReplicaFrames.length = 0;
|
|
524
1396
|
this.quarantineReplicaScope();
|
|
525
1397
|
this.activeAuthFrameId = undefined;
|
|
526
1398
|
// If credentials are already being revalidated, that queued auth
|
|
@@ -537,7 +1409,7 @@ export class GonvexClient {
|
|
|
537
1409
|
this.sendAuth(true, { useFetcher: false });
|
|
538
1410
|
return;
|
|
539
1411
|
}
|
|
540
|
-
const fetcher = this.auth.fetchToken;
|
|
1412
|
+
const fetcher = this.publicTenant ? undefined : this.auth.fetchToken;
|
|
541
1413
|
if (fetcher) {
|
|
542
1414
|
this.authInFlight = true;
|
|
543
1415
|
this.authRetriedAfterError = true;
|
|
@@ -577,6 +1449,8 @@ export class GonvexClient {
|
|
|
577
1449
|
this.activeAuthFrameId = message.id;
|
|
578
1450
|
else
|
|
579
1451
|
this.activeAuthFrameId = undefined;
|
|
1452
|
+
if (message.type === "auth.result")
|
|
1453
|
+
this.flushPendingNativeErrors();
|
|
580
1454
|
if (this.authWatchdogTimer) {
|
|
581
1455
|
clearTimeout(this.authWatchdogTimer);
|
|
582
1456
|
this.authWatchdogTimer = undefined;
|
|
@@ -584,18 +1458,27 @@ export class GonvexClient {
|
|
|
584
1458
|
if (message.type === "auth.result") {
|
|
585
1459
|
const reauthenticatedSameSocket = this.authenticatedSocketGeneration === this.socketGeneration;
|
|
586
1460
|
this.authenticatedSocketGeneration = this.socketGeneration;
|
|
1461
|
+
this.lastOnlineAtMs = Date.now();
|
|
587
1462
|
this.activeArtifactHashValue = artifactHashFromAuthResult(message.result) ?? this.activeArtifactHashValue;
|
|
588
1463
|
const developerSessionToken = developerSessionTokenFromAuthResult(message.result);
|
|
589
1464
|
if (developerSessionToken) {
|
|
590
|
-
// The activation token is single-use.
|
|
591
|
-
//
|
|
1465
|
+
// The activation token is single-use. Use its rotating successor
|
|
1466
|
+
// for the next reconnect, and hand it to the provider that owns
|
|
1467
|
+
// developer mode so a reload can resume the same grant.
|
|
592
1468
|
this.auth = { ...this.auth, token: developerSessionToken, fetchToken: undefined };
|
|
1469
|
+
for (const handler of Array.from(this.developerCredentialHandlers))
|
|
1470
|
+
handler(developerSessionToken);
|
|
593
1471
|
}
|
|
594
1472
|
this.authRetriedAfterError = false;
|
|
595
1473
|
const directive = replicaDirectiveFromAuthResult(message.result);
|
|
1474
|
+
const localIdentity = localIdentityFromAuthResult(message.result);
|
|
1475
|
+
if (localIdentity) {
|
|
1476
|
+
this.localIdentity = localIdentity;
|
|
1477
|
+
void this.localExecutor?.ready.catch(() => undefined);
|
|
1478
|
+
}
|
|
596
1479
|
if (!directive) {
|
|
597
1480
|
this.authInFlight = false;
|
|
598
|
-
if (!this.auth.tenant) {
|
|
1481
|
+
if (!this.auth.tenant || this.publicTenant) {
|
|
599
1482
|
this.resumeQuerySubscriptions(reauthenticatedSameSocket);
|
|
600
1483
|
this.settleManagedAuthAttempt(message.id);
|
|
601
1484
|
}
|
|
@@ -612,12 +1495,18 @@ export class GonvexClient {
|
|
|
612
1495
|
}
|
|
613
1496
|
void this.activateReplicaDirective(directive)
|
|
614
1497
|
.then(() => this.activateOutboxScope())
|
|
1498
|
+
.then(async () => {
|
|
1499
|
+
if (localIdentity && this.localBinding)
|
|
1500
|
+
await this.localStorage?.saveSession?.(this.outboxScope, {
|
|
1501
|
+
directive, identity: localIdentity, artifactHash: this.activeArtifactHashValue ?? this.localBinding.artifactHash, lastOnlineAtMs: this.lastOnlineAtMs,
|
|
1502
|
+
});
|
|
1503
|
+
})
|
|
615
1504
|
.then(() => {
|
|
616
1505
|
// The accepted server identity is not usable until its durable
|
|
617
1506
|
// Replica partition has been activated locally. Only now may
|
|
618
1507
|
// tenant calls and subscriptions leave the client.
|
|
619
1508
|
this.authInFlight = false;
|
|
620
|
-
this.
|
|
1509
|
+
this.drainPendingReplicaFrames();
|
|
621
1510
|
// Authentication is replaced in-place on the same socket during
|
|
622
1511
|
// normal token rotation. The runtime clears its subscription
|
|
623
1512
|
// maps for every accepted auth frame, so same-generation Live
|
|
@@ -632,7 +1521,7 @@ export class GonvexClient {
|
|
|
632
1521
|
})
|
|
633
1522
|
.catch((error) => {
|
|
634
1523
|
this.authInFlight = false;
|
|
635
|
-
this.
|
|
1524
|
+
this.pendingReplicaFrames.length = 0;
|
|
636
1525
|
this.settleManagedAuthAttempt(message.id, error instanceof Error ? error.message : "Runtime returned an invalid Local Replica scope");
|
|
637
1526
|
this.rejectReplicaDirective(error);
|
|
638
1527
|
this.flushPendingMessages();
|
|
@@ -641,7 +1530,7 @@ export class GonvexClient {
|
|
|
641
1530
|
}
|
|
642
1531
|
else {
|
|
643
1532
|
this.authInFlight = false;
|
|
644
|
-
this.
|
|
1533
|
+
this.pendingReplicaFrames.length = 0;
|
|
645
1534
|
const fetcher = this.auth.fetchToken;
|
|
646
1535
|
if (fetcher && !this.authRetriedAfterError) {
|
|
647
1536
|
// The installed token was rejected — typically expired while the
|
|
@@ -671,7 +1560,7 @@ export class GonvexClient {
|
|
|
671
1560
|
// Replica frames carry no tenant/scope field. During auth renewal we
|
|
672
1561
|
// cannot safely attribute a late frame to either side of the switch.
|
|
673
1562
|
if (this.authInFlight) {
|
|
674
|
-
this.
|
|
1563
|
+
this.pendingReplicaFrames.push(message);
|
|
675
1564
|
return;
|
|
676
1565
|
}
|
|
677
1566
|
this.enqueueReplicaTransaction(message);
|
|
@@ -683,7 +1572,11 @@ export class GonvexClient {
|
|
|
683
1572
|
// stream. Keep both on the same client queue so a watermark cannot
|
|
684
1573
|
// advance the cursor past a transaction that was received first but
|
|
685
1574
|
// has not finished applying to durable Local Replica storage yet.
|
|
686
|
-
this.
|
|
1575
|
+
if (this.authInFlight) {
|
|
1576
|
+
this.pendingReplicaFrames.push(message);
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
this.enqueueReplicaWatermark(message.revision);
|
|
687
1580
|
}
|
|
688
1581
|
return;
|
|
689
1582
|
}
|
|
@@ -725,6 +1618,11 @@ export class GonvexClient {
|
|
|
725
1618
|
}
|
|
726
1619
|
close() {
|
|
727
1620
|
this.manuallyClosed = true;
|
|
1621
|
+
this.unsubscribeLocal?.();
|
|
1622
|
+
for (const close of this.localCollectionClosers.splice(0))
|
|
1623
|
+
close();
|
|
1624
|
+
this.localCollectionKeys.clear();
|
|
1625
|
+
this.localExecutor?.close();
|
|
728
1626
|
this.cancelManagedAuthAttempt("Gonvex client was closed during authentication.");
|
|
729
1627
|
if (isEphemeralOutboxScope(this.outboxScope)) {
|
|
730
1628
|
void this.reducerOutbox.clear(this.outboxScope);
|
|
@@ -760,12 +1658,15 @@ export class GonvexClient {
|
|
|
760
1658
|
this.outboxDrainTimer = undefined;
|
|
761
1659
|
}
|
|
762
1660
|
this.unsubscribeOutbox();
|
|
1661
|
+
this.unsubscribePeerOutbox?.();
|
|
1662
|
+
this.unsubscribePeerReplica?.();
|
|
763
1663
|
this.unsubscribeBrowserOnline?.();
|
|
764
1664
|
this.handlers.clear();
|
|
765
1665
|
this.querySubscriptions.clear();
|
|
766
1666
|
this.replicaSubscriptions.clear();
|
|
767
1667
|
this.sessionScopeHandlers.clear();
|
|
768
1668
|
this.authErrorHandlers.clear();
|
|
1669
|
+
this.developerCredentialHandlers.clear();
|
|
769
1670
|
// Invalidate any token fetch still in flight so its resolve can't touch
|
|
770
1671
|
// the closed client's caches.
|
|
771
1672
|
this.authFetchGeneration += 1;
|
|
@@ -777,6 +1678,10 @@ export class GonvexClient {
|
|
|
777
1678
|
this.notifyConnectionState();
|
|
778
1679
|
this.connectionStateHandlers.clear();
|
|
779
1680
|
this.supportCommandHandlers.clear();
|
|
1681
|
+
this.replica.dispose();
|
|
1682
|
+
const closedError = new Error("Gonvex client is closed");
|
|
1683
|
+
for (const pending of this.pendingNativeErrors.splice(0))
|
|
1684
|
+
pending.reject(closedError);
|
|
780
1685
|
if (!socket)
|
|
781
1686
|
return;
|
|
782
1687
|
socket.close();
|
|
@@ -1110,7 +2015,9 @@ export class GonvexClient {
|
|
|
1110
2015
|
// could overwrite a result already accepted on the same connection.
|
|
1111
2016
|
return subscription.revisionSocketGeneration !== this.socketGeneration;
|
|
1112
2017
|
}
|
|
1113
|
-
subscribeReplicaTransport(ref, args = {}, onMessage) {
|
|
2018
|
+
subscribeReplicaTransport(ref, args = {}, onMessage, metadataOnly = false) {
|
|
2019
|
+
if (metadataOnly)
|
|
2020
|
+
this.replicaMetadataListeners.add(onMessage);
|
|
1114
2021
|
this.connect();
|
|
1115
2022
|
const key = querySubscriptionKey(ref, args);
|
|
1116
2023
|
const existing = this.replicaSubscriptions.get(key);
|
|
@@ -1120,10 +2027,14 @@ export class GonvexClient {
|
|
|
1120
2027
|
existing.unsubscribeTimer = undefined;
|
|
1121
2028
|
}
|
|
1122
2029
|
existing.listeners.add(onMessage);
|
|
2030
|
+
// A reset may arrive during the retention grace period with no listeners.
|
|
2031
|
+
// Its reopen was correctly deferred; the next subscriber must resume it.
|
|
2032
|
+
if (!existing.isUpToDate && !existing.opening)
|
|
2033
|
+
this.sendReplicaOpen(existing);
|
|
1123
2034
|
if (existing.lastMessage) {
|
|
1124
2035
|
queueMicrotask(() => {
|
|
1125
2036
|
if (existing.listeners.has(onMessage) && existing.lastMessage) {
|
|
1126
|
-
onMessage(this.materializeReplicaMessage(existing, existing.lastMessage));
|
|
2037
|
+
onMessage(metadataOnly ? existing.lastMessage : this.materializeReplicaMessage(existing, existing.lastMessage));
|
|
1127
2038
|
}
|
|
1128
2039
|
});
|
|
1129
2040
|
}
|
|
@@ -1161,12 +2072,16 @@ export class GonvexClient {
|
|
|
1161
2072
|
return this.subscribeReplicaTransport(ref, args, onMessage);
|
|
1162
2073
|
}
|
|
1163
2074
|
/** Watch a bounded Replica Collection through the normalized Local Replica. */
|
|
1164
|
-
watchReplica(ref, args = {}) {
|
|
2075
|
+
watchReplica(ref, args = {}, options = {}) {
|
|
1165
2076
|
const key = querySubscriptionKey(ref, args);
|
|
1166
2077
|
const updateHandlers = new Set();
|
|
1167
2078
|
let latestError;
|
|
1168
2079
|
let snapshotVersion = -1;
|
|
1169
2080
|
let snapshotRows;
|
|
2081
|
+
const readRows = () => {
|
|
2082
|
+
const next = this.replica.liveQuerySnapshot(key).rows;
|
|
2083
|
+
return snapshotRows?.length === next.length && next.every((row, index) => row === snapshotRows[index]) ? snapshotRows : next;
|
|
2084
|
+
};
|
|
1170
2085
|
let stateVersion = -1;
|
|
1171
2086
|
let stateFreshness;
|
|
1172
2087
|
let stateIsUpToDate;
|
|
@@ -1178,45 +2093,57 @@ export class GonvexClient {
|
|
|
1178
2093
|
};
|
|
1179
2094
|
// Keep the ReplicaSubscription as transport/reconciliation state only. The
|
|
1180
2095
|
// 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;
|
|
2096
|
+
let stop;
|
|
2097
|
+
const start = () => {
|
|
2098
|
+
if (stop)
|
|
2099
|
+
return;
|
|
1201
2100
|
snapshotVersion = -1;
|
|
1202
|
-
snapshotRows = undefined;
|
|
1203
2101
|
stateVersion = -1;
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
2102
|
+
const releaseRows = this.replica.retainWindow(key);
|
|
2103
|
+
const unsubscribeTransport = this.subscribeReplicaTransport(ref, args, (message) => {
|
|
2104
|
+
if (message.type === "replica.error") {
|
|
2105
|
+
latestError = new Error(message.error);
|
|
2106
|
+
notify();
|
|
2107
|
+
}
|
|
2108
|
+
else if (message.type === "replica.syncing" || message.type === "replica.reset") {
|
|
2109
|
+
latestError = undefined;
|
|
2110
|
+
notify();
|
|
2111
|
+
}
|
|
2112
|
+
else if (message.type === "replica.ready") {
|
|
2113
|
+
latestError = undefined;
|
|
2114
|
+
notify();
|
|
2115
|
+
}
|
|
2116
|
+
else if (message.type === "replica.snapshot") {
|
|
2117
|
+
latestError = undefined;
|
|
2118
|
+
}
|
|
2119
|
+
}, true);
|
|
2120
|
+
const unsubscribeReplica = this.replica.subscribe(notify);
|
|
2121
|
+
const unsubscribeScope = this.onSessionScopeChange(() => {
|
|
2122
|
+
latestError = undefined;
|
|
2123
|
+
snapshotVersion = -1;
|
|
2124
|
+
snapshotRows = undefined;
|
|
2125
|
+
stateVersion = -1;
|
|
2126
|
+
stateFreshness = undefined;
|
|
2127
|
+
stateIsUpToDate = undefined;
|
|
2128
|
+
snapshotState = undefined;
|
|
2129
|
+
notify();
|
|
2130
|
+
});
|
|
2131
|
+
stop = () => { unsubscribeTransport(); unsubscribeReplica(); unsubscribeScope(); releaseRows(); stop = undefined; };
|
|
2132
|
+
};
|
|
2133
|
+
// React can discard a render before subscribing. Such watches must own no resources.
|
|
2134
|
+
if (!options.deferStart)
|
|
2135
|
+
start();
|
|
1209
2136
|
return {
|
|
1210
2137
|
localReplicaResult: () => {
|
|
1211
2138
|
if (latestError)
|
|
1212
2139
|
throw latestError;
|
|
1213
2140
|
if (!this.replica.hasLiveQuery(key))
|
|
1214
2141
|
return undefined;
|
|
1215
|
-
const version = this.replica.
|
|
2142
|
+
const version = this.replica.windowRowsVersion(key);
|
|
1216
2143
|
if (snapshotVersion === version)
|
|
1217
2144
|
return snapshotRows;
|
|
1218
2145
|
snapshotVersion = version;
|
|
1219
|
-
snapshotRows =
|
|
2146
|
+
snapshotRows = readRows();
|
|
1220
2147
|
return snapshotRows;
|
|
1221
2148
|
},
|
|
1222
2149
|
localReplicaState: () => {
|
|
@@ -1234,7 +2161,12 @@ export class GonvexClient {
|
|
|
1234
2161
|
stateVersion = version;
|
|
1235
2162
|
stateFreshness = freshness;
|
|
1236
2163
|
stateIsUpToDate = isUpToDate;
|
|
1237
|
-
const
|
|
2164
|
+
const rowsVersion = this.replica.windowRowsVersion(key);
|
|
2165
|
+
if (snapshotVersion !== rowsVersion) {
|
|
2166
|
+
snapshotVersion = rowsVersion;
|
|
2167
|
+
snapshotRows = readRows();
|
|
2168
|
+
}
|
|
2169
|
+
const state = this.replica.collectionState(key, snapshotRows);
|
|
1238
2170
|
snapshotState = {
|
|
1239
2171
|
...state,
|
|
1240
2172
|
isUpToDate,
|
|
@@ -1250,6 +2182,7 @@ export class GonvexClient {
|
|
|
1250
2182
|
isUpToDate: this.replicaSubscriptions.get(key)?.isUpToDate === true,
|
|
1251
2183
|
}),
|
|
1252
2184
|
onUpdate(handler) {
|
|
2185
|
+
start();
|
|
1253
2186
|
if (releaseTimer) {
|
|
1254
2187
|
clearTimeout(releaseTimer);
|
|
1255
2188
|
releaseTimer = undefined;
|
|
@@ -1267,9 +2200,7 @@ export class GonvexClient {
|
|
|
1267
2200
|
releaseTimer = undefined;
|
|
1268
2201
|
if (updateHandlers.size > 0)
|
|
1269
2202
|
return;
|
|
1270
|
-
|
|
1271
|
-
unsubscribeReplica();
|
|
1272
|
-
unsubscribeScope();
|
|
2203
|
+
stop?.();
|
|
1273
2204
|
}, 0);
|
|
1274
2205
|
};
|
|
1275
2206
|
},
|
|
@@ -1280,7 +2211,7 @@ export class GonvexClient {
|
|
|
1280
2211
|
* latest query result is retained only as the transport-shaped skeleton;
|
|
1281
2212
|
* its row window is always rebuilt from LocalReplica membership/entities.
|
|
1282
2213
|
*/
|
|
1283
|
-
watchLiveQuery(ref, args = {}) {
|
|
2214
|
+
watchLiveQuery(ref, args = {}, options = {}) {
|
|
1284
2215
|
const key = querySubscriptionKey(ref, args);
|
|
1285
2216
|
const updateHandlers = new Set();
|
|
1286
2217
|
let transportResult;
|
|
@@ -1300,39 +2231,51 @@ export class GonvexClient {
|
|
|
1300
2231
|
handler();
|
|
1301
2232
|
});
|
|
1302
2233
|
};
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
2234
|
+
let stop;
|
|
2235
|
+
const start = () => {
|
|
2236
|
+
if (stop)
|
|
2237
|
+
return;
|
|
2238
|
+
transportResult = undefined;
|
|
2239
|
+
snapshotToken = "";
|
|
2240
|
+
const releaseRows = this.replica.retainWindow(key);
|
|
2241
|
+
const unsubscribeQuery = this.subscribeLiveQuery(ref, args, (message) => {
|
|
2242
|
+
if (message.type === "query.result") {
|
|
2243
|
+
transportResult = message.result;
|
|
2244
|
+
transportGeneration += 1;
|
|
2245
|
+
latestError = undefined;
|
|
2246
|
+
// LocalReplica has already published its atomic window swap before
|
|
2247
|
+
// this callback is emitted, so this is the single initial UI wake-up.
|
|
2248
|
+
notify();
|
|
2249
|
+
}
|
|
2250
|
+
else if (message.type === "query.error") {
|
|
2251
|
+
latestError = new GonvexClientError(`Query ${ref.path} failed: ${message.error}`, {
|
|
2252
|
+
code: "server", path: ref.path, operation: "query",
|
|
2253
|
+
});
|
|
2254
|
+
notify();
|
|
2255
|
+
}
|
|
2256
|
+
});
|
|
2257
|
+
// During the initial query result, LocalReplica notifies before the
|
|
2258
|
+
// transport-shaped skeleton is installed above. Suppress that empty
|
|
2259
|
+
// intermediate wake-up; later transactions notify directly from the
|
|
2260
|
+
// normalized store.
|
|
2261
|
+
const unsubscribeReplica = this.replica.subscribe(() => {
|
|
2262
|
+
if (transportResult !== undefined || this.replica.hasLiveQuery(key))
|
|
2263
|
+
notify();
|
|
2264
|
+
});
|
|
2265
|
+
void this.replicaReady.then(() => notify());
|
|
2266
|
+
const unsubscribeScope = this.onSessionScopeChange(() => {
|
|
2267
|
+
transportResult = undefined;
|
|
1306
2268
|
transportGeneration += 1;
|
|
2269
|
+
snapshotToken = "";
|
|
2270
|
+
snapshotResult = undefined;
|
|
1307
2271
|
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
2272
|
notify();
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
//
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
const unsubscribeReplica = this.replica.subscribe(() => {
|
|
1324
|
-
if (transportResult !== undefined || this.replica.hasLiveQuery(key))
|
|
1325
|
-
notify();
|
|
1326
|
-
});
|
|
1327
|
-
void this.replicaReady.then(() => notify());
|
|
1328
|
-
const unsubscribeScope = this.onSessionScopeChange(() => {
|
|
1329
|
-
transportResult = undefined;
|
|
1330
|
-
transportGeneration += 1;
|
|
1331
|
-
snapshotToken = "";
|
|
1332
|
-
snapshotResult = undefined;
|
|
1333
|
-
latestError = undefined;
|
|
1334
|
-
notify();
|
|
1335
|
-
});
|
|
2273
|
+
});
|
|
2274
|
+
stop = () => { unsubscribeQuery(); unsubscribeReplica(); unsubscribeScope(); releaseRows(); stop = undefined; };
|
|
2275
|
+
};
|
|
2276
|
+
// React can discard a render before subscribing. Such watches must own no resources.
|
|
2277
|
+
if (!options.deferStart)
|
|
2278
|
+
start();
|
|
1336
2279
|
return {
|
|
1337
2280
|
localLiveQueryResult: () => {
|
|
1338
2281
|
if (latestError)
|
|
@@ -1367,6 +2310,7 @@ export class GonvexClient {
|
|
|
1367
2310
|
return snapshotResult;
|
|
1368
2311
|
},
|
|
1369
2312
|
onUpdate(handler) {
|
|
2313
|
+
start();
|
|
1370
2314
|
if (releaseTimer) {
|
|
1371
2315
|
clearTimeout(releaseTimer);
|
|
1372
2316
|
releaseTimer = undefined;
|
|
@@ -1384,9 +2328,7 @@ export class GonvexClient {
|
|
|
1384
2328
|
releaseTimer = undefined;
|
|
1385
2329
|
if (updateHandlers.size > 0)
|
|
1386
2330
|
return;
|
|
1387
|
-
|
|
1388
|
-
unsubscribeReplica();
|
|
1389
|
-
unsubscribeScope();
|
|
2331
|
+
stop?.();
|
|
1390
2332
|
}, 0);
|
|
1391
2333
|
};
|
|
1392
2334
|
},
|
|
@@ -1431,7 +2373,7 @@ export class GonvexClient {
|
|
|
1431
2373
|
scope,
|
|
1432
2374
|
};
|
|
1433
2375
|
await this.replica.replaceWindow(window);
|
|
1434
|
-
const snapshot = { ...message, result:
|
|
2376
|
+
const snapshot = { ...message, result: [] };
|
|
1435
2377
|
subscription.lastMessage = snapshot;
|
|
1436
2378
|
this.emitReplicaMessage(subscription, snapshot, scope);
|
|
1437
2379
|
return;
|
|
@@ -1442,12 +2384,20 @@ export class GonvexClient {
|
|
|
1442
2384
|
return;
|
|
1443
2385
|
this.clearReplicaRetry(subscription, true);
|
|
1444
2386
|
raiseReplicaCursorFloor(subscription, message.cursor);
|
|
2387
|
+
const upserts = (message.upserts ?? []).filter((row) => asReplicaRow(row) !== undefined).map(row => asReplicaRow(row));
|
|
2388
|
+
// Incremental integrity is safe only for full projected row images. Older
|
|
2389
|
+
// partial-row protocols keep the full normalized rehash on replica.ready.
|
|
2390
|
+
const canCarryHashes = prior?.hashes && subscription.columns?.length
|
|
2391
|
+
&& upserts.every(row => subscription.columns.every(column => hasOwn(row, column)));
|
|
2392
|
+
const hashes = message.hashes ?? (canCarryHashes
|
|
2393
|
+
? await applyReplicaHashDelta(prior.hashes, projectReplicaIntegrityRows(upserts, subscription.columns), message.deleted ?? [], prior?.key ?? "id")
|
|
2394
|
+
: undefined);
|
|
1445
2395
|
await this.replica.applyWindowDelta({
|
|
1446
2396
|
signature: subscription.key,
|
|
1447
2397
|
kind: "replica",
|
|
1448
2398
|
entity: subscription.entity,
|
|
1449
2399
|
key: prior?.key ?? "id",
|
|
1450
|
-
upserts
|
|
2400
|
+
upserts,
|
|
1451
2401
|
deleted: message.deleted ?? [],
|
|
1452
2402
|
completeness: prior?.completeness ?? "partial",
|
|
1453
2403
|
source: "server",
|
|
@@ -1458,13 +2408,11 @@ export class GonvexClient {
|
|
|
1458
2408
|
orderDirection: prior?.orderDirection,
|
|
1459
2409
|
maxRows: prior?.maxRows,
|
|
1460
2410
|
maxBytes: prior?.maxBytes,
|
|
1461
|
-
|
|
1462
|
-
// supplied a complete replacement map with this frame.
|
|
1463
|
-
hashes: message.hashes,
|
|
2411
|
+
hashes,
|
|
1464
2412
|
});
|
|
1465
2413
|
const snapshot = {
|
|
1466
2414
|
type: "replica.snapshot", id: subscription.id, path: subscription.path,
|
|
1467
|
-
result:
|
|
2415
|
+
result: [], cursor: message.cursor,
|
|
1468
2416
|
key: prior?.key ?? "id", mode: prior?.mode, orderBy: prior?.orderBy,
|
|
1469
2417
|
orderDirection: prior?.orderDirection, maxRows: prior?.maxRows, maxBytes: prior?.maxBytes,
|
|
1470
2418
|
};
|
|
@@ -1518,12 +2466,34 @@ export class GonvexClient {
|
|
|
1518
2466
|
}
|
|
1519
2467
|
this.emitReplicaMessage(subscription, message, scope);
|
|
1520
2468
|
}
|
|
1521
|
-
enqueueReplicaFrame(operation) {
|
|
2469
|
+
enqueueReplicaFrame(operation, watermark = false) {
|
|
2470
|
+
// Any entity/window frame seals the adjacent watermark batch. Advancing
|
|
2471
|
+
// across that barrier could make an unapplied transaction look stale.
|
|
2472
|
+
if (!watermark)
|
|
2473
|
+
this.queuedReplicaWatermark = undefined;
|
|
1522
2474
|
const processing = this.replicaFrames.then(operation);
|
|
1523
2475
|
this.replicaFrames = processing.catch(() => {
|
|
1524
2476
|
this.replica.setFreshness("verifying");
|
|
1525
2477
|
});
|
|
1526
2478
|
}
|
|
2479
|
+
enqueueReplicaWatermark(revision) {
|
|
2480
|
+
if (!Number.isSafeInteger(revision) || revision < 0)
|
|
2481
|
+
return;
|
|
2482
|
+
const pending = this.queuedReplicaWatermark;
|
|
2483
|
+
if (pending && pending.scope === this.replicaScope) {
|
|
2484
|
+
pending.revision = Math.max(pending.revision, revision);
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2487
|
+
const batch = { revision, scope: this.replicaScope };
|
|
2488
|
+
this.queuedReplicaWatermark = batch;
|
|
2489
|
+
this.enqueueReplicaFrame(async () => {
|
|
2490
|
+
if (this.queuedReplicaWatermark === batch)
|
|
2491
|
+
this.queuedReplicaWatermark = undefined;
|
|
2492
|
+
if (batch.scope !== this.replicaScope)
|
|
2493
|
+
return;
|
|
2494
|
+
await this.handleReplicaWatermark(batch.revision);
|
|
2495
|
+
}, true);
|
|
2496
|
+
}
|
|
1527
2497
|
enqueueReplicaTransaction(message) {
|
|
1528
2498
|
const scope = this.replicaScope;
|
|
1529
2499
|
this.enqueueReplicaFrame(async () => {
|
|
@@ -1542,10 +2512,14 @@ export class GonvexClient {
|
|
|
1542
2512
|
}
|
|
1543
2513
|
});
|
|
1544
2514
|
}
|
|
1545
|
-
|
|
1546
|
-
const pending = this.
|
|
1547
|
-
for (const message of pending)
|
|
1548
|
-
|
|
2515
|
+
drainPendingReplicaFrames() {
|
|
2516
|
+
const pending = this.pendingReplicaFrames.splice(0);
|
|
2517
|
+
for (const message of pending) {
|
|
2518
|
+
if (message.type === "replica.transaction")
|
|
2519
|
+
this.enqueueReplicaTransaction(message);
|
|
2520
|
+
else
|
|
2521
|
+
this.enqueueReplicaWatermark(message.revision);
|
|
2522
|
+
}
|
|
1549
2523
|
}
|
|
1550
2524
|
async acceptReplicaReady(subscription, message, hashes, hashesWereStored, scope = this.replicaScope) {
|
|
1551
2525
|
this.clearReplicaRetry(subscription, true);
|
|
@@ -1590,12 +2564,9 @@ export class GonvexClient {
|
|
|
1590
2564
|
return;
|
|
1591
2565
|
const eligibleSignatures = [];
|
|
1592
2566
|
for (const subscription of this.replicaSubscriptions.values()) {
|
|
1593
|
-
|
|
1594
|
-
if (!cursor
|
|
1595
|
-
|| cursor.revision >= revision
|
|
1596
|
-
|| !subscription.isUpToDate
|
|
2567
|
+
if (!subscription.isUpToDate
|
|
1597
2568
|
|| subscription.opening
|
|
1598
|
-
|| !this.replica.
|
|
2569
|
+
|| !this.replica.windowCanAdvance(subscription.key, revision))
|
|
1599
2570
|
continue;
|
|
1600
2571
|
eligibleSignatures.push(subscription.key);
|
|
1601
2572
|
}
|
|
@@ -1614,9 +2585,15 @@ export class GonvexClient {
|
|
|
1614
2585
|
emitReplicaMessage(subscription, message, scope = this.replicaScope) {
|
|
1615
2586
|
if (scope !== this.replicaScope || subscription.scope !== scope)
|
|
1616
2587
|
return;
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
2588
|
+
// Hook observers consume metadata only; their rows come from Local Replica.
|
|
2589
|
+
// Materialize a payload only when a public transport consumer needs it.
|
|
2590
|
+
let outgoing;
|
|
2591
|
+
for (const listener of Array.from(subscription.listeners)) {
|
|
2592
|
+
if (this.replicaMetadataListeners.has(listener))
|
|
2593
|
+
listener(message);
|
|
2594
|
+
else
|
|
2595
|
+
listener(outgoing ??= this.materializeReplicaMessage(subscription, message));
|
|
2596
|
+
}
|
|
1620
2597
|
}
|
|
1621
2598
|
materializeReplicaMessage(subscription, message) {
|
|
1622
2599
|
if (message.type !== "replica.snapshot")
|
|
@@ -1662,7 +2639,7 @@ export class GonvexClient {
|
|
|
1662
2639
|
subscription.isUpToDate = false;
|
|
1663
2640
|
const message = {
|
|
1664
2641
|
type: "replica.snapshot", id: subscription.id, path: subscription.path,
|
|
1665
|
-
result:
|
|
2642
|
+
result: [], cursor: cached.cursor ?? { epoch: "cache", revision: 0 },
|
|
1666
2643
|
key: cached.key, mode: cached.mode, orderBy: cached.orderBy,
|
|
1667
2644
|
orderDirection: cached.orderDirection, maxRows: cached.maxRows, maxBytes: cached.maxBytes,
|
|
1668
2645
|
};
|
|
@@ -1771,8 +2748,11 @@ export class GonvexClient {
|
|
|
1771
2748
|
this.outboxScope = scope;
|
|
1772
2749
|
const ready = this.hasAuthoritativeReplicaScope
|
|
1773
2750
|
? this.restoreOutbox(scope, generation)
|
|
1774
|
-
:
|
|
1775
|
-
this.
|
|
2751
|
+
: this.restoreLocalSession(scope, generation);
|
|
2752
|
+
this.setOutboxReady(ready);
|
|
2753
|
+
// Never show the previous identity's intents under the new one.
|
|
2754
|
+
this.publishIntents([]);
|
|
2755
|
+
this.refreshIntents();
|
|
1776
2756
|
return ready;
|
|
1777
2757
|
}
|
|
1778
2758
|
async activateReplicaDirective(directive) {
|
|
@@ -1795,8 +2775,11 @@ export class GonvexClient {
|
|
|
1795
2775
|
this.resetReplicaScopeState();
|
|
1796
2776
|
this.replicaScope = scope;
|
|
1797
2777
|
this.hasAuthoritativeReplicaScope = true;
|
|
1798
|
-
this.
|
|
2778
|
+
this.setReplicaReady(this.replica.activateScope(scope));
|
|
1799
2779
|
this.rotateSubscriptionScopes();
|
|
2780
|
+
// Subscriptions follow scope rotation, so this opens each resident
|
|
2781
|
+
// dependency collection once per client, from cache first when offline.
|
|
2782
|
+
this.ensureLocalCollections(this.residentDependencyTables);
|
|
1800
2783
|
const generation = this.outboxScopeGeneration;
|
|
1801
2784
|
// Publish the recovery barrier before yielding to Replica storage. A
|
|
1802
2785
|
// reducer may be invoked as soon as the auth result arrives, while the
|
|
@@ -1804,10 +2787,24 @@ export class GonvexClient {
|
|
|
1804
2787
|
// points at the old resolved promise, that reducer can enqueue an inflight
|
|
1805
2788
|
// row which the concurrent recovery then mistakes for an abandoned call
|
|
1806
2789
|
// and sends a second time with the same command ID.
|
|
1807
|
-
this.
|
|
2790
|
+
this.setOutboxReady(this.restoreOutbox(this.outboxScope, generation));
|
|
1808
2791
|
await this.outboxReady;
|
|
1809
2792
|
}
|
|
2793
|
+
// Scope activation rejects once the replica is disposed (for example when
|
|
2794
|
+
// an auth error quarantines a client that was already closed). Callers that
|
|
2795
|
+
// await replicaReady still see the rejection; the stored promise itself must
|
|
2796
|
+
// not surface as an unhandled rejection that kills Node processes.
|
|
2797
|
+
setReplicaReady(ready) {
|
|
2798
|
+
this.replicaReady = ready;
|
|
2799
|
+
ready.catch(() => undefined);
|
|
2800
|
+
}
|
|
2801
|
+
// Outbox restore awaits replicaReady, so it rejects the same way.
|
|
2802
|
+
setOutboxReady(ready) {
|
|
2803
|
+
this.outboxReady = ready;
|
|
2804
|
+
ready.catch(() => undefined);
|
|
2805
|
+
}
|
|
1810
2806
|
quarantineReplicaScope() {
|
|
2807
|
+
this.localIdentity = undefined;
|
|
1811
2808
|
// Keep the durable prior identity scope intact for an authorized future
|
|
1812
2809
|
// login, but make every synchronous selector fail closed immediately.
|
|
1813
2810
|
// The random suffix prevents a denied scope from ever restoring rows.
|
|
@@ -1819,7 +2816,7 @@ export class GonvexClient {
|
|
|
1819
2816
|
this.resetReplicaScopeState();
|
|
1820
2817
|
this.replicaScope = scope;
|
|
1821
2818
|
this.hasAuthoritativeReplicaScope = false;
|
|
1822
|
-
this.
|
|
2819
|
+
this.setReplicaReady(this.replica.activateScope(scope, true));
|
|
1823
2820
|
this.rotateSubscriptionScopes();
|
|
1824
2821
|
}
|
|
1825
2822
|
rejectMissingReplicaDirective() {
|
|
@@ -1832,12 +2829,26 @@ export class GonvexClient {
|
|
|
1832
2829
|
}
|
|
1833
2830
|
async restoreOutbox(scope, generation) {
|
|
1834
2831
|
await this.replicaReady;
|
|
2832
|
+
await this.replica.synchronizeStorage();
|
|
1835
2833
|
const entries = await this.reducerOutbox.loadAll(scope);
|
|
1836
2834
|
if (this.manuallyClosed
|
|
1837
2835
|
|| generation !== this.outboxScopeGeneration
|
|
1838
2836
|
|| scope !== this.outboxScope)
|
|
1839
2837
|
return;
|
|
1840
2838
|
for (const entry of entries) {
|
|
2839
|
+
// markCommitted follows the durable replica watermark barrier. Its
|
|
2840
|
+
// transaction is already in the restored base; never predict it twice.
|
|
2841
|
+
if (entry.localExecution && entry.state === "committed")
|
|
2842
|
+
await this.reducerOutbox.ack(entry.id);
|
|
2843
|
+
}
|
|
2844
|
+
if (entries.some((entry) => entry.localExecution) && this.localExecutor) {
|
|
2845
|
+
await this.inLocalLane(() => this.rebaseLocalEntries());
|
|
2846
|
+
}
|
|
2847
|
+
for (const entry of entries) {
|
|
2848
|
+
if (entry.localExecution && this.localExecutor)
|
|
2849
|
+
continue;
|
|
2850
|
+
if (!outboxEntryIsLive(entry))
|
|
2851
|
+
continue;
|
|
1841
2852
|
if (entry.state === "committed" && (entry.patches?.length ?? 0) === 0) {
|
|
1842
2853
|
await this.reducerOutbox.ack(entry.id);
|
|
1843
2854
|
continue;
|
|
@@ -1854,11 +2865,15 @@ export class GonvexClient {
|
|
|
1854
2865
|
// If this scope was installed after the socket authenticated, no reconnect
|
|
1855
2866
|
// or new enqueue may occur to wake the queue. The await inside drainOutbox
|
|
1856
2867
|
// yields until this restore promise resolves, then safely resumes it.
|
|
2868
|
+
this.refreshIntents();
|
|
1857
2869
|
void this.drainOutbox();
|
|
1858
2870
|
}
|
|
1859
2871
|
addOptimisticReducer(reducerId, patches, accepted = false) {
|
|
1860
|
-
if (patches.length === 0
|
|
2872
|
+
if (patches.length === 0) {
|
|
2873
|
+
this.optimisticReducerIds.delete(reducerId);
|
|
2874
|
+
this.replica.rejectCommand(reducerId);
|
|
1861
2875
|
return;
|
|
2876
|
+
}
|
|
1862
2877
|
this.optimisticReducerIds.add(reducerId);
|
|
1863
2878
|
this.replica.applyOptimistic(reducerId, patches);
|
|
1864
2879
|
}
|
|
@@ -1878,69 +2893,195 @@ export class GonvexClient {
|
|
|
1878
2893
|
await this.reducerOutbox.ack(entryId);
|
|
1879
2894
|
}
|
|
1880
2895
|
async drainOutbox() {
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
2896
|
+
if (this.publicTenant)
|
|
2897
|
+
return;
|
|
2898
|
+
// A failed restore is reported to whoever awaits the scope transition;
|
|
2899
|
+
// there is nothing to drain from a scope that never became ready.
|
|
2900
|
+
try {
|
|
2901
|
+
await this.outboxReady;
|
|
2902
|
+
}
|
|
2903
|
+
catch {
|
|
2904
|
+
return;
|
|
2905
|
+
}
|
|
2906
|
+
if (this.drainingOutbox) {
|
|
2907
|
+
// The running drain may already be past the entry this wake-up is for
|
|
2908
|
+
// (for example a short backoff timer firing before the drain returned).
|
|
2909
|
+
this.outboxDrainRequested = true;
|
|
2910
|
+
return;
|
|
2911
|
+
}
|
|
2912
|
+
if (this.manuallyClosed || !this.canSendReducerNow())
|
|
1885
2913
|
return;
|
|
1886
2914
|
const drainScope = this.outboxScope;
|
|
1887
2915
|
this.drainingOutbox = true;
|
|
2916
|
+
this.outboxDrainRequested = false;
|
|
1888
2917
|
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);
|
|
1903
|
-
return;
|
|
1904
|
-
}
|
|
1905
|
-
if (!this.canSendReducerNow()) {
|
|
1906
|
-
await this.reducerOutbox.markPending(entry.id);
|
|
1907
|
-
return;
|
|
1908
|
-
}
|
|
1909
|
-
await this.reducerOutbox.markInflight(entry.id);
|
|
1910
|
-
if (scope !== this.outboxScope)
|
|
2918
|
+
await this.coordinateOutbox("delivery", async () => {
|
|
2919
|
+
if (this.manuallyClosed || drainScope !== this.outboxScope || !this.canSendReducerNow())
|
|
1911
2920
|
return;
|
|
1912
|
-
if (
|
|
1913
|
-
await this.reducerOutbox.
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
await this.
|
|
2921
|
+
if (this.sharedOutboxStore)
|
|
2922
|
+
await this.reducerOutbox.recoverInflight?.(drainScope);
|
|
2923
|
+
while (!this.manuallyClosed && this.socket?.readyState === WebSocket.OPEN) {
|
|
2924
|
+
const scope = this.outboxScope;
|
|
2925
|
+
const entry = await this.inLocalLane(async () => {
|
|
2926
|
+
const next = await this.reducerOutbox.nextReady(scope, Date.now());
|
|
2927
|
+
// A concurrent discard (this or another tab) fences the entry; a
|
|
2928
|
+
// refused transition means it must not be sent.
|
|
2929
|
+
if (next && (await this.reducerOutbox.markInflight(next.id)) === false)
|
|
2930
|
+
return null;
|
|
2931
|
+
return next;
|
|
2932
|
+
});
|
|
2933
|
+
if (entry === null)
|
|
2934
|
+
continue;
|
|
2935
|
+
if (!entry) {
|
|
2936
|
+
await this.scheduleNextOutboxAttempt(scope);
|
|
2937
|
+
return;
|
|
1921
2938
|
}
|
|
1922
|
-
|
|
1923
|
-
|
|
2939
|
+
if (scope !== this.outboxScope)
|
|
2940
|
+
return;
|
|
2941
|
+
if (this.directOutboxReducerIds.has(entry.idempotencyKey)) {
|
|
2942
|
+
// Scope recovery may observe an inflight row created by this live
|
|
2943
|
+
// process and reset it to pending under the assumption that a prior
|
|
2944
|
+
// process crashed. The foreground call still owns that command ID.
|
|
2945
|
+
// Restore the durable marker and wait for its real result instead of
|
|
2946
|
+
// registering a second response handler for the same command.
|
|
2947
|
+
await this.reducerOutbox.markInflight(entry.id);
|
|
2948
|
+
return;
|
|
1924
2949
|
}
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
2950
|
+
if (!this.canSendReducerNow()) {
|
|
2951
|
+
await this.reducerOutbox.markPending(entry.id);
|
|
2952
|
+
return;
|
|
2953
|
+
}
|
|
2954
|
+
if ((await this.reducerOutbox.markInflight(entry.id)) === false)
|
|
1929
2955
|
continue;
|
|
2956
|
+
if (scope !== this.outboxScope)
|
|
2957
|
+
return;
|
|
2958
|
+
if (!this.canSendReducerNow()) {
|
|
2959
|
+
await this.reducerOutbox.markPending(entry.id);
|
|
2960
|
+
return;
|
|
2961
|
+
}
|
|
2962
|
+
try {
|
|
2963
|
+
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);
|
|
2964
|
+
this.unauthenticatedRetries = 0;
|
|
2965
|
+
await this.reducerOutbox.markCommitted(entry.id);
|
|
2966
|
+
if (entry.localExecution && this.localExecutor) {
|
|
2967
|
+
await this.inLocalLane(async () => {
|
|
2968
|
+
await this.reducerOutbox.ack(entry.id);
|
|
2969
|
+
if (scope === this.outboxScope)
|
|
2970
|
+
await this.rebaseLocalEntries();
|
|
2971
|
+
});
|
|
2972
|
+
}
|
|
2973
|
+
else if ((entry.patches?.length ?? 0) > 0) {
|
|
2974
|
+
await this.settleOptimisticReducer(entry.idempotencyKey);
|
|
2975
|
+
}
|
|
2976
|
+
else {
|
|
2977
|
+
await this.ackOptimisticReducer(entry.idempotencyKey, entry.id);
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
catch (error) {
|
|
2981
|
+
const disposition = deliveryErrorClass(error);
|
|
2982
|
+
if (disposition === "rejected") {
|
|
2983
|
+
// Keep today's rollback and rebase, but record the rejection
|
|
2984
|
+
// durably instead of deleting the intent without a trace.
|
|
2985
|
+
if (entry.localExecution && this.localExecutor) {
|
|
2986
|
+
await this.inLocalLane(async () => {
|
|
2987
|
+
await this.reducerOutbox.reject(entry.id, reducerErrorMessage(error));
|
|
2988
|
+
if (scope === this.outboxScope) {
|
|
2989
|
+
await this.rebaseLocalEntries(true);
|
|
2990
|
+
this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
|
|
2991
|
+
}
|
|
2992
|
+
});
|
|
2993
|
+
}
|
|
2994
|
+
else {
|
|
2995
|
+
this.optimisticReducerIds.delete(entry.idempotencyKey);
|
|
2996
|
+
this.optimisticOutboxEntryIds.delete(entry.idempotencyKey);
|
|
2997
|
+
this.replica.rejectCommand(entry.idempotencyKey);
|
|
2998
|
+
await this.reducerOutbox.reject(entry.id, reducerErrorMessage(error));
|
|
2999
|
+
if (scope === this.outboxScope)
|
|
3000
|
+
this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
|
|
3001
|
+
}
|
|
3002
|
+
continue;
|
|
3003
|
+
}
|
|
3004
|
+
const outcome = await this.recordDeliveryFailure(entry, error, disposition);
|
|
3005
|
+
// A parked intent keeps its prediction but no longer blocks the
|
|
3006
|
+
// chain; later intents proceed and the server validates them.
|
|
3007
|
+
if (outcome === "parked")
|
|
3008
|
+
continue;
|
|
3009
|
+
return;
|
|
1930
3010
|
}
|
|
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
3011
|
}
|
|
1935
|
-
}
|
|
3012
|
+
});
|
|
1936
3013
|
}
|
|
1937
3014
|
finally {
|
|
1938
3015
|
this.drainingOutbox = false;
|
|
1939
|
-
|
|
3016
|
+
this.scheduleLocalReplay();
|
|
3017
|
+
const requested = this.outboxDrainRequested;
|
|
3018
|
+
this.outboxDrainRequested = false;
|
|
3019
|
+
if (!this.manuallyClosed && (drainScope !== this.outboxScope || requested)) {
|
|
1940
3020
|
void this.drainOutbox();
|
|
1941
3021
|
}
|
|
1942
3022
|
}
|
|
1943
3023
|
}
|
|
3024
|
+
/**
|
|
3025
|
+
* Record a non-rejection delivery failure and arm the next attempt.
|
|
3026
|
+
* - update_required: keep the intent pending and stop for an app update.
|
|
3027
|
+
* - unauthenticated: keep the intent, re-authenticate, retry with backoff
|
|
3028
|
+
* that never spends the retry budget.
|
|
3029
|
+
* - network: connectivity loss; retried on reconnect without spending budget.
|
|
3030
|
+
* - transient (and timeouts): exponential backoff, parked as `failed` once
|
|
3031
|
+
* the retry budget is spent.
|
|
3032
|
+
*/
|
|
3033
|
+
async recordDeliveryFailure(entry, error, disposition) {
|
|
3034
|
+
const message = reducerErrorMessage(error);
|
|
3035
|
+
if (disposition === "update_required") {
|
|
3036
|
+
await this.reducerOutbox.fail(entry.id, message, { errorClass: disposition, countAttempt: false, delayMs: 0 });
|
|
3037
|
+
this.requireClientUpdate(message);
|
|
3038
|
+
return "update_required";
|
|
3039
|
+
}
|
|
3040
|
+
if (disposition === "unauthenticated") {
|
|
3041
|
+
const delay = outboxBackoffMs(this.unauthenticatedRetries++, this.outboxRetry.maxBackoffMs);
|
|
3042
|
+
await this.reducerOutbox.fail(entry.id, message, { errorClass: disposition, countAttempt: false, delayMs: delay });
|
|
3043
|
+
this.requestReauthentication();
|
|
3044
|
+
this.scheduleOutboxDrain(delay);
|
|
3045
|
+
return "retrying";
|
|
3046
|
+
}
|
|
3047
|
+
const counted = disposition === "transient";
|
|
3048
|
+
const updated = await this.reducerOutbox.fail(entry.id, message, {
|
|
3049
|
+
errorClass: disposition,
|
|
3050
|
+
countAttempt: counted,
|
|
3051
|
+
maxAttempts: this.outboxRetry.maxAttempts,
|
|
3052
|
+
maxBackoffMs: this.outboxRetry.maxBackoffMs,
|
|
3053
|
+
...(counted ? {} : { delayMs: 1_000 }),
|
|
3054
|
+
});
|
|
3055
|
+
if (updated && updated.state === "failed")
|
|
3056
|
+
return "parked";
|
|
3057
|
+
const delay = updated ? Math.max(0, updated.nextAttemptAt - Date.now()) : DEFAULT_OUTBOX_MAX_BACKOFF_MS;
|
|
3058
|
+
this.scheduleOutboxDrain(delay);
|
|
3059
|
+
return "retrying";
|
|
3060
|
+
}
|
|
3061
|
+
/**
|
|
3062
|
+
* Arm a timer for the earliest backed-off entry. Timer clocks and
|
|
3063
|
+
* `Date.now()` can disagree by a millisecond, so a wake-up may find its
|
|
3064
|
+
* entry not quite due; without this the queue would wait for an unrelated
|
|
3065
|
+
* event to resume.
|
|
3066
|
+
*/
|
|
3067
|
+
async scheduleNextOutboxAttempt(scope) {
|
|
3068
|
+
const now = Date.now();
|
|
3069
|
+
let next = Infinity;
|
|
3070
|
+
for (const entry of await this.reducerOutbox.list(scope)) {
|
|
3071
|
+
if (entry.state === "pending" && entry.nextAttemptAt > now)
|
|
3072
|
+
next = Math.min(next, entry.nextAttemptAt);
|
|
3073
|
+
}
|
|
3074
|
+
if (Number.isFinite(next) && scope === this.outboxScope)
|
|
3075
|
+
this.scheduleOutboxDrain(next - now + 1);
|
|
3076
|
+
}
|
|
3077
|
+
/** Re-send auth on the open socket so a lost tenant session is restored. */
|
|
3078
|
+
requestReauthentication() {
|
|
3079
|
+
if (this.manuallyClosed || this.authInFlight || this.socket?.readyState !== WebSocket.OPEN)
|
|
3080
|
+
return;
|
|
3081
|
+
if (!this.auth.project && !this.auth.tenant && !this.auth.token && !this.auth.fetchToken)
|
|
3082
|
+
return;
|
|
3083
|
+
this.sendAuth(false);
|
|
3084
|
+
}
|
|
1944
3085
|
scheduleOutboxDrain(delay) {
|
|
1945
3086
|
if (this.manuallyClosed)
|
|
1946
3087
|
return;
|
|
@@ -1952,6 +3093,13 @@ export class GonvexClient {
|
|
|
1952
3093
|
}, delay);
|
|
1953
3094
|
}
|
|
1954
3095
|
reducer(ref, args = {}, options = {}) {
|
|
3096
|
+
if (this.publicTenant) {
|
|
3097
|
+
if (options.offline === "queue" || options.optimistic?.length)
|
|
3098
|
+
return Promise.reject(new GonvexClientError("Public reducers are server-only and cannot queue or execute optimistically", { code: "disconnected", path: ref.path, operation: "reducer" }));
|
|
3099
|
+
return this.call("reducer", ref, args, options.timeoutMs ?? this.timeouts.reducerTimeoutMs);
|
|
3100
|
+
}
|
|
3101
|
+
if (ref.localExecution === 1)
|
|
3102
|
+
return this.runLocalReducer(ref, args);
|
|
1955
3103
|
if (options.offline === "queue" && ref.offline?.mode !== "allowed") {
|
|
1956
3104
|
return Promise.reject(new GonvexClientError(`Reducer ${ref.path} does not allow offline queueing.`, { code: "disconnected", path: ref.path, operation: "reducer" }));
|
|
1957
3105
|
}
|
|
@@ -1978,7 +3126,6 @@ export class GonvexClient {
|
|
|
1978
3126
|
}
|
|
1979
3127
|
this.directOutboxReducerIds.add(reducerId);
|
|
1980
3128
|
let entryId;
|
|
1981
|
-
let entryAttempts = 0;
|
|
1982
3129
|
try {
|
|
1983
3130
|
const scope = this.outboxScope;
|
|
1984
3131
|
const entry = await this.reducerOutbox.enqueue({
|
|
@@ -1991,7 +3138,6 @@ export class GonvexClient {
|
|
|
1991
3138
|
state: "inflight",
|
|
1992
3139
|
});
|
|
1993
3140
|
entryId = entry.id;
|
|
1994
|
-
entryAttempts = entry.attempts;
|
|
1995
3141
|
if (this.manuallyClosed) {
|
|
1996
3142
|
await this.reducerOutbox.ack(entry.id);
|
|
1997
3143
|
throw new GonvexClientError(`Gonvex client was closed before reducer ${ref.path} could be sent.`, { code: "closed", path: ref.path, operation: "reducer" });
|
|
@@ -2019,14 +3165,13 @@ export class GonvexClient {
|
|
|
2019
3165
|
return result;
|
|
2020
3166
|
}
|
|
2021
3167
|
catch (error) {
|
|
2022
|
-
|
|
3168
|
+
const disposition = deliveryErrorClass(error);
|
|
3169
|
+
if (disposition !== "rejected" && options.offline === "queue") {
|
|
2023
3170
|
const queuedEntryId = this.optimisticOutboxEntryIds.get(reducerId) ?? entryId;
|
|
2024
3171
|
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))));
|
|
3172
|
+
// Records backoff (or parks the entry) and arms the client's timer,
|
|
3173
|
+
// exactly like the background drain path.
|
|
3174
|
+
await this.recordDeliveryFailure({ id: queuedEntryId }, error, disposition);
|
|
2030
3175
|
}
|
|
2031
3176
|
return { status: "queued", reducerId };
|
|
2032
3177
|
}
|
|
@@ -2038,7 +3183,17 @@ export class GonvexClient {
|
|
|
2038
3183
|
void this.drainOutbox();
|
|
2039
3184
|
}
|
|
2040
3185
|
}
|
|
3186
|
+
requireClientUpdate(reason) {
|
|
3187
|
+
if (this.updateRequired)
|
|
3188
|
+
return;
|
|
3189
|
+
this.updateRequired = true;
|
|
3190
|
+
// Keep durable intents intact. Closing suspends subscriptions and future sends.
|
|
3191
|
+
this.close();
|
|
3192
|
+
this.updateRequiredHandler?.(reason);
|
|
3193
|
+
}
|
|
2041
3194
|
canSendReducerNow() {
|
|
3195
|
+
if (this.updateRequired)
|
|
3196
|
+
return false;
|
|
2042
3197
|
if (globalThis.navigator?.onLine === false)
|
|
2043
3198
|
return false;
|
|
2044
3199
|
const socket = this.socket;
|
|
@@ -2183,6 +3338,10 @@ export class GonvexClient {
|
|
|
2183
3338
|
...(ref.scope === "control" ? { scope: "control" } : {}),
|
|
2184
3339
|
trace: { clientSentAtMs: entry.clientSentAtMs },
|
|
2185
3340
|
...(effectiveIdempotencyKey ? { idempotencyKey: effectiveIdempotencyKey } : {}),
|
|
3341
|
+
...(ref.artifactHash ? { artifactHash: ref.artifactHash } : {}),
|
|
3342
|
+
...(this.clientContract ? { clientContract: this.clientContract.version } : {}),
|
|
3343
|
+
...(ref.receiptPath ? { receiptPath: ref.receiptPath } : {}),
|
|
3344
|
+
...(ref.intentEntropy ? { intentEntropy: ref.intentEntropy } : {}),
|
|
2186
3345
|
});
|
|
2187
3346
|
}
|
|
2188
3347
|
else {
|
|
@@ -2233,8 +3392,11 @@ export class GonvexClient {
|
|
|
2233
3392
|
return;
|
|
2234
3393
|
}
|
|
2235
3394
|
if (kind === "reducer" && message.type === "reducer.result") {
|
|
2236
|
-
this.replica.acknowledgeCommand(message.originCommandId, message.committedRevision);
|
|
2237
3395
|
const complete = () => {
|
|
3396
|
+
// A collection frame can advance the shared cursor before another
|
|
3397
|
+
// collection's rows and membership have arrived. Retire the local
|
|
3398
|
+
// prediction only after the commit's durable watermark barrier.
|
|
3399
|
+
this.replica.acknowledgeCommand(message.originCommandId, message.committedRevision);
|
|
2238
3400
|
settle();
|
|
2239
3401
|
this.emitTelemetryFromCall(kind, id, ref.path, "ok", clientSentAtMs, message.trace);
|
|
2240
3402
|
resolve(message.result);
|
|
@@ -2258,7 +3420,11 @@ export class GonvexClient {
|
|
|
2258
3420
|
if (kind === "reducer" && message.type === "reducer.error") {
|
|
2259
3421
|
settle();
|
|
2260
3422
|
this.emitTelemetryFromCall(kind, id, ref.path, "error", clientSentAtMs, message.trace, message.error);
|
|
2261
|
-
|
|
3423
|
+
const errorClass = reducerErrorClassFromMessage(message);
|
|
3424
|
+
reject(new GonvexClientError(message.error, {
|
|
3425
|
+
code: "server", path: ref.path, operation: kind,
|
|
3426
|
+
...(errorClass ? { errorClass, retryable: message.retryable ?? (errorClass === "transient" || errorClass === "unauthenticated") } : {}),
|
|
3427
|
+
}));
|
|
2262
3428
|
}
|
|
2263
3429
|
if (kind === "action" && message.type === "action.result") {
|
|
2264
3430
|
const complete = () => {
|
|
@@ -2485,6 +3651,9 @@ export class GonvexClient {
|
|
|
2485
3651
|
this.send(message);
|
|
2486
3652
|
}
|
|
2487
3653
|
hasControlPlaneWork() {
|
|
3654
|
+
for (const query of this.querySubscriptions.values())
|
|
3655
|
+
if (query.executionScope === "control")
|
|
3656
|
+
return true;
|
|
2488
3657
|
for (const query of this.oneShotQueries.values())
|
|
2489
3658
|
if (query.scope === "control")
|
|
2490
3659
|
return true;
|
|
@@ -2615,6 +3784,21 @@ export class GonvexClient {
|
|
|
2615
3784
|
if (this.manuallyClosed)
|
|
2616
3785
|
return Promise.reject(new Error("Gonvex client is closed"));
|
|
2617
3786
|
this.connect();
|
|
3787
|
+
if (!this.activeAuthFrameId) {
|
|
3788
|
+
return new Promise((resolve, reject) => {
|
|
3789
|
+
this.pendingNativeErrors.push({ type, payload, resolve, reject });
|
|
3790
|
+
});
|
|
3791
|
+
}
|
|
3792
|
+
return this.sendNativeErrorFrame(type, payload);
|
|
3793
|
+
}
|
|
3794
|
+
flushPendingNativeErrors() {
|
|
3795
|
+
if (!this.activeAuthFrameId || this.pendingNativeErrors.length === 0)
|
|
3796
|
+
return;
|
|
3797
|
+
for (const pending of this.pendingNativeErrors.splice(0)) {
|
|
3798
|
+
void this.sendNativeErrorFrame(pending.type, pending.payload).then(pending.resolve, pending.reject);
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
3801
|
+
sendNativeErrorFrame(type, payload) {
|
|
2618
3802
|
const id = randomID();
|
|
2619
3803
|
return new Promise((resolve, reject) => {
|
|
2620
3804
|
const timer = setTimeout(() => {
|
|
@@ -2663,11 +3847,13 @@ export class GonvexClient {
|
|
|
2663
3847
|
this.managedAuthAttempt?.ids.add(id);
|
|
2664
3848
|
this.sendNow({
|
|
2665
3849
|
type: "auth",
|
|
3850
|
+
...(this.clientContract ? { clientContract: this.clientContract.version } : {}),
|
|
2666
3851
|
id,
|
|
2667
3852
|
token: this.auth.token,
|
|
2668
3853
|
project: this.auth.project,
|
|
2669
3854
|
tenant: this.auth.tenant,
|
|
2670
3855
|
controlOnly: !this.auth.tenant,
|
|
3856
|
+
...(this.publicTenant ? { public: true } : {}),
|
|
2671
3857
|
device: browserTelemetryInfo(),
|
|
2672
3858
|
capabilities: { replicaReadyMany: 1, replicaWatermark: 1, queryPagePatch: 1, queryObjectPatch: 1, queryOrderDelta: 1, queryFanout: 1, queryResultBatch: 1 },
|
|
2673
3859
|
});
|
|
@@ -2805,7 +3991,19 @@ export class GonvexClient {
|
|
|
2805
3991
|
socket.send(JSON.stringify(message));
|
|
2806
3992
|
return;
|
|
2807
3993
|
}
|
|
2808
|
-
this
|
|
3994
|
+
// A replaced socket re-sends its own work; don't route this message
|
|
3995
|
+
// onto the newer connection a second time.
|
|
3996
|
+
if (this.socket !== socket)
|
|
3997
|
+
return;
|
|
3998
|
+
// Public control calls (such as the token exchange) renew the
|
|
3999
|
+
// session that the pending auth is waiting for. Queueing them behind
|
|
4000
|
+
// that auth deadlocks the connection, so keep their auth bypass.
|
|
4001
|
+
const id = "id" in message ? message.id : undefined;
|
|
4002
|
+
const pending = id ? this.pendingCalls.get(id) ?? this.oneShotQueries.get(id) : undefined;
|
|
4003
|
+
if ("scope" in message && message.scope === "control" && pending?.authorization === "public")
|
|
4004
|
+
this.sendNow(message);
|
|
4005
|
+
else
|
|
4006
|
+
this.send(message);
|
|
2809
4007
|
}, { once: true });
|
|
2810
4008
|
return;
|
|
2811
4009
|
}
|
|
@@ -2860,15 +4058,33 @@ function replaceOfflineLiveQueryMetadata(result, path, offline) {
|
|
|
2860
4058
|
const current = result[head];
|
|
2861
4059
|
return { ...result, [head]: replaceOfflineLiveQueryMetadata(current ?? null, tail, offline) };
|
|
2862
4060
|
}
|
|
4061
|
+
// Generated references and their plans are immutable module metadata. Retain
|
|
4062
|
+
// one canonical contract per reference, not a fresh sorted serialization for
|
|
4063
|
+
// every React watch/read. Weak keys do not retain replaced modules or callers.
|
|
4064
|
+
const subscriptionContracts = new WeakMap();
|
|
2863
4065
|
function querySubscriptionKey(ref, args) {
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
:
|
|
2870
|
-
|
|
2871
|
-
|
|
4066
|
+
let cached = subscriptionContracts.get(ref);
|
|
4067
|
+
if (!cached || cached.scope !== ref.scope || cached.delivery !== ref.delivery || cached.live !== ref.live) {
|
|
4068
|
+
const contract = {
|
|
4069
|
+
scope: ref.scope ?? "tenant",
|
|
4070
|
+
delivery: ref.delivery ?? "oneShot",
|
|
4071
|
+
live: ref.live
|
|
4072
|
+
? { entity: ref.live.entity, key: ref.live.key, resultPath: [...(ref.live.resultPath ?? [])], plan: ref.live.plan ?? null }
|
|
4073
|
+
: null,
|
|
4074
|
+
};
|
|
4075
|
+
cached = { scope: ref.scope, delivery: ref.delivery, live: ref.live, value: stableStringify(contract), keys: new Map() };
|
|
4076
|
+
subscriptionContracts.set(ref, cached);
|
|
4077
|
+
}
|
|
4078
|
+
const argumentsKey = `${ref.path}\u0000${stableStringify(args)}`;
|
|
4079
|
+
const previous = cached.keys.get(argumentsKey);
|
|
4080
|
+
if (previous !== undefined)
|
|
4081
|
+
return previous;
|
|
4082
|
+
const key = `${argumentsKey}\u0000${cached.value}`;
|
|
4083
|
+
// Search text and pagination may produce unbounded argument combinations.
|
|
4084
|
+
if (cached.keys.size >= 16)
|
|
4085
|
+
cached.keys.delete(cached.keys.keys().next().value);
|
|
4086
|
+
cached.keys.set(argumentsKey, key);
|
|
4087
|
+
return key;
|
|
2872
4088
|
}
|
|
2873
4089
|
function countPendingCalls(calls, kind) {
|
|
2874
4090
|
let count = 0;
|
|
@@ -2878,9 +4094,96 @@ function countPendingCalls(calls, kind) {
|
|
|
2878
4094
|
}
|
|
2879
4095
|
return count;
|
|
2880
4096
|
}
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
4097
|
+
/**
|
|
4098
|
+
* How the outbox treats one failed delivery. Runtimes before
|
|
4099
|
+
* 0.5.2-staging.15 send no class: their server errors stay rejections, as
|
|
4100
|
+
* before, except the two cases the legacy runtime could only express in prose.
|
|
4101
|
+
*/
|
|
4102
|
+
function deliveryErrorClass(error) {
|
|
4103
|
+
if (!(error instanceof GonvexClientError))
|
|
4104
|
+
return "transient";
|
|
4105
|
+
switch (error.code) {
|
|
4106
|
+
case "server": return error.errorClass ?? "rejected";
|
|
4107
|
+
case "timeout": return "transient";
|
|
4108
|
+
default: return "network";
|
|
4109
|
+
}
|
|
4110
|
+
}
|
|
4111
|
+
const legacyUnauthenticatedReducerError = "authenticate with an active tenant before calling a Reducer";
|
|
4112
|
+
function reducerErrorClassFromMessage(message) {
|
|
4113
|
+
if (message.class === "rejected" || message.class === "transient" || message.class === "update_required" || message.class === "unauthenticated") {
|
|
4114
|
+
return message.class;
|
|
4115
|
+
}
|
|
4116
|
+
// Legacy runtimes: the only structured signals were these literal messages.
|
|
4117
|
+
if (/STALE_REDUCER_ARTIFACT|CLIENT_UPDATE_REQUIRED/.test(message.error))
|
|
4118
|
+
return "update_required";
|
|
4119
|
+
if (message.error === legacyUnauthenticatedReducerError)
|
|
4120
|
+
return "unauthenticated";
|
|
4121
|
+
return undefined;
|
|
4122
|
+
}
|
|
4123
|
+
function positiveOr(value, fallback) {
|
|
4124
|
+
return typeof value === "number" && value > 0 ? value : fallback;
|
|
4125
|
+
}
|
|
4126
|
+
function summarizeArgs(args) {
|
|
4127
|
+
let text;
|
|
4128
|
+
try {
|
|
4129
|
+
text = JSON.stringify(args) ?? "";
|
|
4130
|
+
}
|
|
4131
|
+
catch {
|
|
4132
|
+
text = String(args);
|
|
4133
|
+
}
|
|
4134
|
+
return text.length > 160 ? `${text.slice(0, 157)}...` : text;
|
|
4135
|
+
}
|
|
4136
|
+
function outboxIntentFromEntry(entry) {
|
|
4137
|
+
const entities = new Map();
|
|
4138
|
+
for (const patch of entry.patches ?? []) {
|
|
4139
|
+
const entity = patch.entity ?? patch.collection;
|
|
4140
|
+
if (!entity)
|
|
4141
|
+
continue;
|
|
4142
|
+
entities.set(`${entity}\u0000${patch.rowId}`, { entity, id: patch.rowId });
|
|
4143
|
+
}
|
|
4144
|
+
return {
|
|
4145
|
+
id: entry.idempotencyKey,
|
|
4146
|
+
entryId: entry.id,
|
|
4147
|
+
reducer: entry.path,
|
|
4148
|
+
state: entry.state,
|
|
4149
|
+
attempts: entry.attempts,
|
|
4150
|
+
...(entry.lastError !== undefined ? { lastError: entry.lastError } : {}),
|
|
4151
|
+
...(entry.lastErrorClass ? { errorClass: entry.lastErrorClass } : {}),
|
|
4152
|
+
createdAt: entry.createdAt,
|
|
4153
|
+
nextAttemptAt: entry.nextAttemptAt,
|
|
4154
|
+
...(entry.settledAt !== undefined ? { settledAt: entry.settledAt } : {}),
|
|
4155
|
+
args: entry.args,
|
|
4156
|
+
argsSummary: summarizeArgs(entry.args),
|
|
4157
|
+
entities: [...entities.values()],
|
|
4158
|
+
};
|
|
4159
|
+
}
|
|
4160
|
+
function sameIntents(left, right) {
|
|
4161
|
+
if (left.length !== right.length)
|
|
4162
|
+
return false;
|
|
4163
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
4164
|
+
const a = left[index], b = right[index];
|
|
4165
|
+
if (a.id !== b.id || a.entryId !== b.entryId || a.state !== b.state || a.attempts !== b.attempts
|
|
4166
|
+
|| a.lastError !== b.lastError || a.errorClass !== b.errorClass || a.nextAttemptAt !== b.nextAttemptAt
|
|
4167
|
+
|| a.settledAt !== b.settledAt || a.entities.length !== b.entities.length
|
|
4168
|
+
|| a.entities.some((entity, position) => entity.entity !== b.entities[position].entity || entity.id !== b.entities[position].id))
|
|
4169
|
+
return false;
|
|
4170
|
+
}
|
|
4171
|
+
return true;
|
|
4172
|
+
}
|
|
4173
|
+
/** Row status from a list of intents: failed > rejected > syncing. */
|
|
4174
|
+
export function entityStatusFromIntents(intents, entity, id) {
|
|
4175
|
+
let status;
|
|
4176
|
+
for (const intent of intents) {
|
|
4177
|
+
if (!intent.entities.some((candidate) => candidate.entity === entity && candidate.id === id))
|
|
4178
|
+
continue;
|
|
4179
|
+
if (intent.state === "failed")
|
|
4180
|
+
return "failed";
|
|
4181
|
+
if (intent.state === "rejected")
|
|
4182
|
+
status = "rejected";
|
|
4183
|
+
else if (intent.state !== "committed" && status === undefined)
|
|
4184
|
+
status = "syncing";
|
|
4185
|
+
}
|
|
4186
|
+
return status;
|
|
2884
4187
|
}
|
|
2885
4188
|
function reducerErrorMessage(error) {
|
|
2886
4189
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -2902,6 +4205,9 @@ function stableStringify(value) {
|
|
|
2902
4205
|
.join(",")}}`;
|
|
2903
4206
|
}
|
|
2904
4207
|
function utf8KeyCompare(left, right) {
|
|
4208
|
+
// ASCII keys have identical UTF-8 and JavaScript lexical ordering.
|
|
4209
|
+
if (!/[^\x00-\x7f]/.test(left) && !/[^\x00-\x7f]/.test(right))
|
|
4210
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
2905
4211
|
const leftBytes = new TextEncoder().encode(left);
|
|
2906
4212
|
const rightBytes = new TextEncoder().encode(right);
|
|
2907
4213
|
const length = Math.min(leftBytes.length, rightBytes.length);
|
|
@@ -2922,9 +4228,9 @@ function boundReplicaRows(rows, keyField, maxRows, maxBytes, orderBy, orderDirec
|
|
|
2922
4228
|
const key = replicaRowKeyValue(row, keyField);
|
|
2923
4229
|
if (!key || seen.has(key))
|
|
2924
4230
|
continue;
|
|
2925
|
-
const size = replicaJSONSize(row);
|
|
2926
4231
|
if (maxRows && kept.length >= maxRows)
|
|
2927
4232
|
break;
|
|
4233
|
+
const size = maxBytes ? replicaJSONSize(row) : 0;
|
|
2928
4234
|
if (maxBytes && bytes + size > maxBytes)
|
|
2929
4235
|
break;
|
|
2930
4236
|
kept.push(row);
|
|
@@ -2971,7 +4277,10 @@ function replicaRowKeyValue(value, keyField) {
|
|
|
2971
4277
|
return key === null || key === undefined ? "" : String(key);
|
|
2972
4278
|
}
|
|
2973
4279
|
function replicaJSONSize(value) {
|
|
2974
|
-
|
|
4280
|
+
// Property ordering cannot change the encoded length. Native serialization
|
|
4281
|
+
// avoids recursively allocating sorted key arrays and intermediate strings.
|
|
4282
|
+
const json = JSON.stringify(value).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
4283
|
+
return new TextEncoder().encode(json).byteLength;
|
|
2975
4284
|
}
|
|
2976
4285
|
function applyKeyedPatch(previous, patch) {
|
|
2977
4286
|
const rows = new Map();
|
|
@@ -3038,6 +4347,31 @@ function queryPatchRowKey(value) {
|
|
|
3038
4347
|
const candidate = value._id ?? value.id;
|
|
3039
4348
|
return typeof candidate === "string" || typeof candidate === "number" ? String(candidate) : "";
|
|
3040
4349
|
}
|
|
4350
|
+
function applyLocalPatches(snapshot, patches) {
|
|
4351
|
+
for (const patch of patches) {
|
|
4352
|
+
const entity = patch.entity ?? patch.collection;
|
|
4353
|
+
if (!entity)
|
|
4354
|
+
continue;
|
|
4355
|
+
const table = snapshot.tables[entity] ??= { complete: false, rows: [] };
|
|
4356
|
+
const index = table.rows.findIndex((row) => String(row._id ?? row.id) === patch.rowId);
|
|
4357
|
+
if (patch.op === "delete") {
|
|
4358
|
+
if (index >= 0)
|
|
4359
|
+
table.rows.splice(index, 1);
|
|
4360
|
+
}
|
|
4361
|
+
else if (index >= 0)
|
|
4362
|
+
table.rows[index] = { ...table.rows[index], ...structuredClone(patch.fields) };
|
|
4363
|
+
else if (patch.op !== "patch")
|
|
4364
|
+
table.rows.push(structuredClone(patch.fields));
|
|
4365
|
+
}
|
|
4366
|
+
}
|
|
4367
|
+
function localIdentityFromAuthResult(result) {
|
|
4368
|
+
if (!result || typeof result !== "object" || Array.isArray(result))
|
|
4369
|
+
return;
|
|
4370
|
+
const identity = result.localIdentity;
|
|
4371
|
+
if (!identity?.auth?.account?.id || !identity.tenant?.id || !identity.member?.id)
|
|
4372
|
+
return;
|
|
4373
|
+
return structuredClone(identity);
|
|
4374
|
+
}
|
|
3041
4375
|
function authFromOptions(options) {
|
|
3042
4376
|
return {
|
|
3043
4377
|
project: options.project,
|
|
@@ -3056,8 +4390,6 @@ function normalizeQuerySubscriptionRetentionMs(value) {
|
|
|
3056
4390
|
return Math.max(0, Math.min(5 * 60_000, Math.floor(value)));
|
|
3057
4391
|
}
|
|
3058
4392
|
function authIdentityKey(auth) {
|
|
3059
|
-
if (!auth.tenant)
|
|
3060
|
-
return "";
|
|
3061
4393
|
if (auth.token) {
|
|
3062
4394
|
const tokenIdentity = authIdentityKeyFromToken(auth);
|
|
3063
4395
|
if (tokenIdentity)
|
|
@@ -3067,12 +4399,12 @@ function authIdentityKey(auth) {
|
|
|
3067
4399
|
// token would supply, so both paths derive the same key for the same Account.
|
|
3068
4400
|
const hint = auth.identity;
|
|
3069
4401
|
if (hint && typeof hint.sub === "string" && hint.sub.trim()) {
|
|
3070
|
-
return [auth.project ?? "", auth.tenant, hint.iss ?? "", hint.sub].join("\u0000");
|
|
4402
|
+
return [auth.project ?? "", auth.tenant ?? "", hint.iss ?? "", hint.sub].join("\u0000");
|
|
3071
4403
|
}
|
|
3072
4404
|
return "";
|
|
3073
4405
|
}
|
|
3074
4406
|
function authIdentityKeyFromToken(auth) {
|
|
3075
|
-
if (!auth.token
|
|
4407
|
+
if (!auth.token)
|
|
3076
4408
|
return "";
|
|
3077
4409
|
const parts = auth.token.split(".");
|
|
3078
4410
|
if (parts.length < 2)
|
|
@@ -3085,7 +4417,7 @@ function authIdentityKeyFromToken(auth) {
|
|
|
3085
4417
|
return "";
|
|
3086
4418
|
return [
|
|
3087
4419
|
auth.project ?? "",
|
|
3088
|
-
auth.tenant,
|
|
4420
|
+
auth.tenant ?? "",
|
|
3089
4421
|
typeof payload.iss === "string" ? payload.iss : "",
|
|
3090
4422
|
payload.sub,
|
|
3091
4423
|
].join("\u0000");
|
|
@@ -3212,4 +4544,56 @@ function detectDeviceType(userAgent) {
|
|
|
3212
4544
|
return "mobile";
|
|
3213
4545
|
return "desktop";
|
|
3214
4546
|
}
|
|
4547
|
+
/**
|
|
4548
|
+
* Dependency tables of locally executing Reducers that stay resident. A
|
|
4549
|
+
* table qualifies when its unfiltered generated collection declares a small
|
|
4550
|
+
* enough `maxRows` (and `maxBytes`, when bounded). Collections without a
|
|
4551
|
+
* declared bound are never resident: their size is unknown.
|
|
4552
|
+
*/
|
|
4553
|
+
export function residentLocalDependencyTables(binding, policy = "bounded") {
|
|
4554
|
+
if (!binding?.localDependencies || policy === "off")
|
|
4555
|
+
return [];
|
|
4556
|
+
const bounds = policy === "bounded" ? {} : policy;
|
|
4557
|
+
const maxRows = bounds.maxRows ?? DEFAULT_LOCAL_DEPENDENCY_RESIDENT_MAX_ROWS;
|
|
4558
|
+
const maxBytes = bounds.maxBytes;
|
|
4559
|
+
const tables = new Set(Object.values(binding.localDependencies).flat());
|
|
4560
|
+
return [...tables].filter((table) => (binding.collections ?? []).some((reference) => {
|
|
4561
|
+
const replica = reference.replica;
|
|
4562
|
+
if (replica?.table !== table || Object.keys(replica.equalFilters ?? {}).length || replica.excludeWhenSet?.length)
|
|
4563
|
+
return false;
|
|
4564
|
+
if (!replica.maxRows || replica.maxRows > maxRows)
|
|
4565
|
+
return false;
|
|
4566
|
+
return maxBytes === undefined || (!!replica.maxBytes && replica.maxBytes <= maxBytes);
|
|
4567
|
+
})).sort();
|
|
4568
|
+
}
|
|
4569
|
+
/** Sign-in and account-only pages do not need reducer code loaded. */
|
|
4570
|
+
function deferredLocalExecutor(create) {
|
|
4571
|
+
let executor;
|
|
4572
|
+
let closed = false;
|
|
4573
|
+
const start = () => {
|
|
4574
|
+
if (closed)
|
|
4575
|
+
throw new Error("Local reducer executor is closed");
|
|
4576
|
+
return executor ??= create();
|
|
4577
|
+
};
|
|
4578
|
+
return {
|
|
4579
|
+
get ready() {
|
|
4580
|
+
try {
|
|
4581
|
+
return start().ready;
|
|
4582
|
+
}
|
|
4583
|
+
catch (error) {
|
|
4584
|
+
return Promise.reject(error);
|
|
4585
|
+
}
|
|
4586
|
+
},
|
|
4587
|
+
prepare: async (path) => start().prepare?.(path),
|
|
4588
|
+
execute: async (...args) => start().execute(...args),
|
|
4589
|
+
executeRead: async (...args) => {
|
|
4590
|
+
const instance = start();
|
|
4591
|
+
if (!instance.executeRead)
|
|
4592
|
+
throw new Error('Local executor does not support indexed reads');
|
|
4593
|
+
return instance.executeRead(...args);
|
|
4594
|
+
},
|
|
4595
|
+
replay: async (...args) => start().replay(...args),
|
|
4596
|
+
close: () => { closed = true; executor?.close(); },
|
|
4597
|
+
};
|
|
4598
|
+
}
|
|
3215
4599
|
//# sourceMappingURL=index.js.map
|