@gonvex/client 0.5.2-staging.1 → 0.5.2-staging.11
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 +109 -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/control.d.ts +3 -7
- package/dist/control.js +1 -2
- package/dist/control.js.map +1 -1
- package/dist/index.d.ts +48 -1
- package/dist/index.js +331 -10
- 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();
|
|
@@ -180,12 +192,22 @@ export class GonvexClient {
|
|
|
180
192
|
pendingCalls = new Map();
|
|
181
193
|
connectionStateHandlers = new Set();
|
|
182
194
|
supportCommandHandlers = new Set();
|
|
195
|
+
pendingNativeErrors = [];
|
|
183
196
|
isWebSocketConnected = false;
|
|
184
197
|
hasEverConnected = false;
|
|
185
198
|
connectionCount = 0;
|
|
186
199
|
timeouts;
|
|
200
|
+
clientContract;
|
|
201
|
+
updateRequiredHandler;
|
|
202
|
+
updateRequired = false;
|
|
203
|
+
lastOnlineAtMs = 0;
|
|
187
204
|
constructor(url, options = {}) {
|
|
188
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;
|
|
189
211
|
this.auth = authFromOptions(options);
|
|
190
212
|
this.telemetryEnabled = options.telemetry === true;
|
|
191
213
|
this.querySubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.querySubscriptionRetentionMs);
|
|
@@ -193,6 +215,8 @@ export class GonvexClient {
|
|
|
193
215
|
this.reducerOutbox = createReducerOutbox(options.outbox);
|
|
194
216
|
this.replica = new LocalReplica(options.localReplica?.storage);
|
|
195
217
|
this.replicaView = createLocalReplicaView(this.replica);
|
|
218
|
+
if (this.localExecutor)
|
|
219
|
+
this.unsubscribeLocal = this.replica.subscribe(() => this.scheduleLocalReplay());
|
|
196
220
|
this.unsubscribeOutbox = this.reducerOutbox.subscribe(() => {
|
|
197
221
|
void this.drainOutbox();
|
|
198
222
|
});
|
|
@@ -229,6 +253,182 @@ export class GonvexClient {
|
|
|
229
253
|
transport: (type, payload) => this.sendNativeError(type, payload),
|
|
230
254
|
});
|
|
231
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
|
+
});
|
|
232
432
|
}
|
|
233
433
|
/** The single normalized authoritative + optimistic application data store. */
|
|
234
434
|
get localReplica() {
|
|
@@ -504,6 +704,10 @@ export class GonvexClient {
|
|
|
504
704
|
}
|
|
505
705
|
return;
|
|
506
706
|
}
|
|
707
|
+
if (message.type === "client.updateRequired") {
|
|
708
|
+
this.requireClientUpdate(message.reason);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
507
711
|
if (message.type === "system.reload") {
|
|
508
712
|
if (typeof message.artifactHash === "string") {
|
|
509
713
|
this.activeArtifactHashValue = message.artifactHash;
|
|
@@ -577,6 +781,8 @@ export class GonvexClient {
|
|
|
577
781
|
this.activeAuthFrameId = message.id;
|
|
578
782
|
else
|
|
579
783
|
this.activeAuthFrameId = undefined;
|
|
784
|
+
if (message.type === "auth.result")
|
|
785
|
+
this.flushPendingNativeErrors();
|
|
580
786
|
if (this.authWatchdogTimer) {
|
|
581
787
|
clearTimeout(this.authWatchdogTimer);
|
|
582
788
|
this.authWatchdogTimer = undefined;
|
|
@@ -584,6 +790,7 @@ export class GonvexClient {
|
|
|
584
790
|
if (message.type === "auth.result") {
|
|
585
791
|
const reauthenticatedSameSocket = this.authenticatedSocketGeneration === this.socketGeneration;
|
|
586
792
|
this.authenticatedSocketGeneration = this.socketGeneration;
|
|
793
|
+
this.lastOnlineAtMs = Date.now();
|
|
587
794
|
this.activeArtifactHashValue = artifactHashFromAuthResult(message.result) ?? this.activeArtifactHashValue;
|
|
588
795
|
const developerSessionToken = developerSessionTokenFromAuthResult(message.result);
|
|
589
796
|
if (developerSessionToken) {
|
|
@@ -593,6 +800,9 @@ export class GonvexClient {
|
|
|
593
800
|
}
|
|
594
801
|
this.authRetriedAfterError = false;
|
|
595
802
|
const directive = replicaDirectiveFromAuthResult(message.result);
|
|
803
|
+
const localIdentity = localIdentityFromAuthResult(message.result);
|
|
804
|
+
if (localIdentity)
|
|
805
|
+
this.localIdentity = localIdentity;
|
|
596
806
|
if (!directive) {
|
|
597
807
|
this.authInFlight = false;
|
|
598
808
|
if (!this.auth.tenant) {
|
|
@@ -612,6 +822,12 @@ export class GonvexClient {
|
|
|
612
822
|
}
|
|
613
823
|
void this.activateReplicaDirective(directive)
|
|
614
824
|
.then(() => this.activateOutboxScope())
|
|
825
|
+
.then(async () => {
|
|
826
|
+
if (localIdentity && this.localBinding)
|
|
827
|
+
await this.localStorage?.saveSession?.(this.outboxScope, {
|
|
828
|
+
directive, identity: localIdentity, artifactHash: this.activeArtifactHashValue ?? this.localBinding.artifactHash, lastOnlineAtMs: this.lastOnlineAtMs,
|
|
829
|
+
});
|
|
830
|
+
})
|
|
615
831
|
.then(() => {
|
|
616
832
|
// The accepted server identity is not usable until its durable
|
|
617
833
|
// Replica partition has been activated locally. Only now may
|
|
@@ -725,6 +941,10 @@ export class GonvexClient {
|
|
|
725
941
|
}
|
|
726
942
|
close() {
|
|
727
943
|
this.manuallyClosed = true;
|
|
944
|
+
this.unsubscribeLocal?.();
|
|
945
|
+
for (const close of this.localCollectionClosers.splice(0))
|
|
946
|
+
close();
|
|
947
|
+
this.localExecutor?.close();
|
|
728
948
|
this.cancelManagedAuthAttempt("Gonvex client was closed during authentication.");
|
|
729
949
|
if (isEphemeralOutboxScope(this.outboxScope)) {
|
|
730
950
|
void this.reducerOutbox.clear(this.outboxScope);
|
|
@@ -777,6 +997,9 @@ export class GonvexClient {
|
|
|
777
997
|
this.notifyConnectionState();
|
|
778
998
|
this.connectionStateHandlers.clear();
|
|
779
999
|
this.supportCommandHandlers.clear();
|
|
1000
|
+
const closedError = new Error("Gonvex client is closed");
|
|
1001
|
+
for (const pending of this.pendingNativeErrors.splice(0))
|
|
1002
|
+
pending.reject(closedError);
|
|
780
1003
|
if (!socket)
|
|
781
1004
|
return;
|
|
782
1005
|
socket.close();
|
|
@@ -1771,7 +1994,7 @@ export class GonvexClient {
|
|
|
1771
1994
|
this.outboxScope = scope;
|
|
1772
1995
|
const ready = this.hasAuthoritativeReplicaScope
|
|
1773
1996
|
? this.restoreOutbox(scope, generation)
|
|
1774
|
-
:
|
|
1997
|
+
: this.restoreLocalSession(scope, generation);
|
|
1775
1998
|
this.outboxReady = ready;
|
|
1776
1999
|
return ready;
|
|
1777
2000
|
}
|
|
@@ -1806,8 +2029,10 @@ export class GonvexClient {
|
|
|
1806
2029
|
// and sends a second time with the same command ID.
|
|
1807
2030
|
this.outboxReady = this.restoreOutbox(this.outboxScope, generation);
|
|
1808
2031
|
await this.outboxReady;
|
|
2032
|
+
this.ensureLocalCollections();
|
|
1809
2033
|
}
|
|
1810
2034
|
quarantineReplicaScope() {
|
|
2035
|
+
this.localIdentity = undefined;
|
|
1811
2036
|
// Keep the durable prior identity scope intact for an authorized future
|
|
1812
2037
|
// login, but make every synchronous selector fail closed immediately.
|
|
1813
2038
|
// The random suffix prevents a denied scope from ever restoring rows.
|
|
@@ -1838,6 +2063,17 @@ export class GonvexClient {
|
|
|
1838
2063
|
|| scope !== this.outboxScope)
|
|
1839
2064
|
return;
|
|
1840
2065
|
for (const entry of entries) {
|
|
2066
|
+
// markCommitted follows the durable replica watermark barrier. Its
|
|
2067
|
+
// transaction is already in the restored base; never predict it twice.
|
|
2068
|
+
if (entry.localExecution && entry.state === "committed")
|
|
2069
|
+
await this.reducerOutbox.ack(entry.id);
|
|
2070
|
+
}
|
|
2071
|
+
if (entries.some((entry) => entry.localExecution) && this.localExecutor) {
|
|
2072
|
+
await this.inLocalLane(() => this.rebaseLocalEntries());
|
|
2073
|
+
}
|
|
2074
|
+
for (const entry of entries) {
|
|
2075
|
+
if (entry.localExecution && this.localExecutor)
|
|
2076
|
+
continue;
|
|
1841
2077
|
if (entry.state === "committed" && (entry.patches?.length ?? 0) === 0) {
|
|
1842
2078
|
await this.reducerOutbox.ack(entry.id);
|
|
1843
2079
|
continue;
|
|
@@ -1888,7 +2124,12 @@ export class GonvexClient {
|
|
|
1888
2124
|
try {
|
|
1889
2125
|
while (!this.manuallyClosed && this.socket?.readyState === WebSocket.OPEN) {
|
|
1890
2126
|
const scope = this.outboxScope;
|
|
1891
|
-
const entry = await this.
|
|
2127
|
+
const entry = await this.inLocalLane(async () => {
|
|
2128
|
+
const next = await this.reducerOutbox.nextReady(scope, Date.now());
|
|
2129
|
+
if (next)
|
|
2130
|
+
await this.reducerOutbox.markInflight(next.id);
|
|
2131
|
+
return next;
|
|
2132
|
+
});
|
|
1892
2133
|
if (!entry)
|
|
1893
2134
|
return;
|
|
1894
2135
|
if (scope !== this.outboxScope)
|
|
@@ -1914,9 +2155,16 @@ export class GonvexClient {
|
|
|
1914
2155
|
return;
|
|
1915
2156
|
}
|
|
1916
2157
|
try {
|
|
1917
|
-
await this.call("reducer", { kind: "reducer", path: entry.path }, entry.args, this.timeouts.reducerTimeoutMs, entry.idempotencyKey, entry.idempotencyKey);
|
|
2158
|
+
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);
|
|
1918
2159
|
await this.reducerOutbox.markCommitted(entry.id);
|
|
1919
|
-
if (
|
|
2160
|
+
if (entry.localExecution && this.localExecutor) {
|
|
2161
|
+
await this.inLocalLane(async () => {
|
|
2162
|
+
await this.reducerOutbox.ack(entry.id);
|
|
2163
|
+
if (scope === this.outboxScope)
|
|
2164
|
+
await this.rebaseLocalEntries();
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
2167
|
+
else if ((entry.patches?.length ?? 0) > 0) {
|
|
1920
2168
|
await this.settleOptimisticReducer(entry.idempotencyKey);
|
|
1921
2169
|
}
|
|
1922
2170
|
else {
|
|
@@ -1924,8 +2172,23 @@ export class GonvexClient {
|
|
|
1924
2172
|
}
|
|
1925
2173
|
}
|
|
1926
2174
|
catch (error) {
|
|
2175
|
+
if (error instanceof Error && /STALE_REDUCER_ARTIFACT|CLIENT_UPDATE_REQUIRED/.test(error.message)) {
|
|
2176
|
+
await this.reducerOutbox.markPending(entry.id);
|
|
2177
|
+
this.requireClientUpdate(error.message);
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
1927
2180
|
if (error instanceof GonvexClientError && error.code === "server") {
|
|
1928
|
-
|
|
2181
|
+
if (entry.localExecution && this.localExecutor) {
|
|
2182
|
+
await this.inLocalLane(async () => {
|
|
2183
|
+
await this.reducerOutbox.ack(entry.id);
|
|
2184
|
+
if (scope === this.outboxScope) {
|
|
2185
|
+
await this.rebaseLocalEntries();
|
|
2186
|
+
this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
|
|
2187
|
+
}
|
|
2188
|
+
});
|
|
2189
|
+
}
|
|
2190
|
+
else
|
|
2191
|
+
await this.rejectOptimisticReducer(entry.idempotencyKey, entry.id);
|
|
1929
2192
|
continue;
|
|
1930
2193
|
}
|
|
1931
2194
|
await this.reducerOutbox.fail(entry.id, reducerErrorMessage(error));
|
|
@@ -1952,6 +2215,8 @@ export class GonvexClient {
|
|
|
1952
2215
|
}, delay);
|
|
1953
2216
|
}
|
|
1954
2217
|
reducer(ref, args = {}, options = {}) {
|
|
2218
|
+
if (ref.localExecution === 1)
|
|
2219
|
+
return this.runLocalReducer(ref, args);
|
|
1955
2220
|
if (options.offline === "queue" && ref.offline?.mode !== "allowed") {
|
|
1956
2221
|
return Promise.reject(new GonvexClientError(`Reducer ${ref.path} does not allow offline queueing.`, { code: "disconnected", path: ref.path, operation: "reducer" }));
|
|
1957
2222
|
}
|
|
@@ -2038,7 +2303,17 @@ export class GonvexClient {
|
|
|
2038
2303
|
void this.drainOutbox();
|
|
2039
2304
|
}
|
|
2040
2305
|
}
|
|
2306
|
+
requireClientUpdate(reason) {
|
|
2307
|
+
if (this.updateRequired)
|
|
2308
|
+
return;
|
|
2309
|
+
this.updateRequired = true;
|
|
2310
|
+
// Keep durable intents intact. Closing suspends subscriptions and future sends.
|
|
2311
|
+
this.close();
|
|
2312
|
+
this.updateRequiredHandler?.(reason);
|
|
2313
|
+
}
|
|
2041
2314
|
canSendReducerNow() {
|
|
2315
|
+
if (this.updateRequired)
|
|
2316
|
+
return false;
|
|
2042
2317
|
if (globalThis.navigator?.onLine === false)
|
|
2043
2318
|
return false;
|
|
2044
2319
|
const socket = this.socket;
|
|
@@ -2183,6 +2458,10 @@ export class GonvexClient {
|
|
|
2183
2458
|
...(ref.scope === "control" ? { scope: "control" } : {}),
|
|
2184
2459
|
trace: { clientSentAtMs: entry.clientSentAtMs },
|
|
2185
2460
|
...(effectiveIdempotencyKey ? { idempotencyKey: effectiveIdempotencyKey } : {}),
|
|
2461
|
+
...(ref.artifactHash ? { artifactHash: ref.artifactHash } : {}),
|
|
2462
|
+
...(this.clientContract ? { clientContract: this.clientContract.version } : {}),
|
|
2463
|
+
...(ref.receiptPath ? { receiptPath: ref.receiptPath } : {}),
|
|
2464
|
+
...(ref.intentEntropy ? { intentEntropy: ref.intentEntropy } : {}),
|
|
2186
2465
|
});
|
|
2187
2466
|
}
|
|
2188
2467
|
else {
|
|
@@ -2485,6 +2764,9 @@ export class GonvexClient {
|
|
|
2485
2764
|
this.send(message);
|
|
2486
2765
|
}
|
|
2487
2766
|
hasControlPlaneWork() {
|
|
2767
|
+
for (const query of this.querySubscriptions.values())
|
|
2768
|
+
if (query.executionScope === "control")
|
|
2769
|
+
return true;
|
|
2488
2770
|
for (const query of this.oneShotQueries.values())
|
|
2489
2771
|
if (query.scope === "control")
|
|
2490
2772
|
return true;
|
|
@@ -2615,6 +2897,21 @@ export class GonvexClient {
|
|
|
2615
2897
|
if (this.manuallyClosed)
|
|
2616
2898
|
return Promise.reject(new Error("Gonvex client is closed"));
|
|
2617
2899
|
this.connect();
|
|
2900
|
+
if (!this.activeAuthFrameId) {
|
|
2901
|
+
return new Promise((resolve, reject) => {
|
|
2902
|
+
this.pendingNativeErrors.push({ type, payload, resolve, reject });
|
|
2903
|
+
});
|
|
2904
|
+
}
|
|
2905
|
+
return this.sendNativeErrorFrame(type, payload);
|
|
2906
|
+
}
|
|
2907
|
+
flushPendingNativeErrors() {
|
|
2908
|
+
if (!this.activeAuthFrameId || this.pendingNativeErrors.length === 0)
|
|
2909
|
+
return;
|
|
2910
|
+
for (const pending of this.pendingNativeErrors.splice(0)) {
|
|
2911
|
+
void this.sendNativeErrorFrame(pending.type, pending.payload).then(pending.resolve, pending.reject);
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
sendNativeErrorFrame(type, payload) {
|
|
2618
2915
|
const id = randomID();
|
|
2619
2916
|
return new Promise((resolve, reject) => {
|
|
2620
2917
|
const timer = setTimeout(() => {
|
|
@@ -2663,6 +2960,7 @@ export class GonvexClient {
|
|
|
2663
2960
|
this.managedAuthAttempt?.ids.add(id);
|
|
2664
2961
|
this.sendNow({
|
|
2665
2962
|
type: "auth",
|
|
2963
|
+
...(this.clientContract ? { clientContract: this.clientContract.version } : {}),
|
|
2666
2964
|
id,
|
|
2667
2965
|
token: this.auth.token,
|
|
2668
2966
|
project: this.auth.project,
|
|
@@ -3038,6 +3336,31 @@ function queryPatchRowKey(value) {
|
|
|
3038
3336
|
const candidate = value._id ?? value.id;
|
|
3039
3337
|
return typeof candidate === "string" || typeof candidate === "number" ? String(candidate) : "";
|
|
3040
3338
|
}
|
|
3339
|
+
function applyLocalPatches(snapshot, patches) {
|
|
3340
|
+
for (const patch of patches) {
|
|
3341
|
+
const entity = patch.entity ?? patch.collection;
|
|
3342
|
+
if (!entity)
|
|
3343
|
+
continue;
|
|
3344
|
+
const table = snapshot.tables[entity] ??= { complete: false, rows: [] };
|
|
3345
|
+
const index = table.rows.findIndex((row) => String(row._id ?? row.id) === patch.rowId);
|
|
3346
|
+
if (patch.op === "delete") {
|
|
3347
|
+
if (index >= 0)
|
|
3348
|
+
table.rows.splice(index, 1);
|
|
3349
|
+
}
|
|
3350
|
+
else if (index >= 0)
|
|
3351
|
+
table.rows[index] = { ...table.rows[index], ...structuredClone(patch.fields) };
|
|
3352
|
+
else if (patch.op !== "patch")
|
|
3353
|
+
table.rows.push(structuredClone(patch.fields));
|
|
3354
|
+
}
|
|
3355
|
+
}
|
|
3356
|
+
function localIdentityFromAuthResult(result) {
|
|
3357
|
+
if (!result || typeof result !== "object" || Array.isArray(result))
|
|
3358
|
+
return;
|
|
3359
|
+
const identity = result.localIdentity;
|
|
3360
|
+
if (!identity?.auth?.account?.id || !identity.tenant?.id || !identity.member?.id)
|
|
3361
|
+
return;
|
|
3362
|
+
return structuredClone(identity);
|
|
3363
|
+
}
|
|
3041
3364
|
function authFromOptions(options) {
|
|
3042
3365
|
return {
|
|
3043
3366
|
project: options.project,
|
|
@@ -3056,8 +3379,6 @@ function normalizeQuerySubscriptionRetentionMs(value) {
|
|
|
3056
3379
|
return Math.max(0, Math.min(5 * 60_000, Math.floor(value)));
|
|
3057
3380
|
}
|
|
3058
3381
|
function authIdentityKey(auth) {
|
|
3059
|
-
if (!auth.tenant)
|
|
3060
|
-
return "";
|
|
3061
3382
|
if (auth.token) {
|
|
3062
3383
|
const tokenIdentity = authIdentityKeyFromToken(auth);
|
|
3063
3384
|
if (tokenIdentity)
|
|
@@ -3067,12 +3388,12 @@ function authIdentityKey(auth) {
|
|
|
3067
3388
|
// token would supply, so both paths derive the same key for the same Account.
|
|
3068
3389
|
const hint = auth.identity;
|
|
3069
3390
|
if (hint && typeof hint.sub === "string" && hint.sub.trim()) {
|
|
3070
|
-
return [auth.project ?? "", auth.tenant, hint.iss ?? "", hint.sub].join("\u0000");
|
|
3391
|
+
return [auth.project ?? "", auth.tenant ?? "", hint.iss ?? "", hint.sub].join("\u0000");
|
|
3071
3392
|
}
|
|
3072
3393
|
return "";
|
|
3073
3394
|
}
|
|
3074
3395
|
function authIdentityKeyFromToken(auth) {
|
|
3075
|
-
if (!auth.token
|
|
3396
|
+
if (!auth.token)
|
|
3076
3397
|
return "";
|
|
3077
3398
|
const parts = auth.token.split(".");
|
|
3078
3399
|
if (parts.length < 2)
|
|
@@ -3085,7 +3406,7 @@ function authIdentityKeyFromToken(auth) {
|
|
|
3085
3406
|
return "";
|
|
3086
3407
|
return [
|
|
3087
3408
|
auth.project ?? "",
|
|
3088
|
-
auth.tenant,
|
|
3409
|
+
auth.tenant ?? "",
|
|
3089
3410
|
typeof payload.iss === "string" ? payload.iss : "",
|
|
3090
3411
|
payload.sub,
|
|
3091
3412
|
].join("\u0000");
|