@gonvex/client 0.5.2-staging.10 → 0.5.2-staging.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser-upgrades.d.ts +19 -0
- package/dist/browser-upgrades.js +111 -0
- package/dist/browser-upgrades.js.map +1 -0
- package/dist/client-upgrades.d.ts +26 -0
- package/dist/client-upgrades.js +40 -0
- package/dist/client-upgrades.js.map +1 -0
- package/dist/index.d.ts +45 -1
- package/dist/index.js +309 -5
- package/dist/index.js.map +1 -1
- package/dist/indexeddb-replica.d.ts +3 -0
- package/dist/indexeddb-replica.js +21 -0
- package/dist/indexeddb-replica.js.map +1 -1
- package/dist/local-replica.d.ts +23 -0
- package/dist/local-replica.js +20 -0
- package/dist/local-replica.js.map +1 -1
- package/dist/outbox.d.ts +16 -0
- package/dist/outbox.js +62 -7
- package/dist/outbox.js.map +1 -1
- package/package.json +5 -3
package/dist/index.js
CHANGED
|
@@ -9,6 +9,8 @@ export * from "./error-reporter.js";
|
|
|
9
9
|
export * from "./optimistic.js";
|
|
10
10
|
export * from "./outbox.js";
|
|
11
11
|
export * from "./kv-stores.js";
|
|
12
|
+
export * from "./client-upgrades.js";
|
|
13
|
+
export * from "./browser-upgrades.js";
|
|
12
14
|
export * from "./signals.js";
|
|
13
15
|
export * from "./external-auth.js";
|
|
14
16
|
// Keep the mutable LocalReplica implementation private to GonvexClient. The
|
|
@@ -115,6 +117,16 @@ const maxReplicaBatchOpens = 256;
|
|
|
115
117
|
// normally settle in a few milliseconds.
|
|
116
118
|
export class GonvexClient {
|
|
117
119
|
url;
|
|
120
|
+
localBinding;
|
|
121
|
+
localExecutor;
|
|
122
|
+
localStorage;
|
|
123
|
+
localIdentity;
|
|
124
|
+
localLane = Promise.resolve();
|
|
125
|
+
localReplayScheduled = false;
|
|
126
|
+
replacingLocal = false;
|
|
127
|
+
unsubscribeLocal;
|
|
128
|
+
localCollectionClosers = [];
|
|
129
|
+
reducerRejectionHandlers = new Set();
|
|
118
130
|
socket;
|
|
119
131
|
handlers = new Map();
|
|
120
132
|
querySubscriptions = new Map();
|
|
@@ -185,8 +197,17 @@ export class GonvexClient {
|
|
|
185
197
|
hasEverConnected = false;
|
|
186
198
|
connectionCount = 0;
|
|
187
199
|
timeouts;
|
|
200
|
+
clientContract;
|
|
201
|
+
updateRequiredHandler;
|
|
202
|
+
updateRequired = false;
|
|
203
|
+
lastOnlineAtMs = 0;
|
|
188
204
|
constructor(url, options = {}) {
|
|
189
205
|
this.url = url;
|
|
206
|
+
this.clientContract = options.clientContract ?? options.localRuntime?.clientContract;
|
|
207
|
+
this.updateRequiredHandler = options.onUpdateRequired;
|
|
208
|
+
this.localBinding = options.localRuntime;
|
|
209
|
+
this.localExecutor = options.localRuntime?.create();
|
|
210
|
+
this.localStorage = options.localReplica?.storage;
|
|
190
211
|
this.auth = authFromOptions(options);
|
|
191
212
|
this.telemetryEnabled = options.telemetry === true;
|
|
192
213
|
this.querySubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.querySubscriptionRetentionMs);
|
|
@@ -194,6 +215,8 @@ export class GonvexClient {
|
|
|
194
215
|
this.reducerOutbox = createReducerOutbox(options.outbox);
|
|
195
216
|
this.replica = new LocalReplica(options.localReplica?.storage);
|
|
196
217
|
this.replicaView = createLocalReplicaView(this.replica);
|
|
218
|
+
if (this.localExecutor)
|
|
219
|
+
this.unsubscribeLocal = this.replica.subscribe(() => this.scheduleLocalReplay());
|
|
197
220
|
this.unsubscribeOutbox = this.reducerOutbox.subscribe(() => {
|
|
198
221
|
void this.drainOutbox();
|
|
199
222
|
});
|
|
@@ -230,6 +253,182 @@ export class GonvexClient {
|
|
|
230
253
|
transport: (type, payload) => this.sendNativeError(type, payload),
|
|
231
254
|
});
|
|
232
255
|
}
|
|
256
|
+
if (this.localBinding)
|
|
257
|
+
this.outboxReady = this.restoreLocalSession(initialScope, this.outboxScopeGeneration);
|
|
258
|
+
}
|
|
259
|
+
/** Authoritative sync failures arrive after an offline call returned locally. */
|
|
260
|
+
onReducerRejection(listener) {
|
|
261
|
+
this.reducerRejectionHandlers.add(listener);
|
|
262
|
+
return () => this.reducerRejectionHandlers.delete(listener);
|
|
263
|
+
}
|
|
264
|
+
async restoreLocalSession(scope, generation) {
|
|
265
|
+
if (!this.localBinding || !scope.startsWith("identity\u0000"))
|
|
266
|
+
return;
|
|
267
|
+
const saved = await this.localStorage?.loadSession?.(scope);
|
|
268
|
+
if (!saved || generation !== this.outboxScopeGeneration || scope !== this.outboxScope || this.hasAuthoritativeReplicaScope)
|
|
269
|
+
return;
|
|
270
|
+
this.lastOnlineAtMs = saved.lastOnlineAtMs ?? 0;
|
|
271
|
+
this.localIdentity = saved.identity;
|
|
272
|
+
await this.activateReplicaDirective(saved.directive);
|
|
273
|
+
this.replica.setFreshness("offline");
|
|
274
|
+
}
|
|
275
|
+
ensureLocalCollections() {
|
|
276
|
+
if (!this.localBinding || this.localCollectionClosers.length)
|
|
277
|
+
return;
|
|
278
|
+
for (const reference of this.localBinding.collections ?? []) {
|
|
279
|
+
this.localCollectionClosers.push(this.subscribeReplicaTransport(reference, {}, () => undefined));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
localSnapshot(includePending) {
|
|
283
|
+
const snapshot = this.replica.snapshot();
|
|
284
|
+
const tables = {};
|
|
285
|
+
for (const table of this.localBinding?.tables ?? []) {
|
|
286
|
+
const references = (this.localBinding?.collections ?? []).filter((ref) => ref.replica?.table === table
|
|
287
|
+
&& !Object.keys(ref.replica.equalFilters ?? {}).length && !(ref.replica.excludeWhenSet?.length));
|
|
288
|
+
const complete = references.some((ref) => {
|
|
289
|
+
const window = snapshot.liveQueries[querySubscriptionKey(ref, {})];
|
|
290
|
+
return window?.completeness === "complete" && window.truncated !== true;
|
|
291
|
+
});
|
|
292
|
+
tables[table] = { complete, columns: [...new Set(references.flatMap(ref => ref.replica?.columns ?? []))], rows: includePending ? this.replica.entityRows(table) : Object.values(snapshot.entities[table] ?? {}) };
|
|
293
|
+
}
|
|
294
|
+
return { scope: this.replicaScope, tables };
|
|
295
|
+
}
|
|
296
|
+
inLocalLane(run) {
|
|
297
|
+
const job = this.localLane.then(run);
|
|
298
|
+
this.localLane = job.catch(() => undefined);
|
|
299
|
+
return job;
|
|
300
|
+
}
|
|
301
|
+
scheduleLocalReplay() {
|
|
302
|
+
if (!this.localExecutor || this.replacingLocal || this.localReplayScheduled || this.manuallyClosed || !this.localIdentity)
|
|
303
|
+
return;
|
|
304
|
+
this.localReplayScheduled = true;
|
|
305
|
+
queueMicrotask(() => {
|
|
306
|
+
void (async () => {
|
|
307
|
+
await this.outboxReady;
|
|
308
|
+
await this.inLocalLane(() => this.rebaseLocalEntries());
|
|
309
|
+
})().catch(() => undefined).finally(() => { this.localReplayScheduled = false; });
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
reportLocalRejection(reducerId, path, error) {
|
|
313
|
+
const message = reducerErrorMessage(error);
|
|
314
|
+
for (const listener of this.reducerRejectionHandlers)
|
|
315
|
+
listener({ reducerId, path, error: message });
|
|
316
|
+
}
|
|
317
|
+
async rebaseLocalEntries() {
|
|
318
|
+
if (!this.localExecutor || !this.localBinding || !this.localIdentity || !this.hasAuthoritativeReplicaScope)
|
|
319
|
+
return;
|
|
320
|
+
const scope = this.outboxScope;
|
|
321
|
+
const replicaScope = this.replicaScope;
|
|
322
|
+
const entries = await this.reducerOutbox.list(scope);
|
|
323
|
+
const baseVersion = this.replica.version();
|
|
324
|
+
const snapshot = this.localSnapshot(false);
|
|
325
|
+
const commands = [];
|
|
326
|
+
for (const entry of entries) {
|
|
327
|
+
let patches = entry.patches ?? [];
|
|
328
|
+
if (entry.localExecution && entry.state !== "inflight" && entry.state !== "committed") {
|
|
329
|
+
const execution = { ...entry.localExecution, scope: replicaScope, identity: this.localIdentity, artifactHash: this.localBinding.artifactHash };
|
|
330
|
+
try {
|
|
331
|
+
const transaction = await this.localExecutor.execute(entry.path, entry.args, snapshot, execution);
|
|
332
|
+
patches = transaction.patches;
|
|
333
|
+
if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
// Re-execution is a prediction. Only an authoritative server rejection
|
|
338
|
+
// removes a durable intent; missing data or local validation can make
|
|
339
|
+
// its predicted transaction unavailable without rejecting the intent.
|
|
340
|
+
patches = [];
|
|
341
|
+
}
|
|
342
|
+
if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
|
|
343
|
+
return;
|
|
344
|
+
// Storage failures must abort rebasing, never masquerade as rejection.
|
|
345
|
+
await this.reducerOutbox.updateLocal(entry.id, patches, execution);
|
|
346
|
+
}
|
|
347
|
+
applyLocalPatches(snapshot, patches);
|
|
348
|
+
commands.push({ commandId: entry.idempotencyKey, patches });
|
|
349
|
+
}
|
|
350
|
+
if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
|
|
351
|
+
return;
|
|
352
|
+
if (baseVersion !== this.replica.version())
|
|
353
|
+
return this.rebaseLocalEntries();
|
|
354
|
+
this.replacingLocal = true;
|
|
355
|
+
try {
|
|
356
|
+
this.replica.replaceOptimistic(commands);
|
|
357
|
+
}
|
|
358
|
+
finally {
|
|
359
|
+
this.replacingLocal = false;
|
|
360
|
+
}
|
|
361
|
+
this.optimisticReducerIds.clear();
|
|
362
|
+
this.optimisticOutboxEntryIds.clear();
|
|
363
|
+
for (const entry of entries) {
|
|
364
|
+
this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
|
|
365
|
+
if (commands.find((command) => command.commandId === entry.idempotencyKey)?.patches.length)
|
|
366
|
+
this.optimisticReducerIds.add(entry.idempotencyKey);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async runLocalReducer(ref, args) {
|
|
370
|
+
args = structuredClone(args);
|
|
371
|
+
const admittedGeneration = this.outboxScopeGeneration;
|
|
372
|
+
await this.outboxReady;
|
|
373
|
+
await this.replicaReady;
|
|
374
|
+
if (!this.localExecutor || !this.localBinding)
|
|
375
|
+
throw new Error("This reducer requires the generated Gonvex client with local execution enabled");
|
|
376
|
+
return this.inLocalLane(async () => {
|
|
377
|
+
if (admittedGeneration !== this.outboxScopeGeneration)
|
|
378
|
+
throw new GonvexClientError("Session changed before local reducer execution", { code: "superseded" });
|
|
379
|
+
if (!this.localIdentity || !this.hasAuthoritativeReplicaScope)
|
|
380
|
+
throw new Error("Restore an authenticated local session before editing offline");
|
|
381
|
+
if (this.manuallyClosed)
|
|
382
|
+
throw new Error("Gonvex client is closed");
|
|
383
|
+
if (this.updateRequired)
|
|
384
|
+
throw new Error("Application update required before editing");
|
|
385
|
+
if (this.clientContract && !this.canSendReducerNow() && (Date.now() - this.lastOnlineAtMs > this.clientContract.offlineMaxAgeMs || Date.now() < this.lastOnlineAtMs))
|
|
386
|
+
throw new Error("Offline editing window expired. Reconnect before making more changes.");
|
|
387
|
+
const scope = this.outboxScope;
|
|
388
|
+
const reducerId = randomID();
|
|
389
|
+
const execution = {
|
|
390
|
+
scope: this.replicaScope, commandId: reducerId, now: Date.now(),
|
|
391
|
+
intentEntropy: Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, "0")).join(""),
|
|
392
|
+
artifactHash: this.localBinding.artifactHash, identity: structuredClone(this.localIdentity),
|
|
393
|
+
};
|
|
394
|
+
let transaction;
|
|
395
|
+
try {
|
|
396
|
+
transaction = await this.localExecutor.execute(ref.path, args, this.localSnapshot(true), execution);
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
if (!(error instanceof Error) || error.name !== "IncompleteReplicaError")
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
if (scope !== this.outboxScope || execution.scope !== this.replicaScope)
|
|
403
|
+
throw new GonvexClientError("Session changed during local reducer execution", { code: "superseded" });
|
|
404
|
+
const patches = transaction?.patches ?? [];
|
|
405
|
+
this.directOutboxReducerIds.add(reducerId);
|
|
406
|
+
try {
|
|
407
|
+
const entry = await this.reducerOutbox.enqueue({
|
|
408
|
+
scope, path: ref.path, args, idempotencyKey: reducerId, state: "pending",
|
|
409
|
+
// All local intents form one causal chain, including reads of rows
|
|
410
|
+
// written by a previous intent, not only overlapping write patches.
|
|
411
|
+
entityKeys: ["__gonvex_local_intents"], patches, localExecution: execution,
|
|
412
|
+
});
|
|
413
|
+
if (scope !== this.outboxScope || execution.scope !== this.replicaScope) {
|
|
414
|
+
throw new GonvexClientError("Session changed while persisting local reducer", { code: "superseded" });
|
|
415
|
+
}
|
|
416
|
+
this.optimisticOutboxEntryIds.set(reducerId, entry.id);
|
|
417
|
+
this.replacingLocal = true;
|
|
418
|
+
try {
|
|
419
|
+
this.addOptimisticReducer(reducerId, patches);
|
|
420
|
+
}
|
|
421
|
+
finally {
|
|
422
|
+
this.replacingLocal = false;
|
|
423
|
+
}
|
|
424
|
+
return transaction ? transaction.result : { status: "queued", reducerId };
|
|
425
|
+
}
|
|
426
|
+
finally {
|
|
427
|
+
this.directOutboxReducerIds.delete(reducerId);
|
|
428
|
+
this.scheduleLocalReplay();
|
|
429
|
+
void this.drainOutbox();
|
|
430
|
+
}
|
|
431
|
+
});
|
|
233
432
|
}
|
|
234
433
|
/** The single normalized authoritative + optimistic application data store. */
|
|
235
434
|
get localReplica() {
|
|
@@ -417,6 +616,8 @@ export class GonvexClient {
|
|
|
417
616
|
}
|
|
418
617
|
}
|
|
419
618
|
connect() {
|
|
619
|
+
if (this.updateRequired)
|
|
620
|
+
return;
|
|
420
621
|
if (this.socket && this.socket.readyState <= WebSocket.OPEN)
|
|
421
622
|
return;
|
|
422
623
|
const isReconnect = this.socket !== undefined;
|
|
@@ -505,6 +706,10 @@ export class GonvexClient {
|
|
|
505
706
|
}
|
|
506
707
|
return;
|
|
507
708
|
}
|
|
709
|
+
if (message.type === "client.updateRequired") {
|
|
710
|
+
this.requireClientUpdate(message.reason);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
508
713
|
if (message.type === "system.reload") {
|
|
509
714
|
if (typeof message.artifactHash === "string") {
|
|
510
715
|
this.activeArtifactHashValue = message.artifactHash;
|
|
@@ -587,6 +792,7 @@ export class GonvexClient {
|
|
|
587
792
|
if (message.type === "auth.result") {
|
|
588
793
|
const reauthenticatedSameSocket = this.authenticatedSocketGeneration === this.socketGeneration;
|
|
589
794
|
this.authenticatedSocketGeneration = this.socketGeneration;
|
|
795
|
+
this.lastOnlineAtMs = Date.now();
|
|
590
796
|
this.activeArtifactHashValue = artifactHashFromAuthResult(message.result) ?? this.activeArtifactHashValue;
|
|
591
797
|
const developerSessionToken = developerSessionTokenFromAuthResult(message.result);
|
|
592
798
|
if (developerSessionToken) {
|
|
@@ -596,6 +802,9 @@ export class GonvexClient {
|
|
|
596
802
|
}
|
|
597
803
|
this.authRetriedAfterError = false;
|
|
598
804
|
const directive = replicaDirectiveFromAuthResult(message.result);
|
|
805
|
+
const localIdentity = localIdentityFromAuthResult(message.result);
|
|
806
|
+
if (localIdentity)
|
|
807
|
+
this.localIdentity = localIdentity;
|
|
599
808
|
if (!directive) {
|
|
600
809
|
this.authInFlight = false;
|
|
601
810
|
if (!this.auth.tenant) {
|
|
@@ -615,6 +824,12 @@ export class GonvexClient {
|
|
|
615
824
|
}
|
|
616
825
|
void this.activateReplicaDirective(directive)
|
|
617
826
|
.then(() => this.activateOutboxScope())
|
|
827
|
+
.then(async () => {
|
|
828
|
+
if (localIdentity && this.localBinding)
|
|
829
|
+
await this.localStorage?.saveSession?.(this.outboxScope, {
|
|
830
|
+
directive, identity: localIdentity, artifactHash: this.activeArtifactHashValue ?? this.localBinding.artifactHash, lastOnlineAtMs: this.lastOnlineAtMs,
|
|
831
|
+
});
|
|
832
|
+
})
|
|
618
833
|
.then(() => {
|
|
619
834
|
// The accepted server identity is not usable until its durable
|
|
620
835
|
// Replica partition has been activated locally. Only now may
|
|
@@ -728,6 +943,10 @@ export class GonvexClient {
|
|
|
728
943
|
}
|
|
729
944
|
close() {
|
|
730
945
|
this.manuallyClosed = true;
|
|
946
|
+
this.unsubscribeLocal?.();
|
|
947
|
+
for (const close of this.localCollectionClosers.splice(0))
|
|
948
|
+
close();
|
|
949
|
+
this.localExecutor?.close();
|
|
731
950
|
this.cancelManagedAuthAttempt("Gonvex client was closed during authentication.");
|
|
732
951
|
if (isEphemeralOutboxScope(this.outboxScope)) {
|
|
733
952
|
void this.reducerOutbox.clear(this.outboxScope);
|
|
@@ -1777,7 +1996,7 @@ export class GonvexClient {
|
|
|
1777
1996
|
this.outboxScope = scope;
|
|
1778
1997
|
const ready = this.hasAuthoritativeReplicaScope
|
|
1779
1998
|
? this.restoreOutbox(scope, generation)
|
|
1780
|
-
:
|
|
1999
|
+
: this.restoreLocalSession(scope, generation);
|
|
1781
2000
|
this.outboxReady = ready;
|
|
1782
2001
|
return ready;
|
|
1783
2002
|
}
|
|
@@ -1812,8 +2031,10 @@ export class GonvexClient {
|
|
|
1812
2031
|
// and sends a second time with the same command ID.
|
|
1813
2032
|
this.outboxReady = this.restoreOutbox(this.outboxScope, generation);
|
|
1814
2033
|
await this.outboxReady;
|
|
2034
|
+
this.ensureLocalCollections();
|
|
1815
2035
|
}
|
|
1816
2036
|
quarantineReplicaScope() {
|
|
2037
|
+
this.localIdentity = undefined;
|
|
1817
2038
|
// Keep the durable prior identity scope intact for an authorized future
|
|
1818
2039
|
// login, but make every synchronous selector fail closed immediately.
|
|
1819
2040
|
// The random suffix prevents a denied scope from ever restoring rows.
|
|
@@ -1844,6 +2065,17 @@ export class GonvexClient {
|
|
|
1844
2065
|
|| scope !== this.outboxScope)
|
|
1845
2066
|
return;
|
|
1846
2067
|
for (const entry of entries) {
|
|
2068
|
+
// markCommitted follows the durable replica watermark barrier. Its
|
|
2069
|
+
// transaction is already in the restored base; never predict it twice.
|
|
2070
|
+
if (entry.localExecution && entry.state === "committed")
|
|
2071
|
+
await this.reducerOutbox.ack(entry.id);
|
|
2072
|
+
}
|
|
2073
|
+
if (entries.some((entry) => entry.localExecution) && this.localExecutor) {
|
|
2074
|
+
await this.inLocalLane(() => this.rebaseLocalEntries());
|
|
2075
|
+
}
|
|
2076
|
+
for (const entry of entries) {
|
|
2077
|
+
if (entry.localExecution && this.localExecutor)
|
|
2078
|
+
continue;
|
|
1847
2079
|
if (entry.state === "committed" && (entry.patches?.length ?? 0) === 0) {
|
|
1848
2080
|
await this.reducerOutbox.ack(entry.id);
|
|
1849
2081
|
continue;
|
|
@@ -1894,7 +2126,12 @@ export class GonvexClient {
|
|
|
1894
2126
|
try {
|
|
1895
2127
|
while (!this.manuallyClosed && this.socket?.readyState === WebSocket.OPEN) {
|
|
1896
2128
|
const scope = this.outboxScope;
|
|
1897
|
-
const entry = await this.
|
|
2129
|
+
const entry = await this.inLocalLane(async () => {
|
|
2130
|
+
const next = await this.reducerOutbox.nextReady(scope, Date.now());
|
|
2131
|
+
if (next)
|
|
2132
|
+
await this.reducerOutbox.markInflight(next.id);
|
|
2133
|
+
return next;
|
|
2134
|
+
});
|
|
1898
2135
|
if (!entry)
|
|
1899
2136
|
return;
|
|
1900
2137
|
if (scope !== this.outboxScope)
|
|
@@ -1920,9 +2157,16 @@ export class GonvexClient {
|
|
|
1920
2157
|
return;
|
|
1921
2158
|
}
|
|
1922
2159
|
try {
|
|
1923
|
-
await this.call("reducer", { kind: "reducer", path: entry.path }, entry.args, this.timeouts.reducerTimeoutMs, entry.idempotencyKey, entry.idempotencyKey);
|
|
2160
|
+
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);
|
|
1924
2161
|
await this.reducerOutbox.markCommitted(entry.id);
|
|
1925
|
-
if (
|
|
2162
|
+
if (entry.localExecution && this.localExecutor) {
|
|
2163
|
+
await this.inLocalLane(async () => {
|
|
2164
|
+
await this.reducerOutbox.ack(entry.id);
|
|
2165
|
+
if (scope === this.outboxScope)
|
|
2166
|
+
await this.rebaseLocalEntries();
|
|
2167
|
+
});
|
|
2168
|
+
}
|
|
2169
|
+
else if ((entry.patches?.length ?? 0) > 0) {
|
|
1926
2170
|
await this.settleOptimisticReducer(entry.idempotencyKey);
|
|
1927
2171
|
}
|
|
1928
2172
|
else {
|
|
@@ -1930,8 +2174,23 @@ export class GonvexClient {
|
|
|
1930
2174
|
}
|
|
1931
2175
|
}
|
|
1932
2176
|
catch (error) {
|
|
2177
|
+
if (error instanceof Error && /STALE_REDUCER_ARTIFACT|CLIENT_UPDATE_REQUIRED/.test(error.message)) {
|
|
2178
|
+
await this.reducerOutbox.markPending(entry.id);
|
|
2179
|
+
this.requireClientUpdate(error.message);
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
1933
2182
|
if (error instanceof GonvexClientError && error.code === "server") {
|
|
1934
|
-
|
|
2183
|
+
if (entry.localExecution && this.localExecutor) {
|
|
2184
|
+
await this.inLocalLane(async () => {
|
|
2185
|
+
await this.reducerOutbox.ack(entry.id);
|
|
2186
|
+
if (scope === this.outboxScope) {
|
|
2187
|
+
await this.rebaseLocalEntries();
|
|
2188
|
+
this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
|
|
2189
|
+
}
|
|
2190
|
+
});
|
|
2191
|
+
}
|
|
2192
|
+
else
|
|
2193
|
+
await this.rejectOptimisticReducer(entry.idempotencyKey, entry.id);
|
|
1935
2194
|
continue;
|
|
1936
2195
|
}
|
|
1937
2196
|
await this.reducerOutbox.fail(entry.id, reducerErrorMessage(error));
|
|
@@ -1958,6 +2217,8 @@ export class GonvexClient {
|
|
|
1958
2217
|
}, delay);
|
|
1959
2218
|
}
|
|
1960
2219
|
reducer(ref, args = {}, options = {}) {
|
|
2220
|
+
if (ref.localExecution === 1)
|
|
2221
|
+
return this.runLocalReducer(ref, args);
|
|
1961
2222
|
if (options.offline === "queue" && ref.offline?.mode !== "allowed") {
|
|
1962
2223
|
return Promise.reject(new GonvexClientError(`Reducer ${ref.path} does not allow offline queueing.`, { code: "disconnected", path: ref.path, operation: "reducer" }));
|
|
1963
2224
|
}
|
|
@@ -2044,7 +2305,17 @@ export class GonvexClient {
|
|
|
2044
2305
|
void this.drainOutbox();
|
|
2045
2306
|
}
|
|
2046
2307
|
}
|
|
2308
|
+
requireClientUpdate(reason) {
|
|
2309
|
+
if (this.updateRequired)
|
|
2310
|
+
return;
|
|
2311
|
+
this.updateRequired = true;
|
|
2312
|
+
// Keep durable intents intact. Closing suspends subscriptions and future sends.
|
|
2313
|
+
this.close();
|
|
2314
|
+
this.updateRequiredHandler?.(reason);
|
|
2315
|
+
}
|
|
2047
2316
|
canSendReducerNow() {
|
|
2317
|
+
if (this.updateRequired)
|
|
2318
|
+
return false;
|
|
2048
2319
|
if (globalThis.navigator?.onLine === false)
|
|
2049
2320
|
return false;
|
|
2050
2321
|
const socket = this.socket;
|
|
@@ -2189,6 +2460,10 @@ export class GonvexClient {
|
|
|
2189
2460
|
...(ref.scope === "control" ? { scope: "control" } : {}),
|
|
2190
2461
|
trace: { clientSentAtMs: entry.clientSentAtMs },
|
|
2191
2462
|
...(effectiveIdempotencyKey ? { idempotencyKey: effectiveIdempotencyKey } : {}),
|
|
2463
|
+
...(ref.artifactHash ? { artifactHash: ref.artifactHash } : {}),
|
|
2464
|
+
...(this.clientContract ? { clientContract: this.clientContract.version } : {}),
|
|
2465
|
+
...(ref.receiptPath ? { receiptPath: ref.receiptPath } : {}),
|
|
2466
|
+
...(ref.intentEntropy ? { intentEntropy: ref.intentEntropy } : {}),
|
|
2192
2467
|
});
|
|
2193
2468
|
}
|
|
2194
2469
|
else {
|
|
@@ -2491,6 +2766,9 @@ export class GonvexClient {
|
|
|
2491
2766
|
this.send(message);
|
|
2492
2767
|
}
|
|
2493
2768
|
hasControlPlaneWork() {
|
|
2769
|
+
for (const query of this.querySubscriptions.values())
|
|
2770
|
+
if (query.executionScope === "control")
|
|
2771
|
+
return true;
|
|
2494
2772
|
for (const query of this.oneShotQueries.values())
|
|
2495
2773
|
if (query.scope === "control")
|
|
2496
2774
|
return true;
|
|
@@ -2684,6 +2962,7 @@ export class GonvexClient {
|
|
|
2684
2962
|
this.managedAuthAttempt?.ids.add(id);
|
|
2685
2963
|
this.sendNow({
|
|
2686
2964
|
type: "auth",
|
|
2965
|
+
...(this.clientContract ? { clientContract: this.clientContract.version } : {}),
|
|
2687
2966
|
id,
|
|
2688
2967
|
token: this.auth.token,
|
|
2689
2968
|
project: this.auth.project,
|
|
@@ -3059,6 +3338,31 @@ function queryPatchRowKey(value) {
|
|
|
3059
3338
|
const candidate = value._id ?? value.id;
|
|
3060
3339
|
return typeof candidate === "string" || typeof candidate === "number" ? String(candidate) : "";
|
|
3061
3340
|
}
|
|
3341
|
+
function applyLocalPatches(snapshot, patches) {
|
|
3342
|
+
for (const patch of patches) {
|
|
3343
|
+
const entity = patch.entity ?? patch.collection;
|
|
3344
|
+
if (!entity)
|
|
3345
|
+
continue;
|
|
3346
|
+
const table = snapshot.tables[entity] ??= { complete: false, rows: [] };
|
|
3347
|
+
const index = table.rows.findIndex((row) => String(row._id ?? row.id) === patch.rowId);
|
|
3348
|
+
if (patch.op === "delete") {
|
|
3349
|
+
if (index >= 0)
|
|
3350
|
+
table.rows.splice(index, 1);
|
|
3351
|
+
}
|
|
3352
|
+
else if (index >= 0)
|
|
3353
|
+
table.rows[index] = { ...table.rows[index], ...structuredClone(patch.fields) };
|
|
3354
|
+
else if (patch.op !== "patch")
|
|
3355
|
+
table.rows.push(structuredClone(patch.fields));
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
function localIdentityFromAuthResult(result) {
|
|
3359
|
+
if (!result || typeof result !== "object" || Array.isArray(result))
|
|
3360
|
+
return;
|
|
3361
|
+
const identity = result.localIdentity;
|
|
3362
|
+
if (!identity?.auth?.account?.id || !identity.tenant?.id || !identity.member?.id)
|
|
3363
|
+
return;
|
|
3364
|
+
return structuredClone(identity);
|
|
3365
|
+
}
|
|
3062
3366
|
function authFromOptions(options) {
|
|
3063
3367
|
return {
|
|
3064
3368
|
project: options.project,
|