@gonvex/client 0.5.2-staging.14 → 0.5.2-staging.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/dist/client-upgrades.js +2 -1
- package/dist/client-upgrades.js.map +1 -1
- package/dist/index.d.ts +169 -9
- package/dist/index.js +493 -39
- package/dist/index.js.map +1 -1
- package/dist/outbox.d.ts +91 -9
- package/dist/outbox.js +278 -90
- package/dist/outbox.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { applyReplicaHashDelta, replicaHashesDigest, replicaRowsHashes } from ".
|
|
|
3
3
|
import { GonvexErrorReporter } from "./error-reporter.js";
|
|
4
4
|
export { GonvexErrorReporter } from "./error-reporter.js";
|
|
5
5
|
import { optimisticPatchesFromReference, } from "./optimistic.js";
|
|
6
|
-
import { createReducerOutbox, } from "./outbox.js";
|
|
6
|
+
import { createReducerOutbox, DEFAULT_OUTBOX_MAX_BACKOFF_MS, outboxBackoffMs, outboxEntryIsLive, } from "./outbox.js";
|
|
7
7
|
import { LocalReplica, } from "./local-replica.js";
|
|
8
8
|
import { runOfflineLiveQuery, liveScopeCoverage } from "./query-expression.js";
|
|
9
9
|
export * from "./error-reporter.js";
|
|
@@ -96,14 +96,28 @@ export class GonvexClientError extends Error {
|
|
|
96
96
|
code;
|
|
97
97
|
path;
|
|
98
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;
|
|
99
107
|
constructor(message, options) {
|
|
100
108
|
super(message);
|
|
101
109
|
this.name = "GonvexClientError";
|
|
102
110
|
this.code = options.code;
|
|
103
111
|
this.path = options.path;
|
|
104
112
|
this.operation = options.operation;
|
|
113
|
+
if (options.errorClass)
|
|
114
|
+
this.errorClass = options.errorClass;
|
|
115
|
+
if (options.retryable !== undefined)
|
|
116
|
+
this.retryable = options.retryable;
|
|
105
117
|
}
|
|
106
118
|
}
|
|
119
|
+
export const DEFAULT_OUTBOX_MAX_ATTEMPTS = 10;
|
|
120
|
+
export const DEFAULT_OUTBOX_RETRY_MAX_BACKOFF_MS = 60_000;
|
|
107
121
|
export const DEFAULT_QUERY_TIMEOUT_MS = 20_000;
|
|
108
122
|
export const DEFAULT_REDUCER_TIMEOUT_MS = 20_000;
|
|
109
123
|
export const DEFAULT_ACTION_TIMEOUT_MS = 60_000;
|
|
@@ -137,6 +151,12 @@ export class GonvexClient {
|
|
|
137
151
|
localCollectionKeys = new Set();
|
|
138
152
|
localExecutionTables = new Map();
|
|
139
153
|
reducerRejectionHandlers = new Set();
|
|
154
|
+
outboxRetry;
|
|
155
|
+
unauthenticatedRetries = 0;
|
|
156
|
+
intentsSnapshotValue = [];
|
|
157
|
+
intentListeners = new Set();
|
|
158
|
+
intentsRefreshRunning = false;
|
|
159
|
+
intentsRefreshDirty = false;
|
|
140
160
|
socket;
|
|
141
161
|
handlers = new Map();
|
|
142
162
|
querySubscriptions = new Map();
|
|
@@ -197,6 +217,8 @@ export class GonvexClient {
|
|
|
197
217
|
peerRefreshDirty = false;
|
|
198
218
|
unsubscribeBrowserOnline;
|
|
199
219
|
drainingOutbox = false;
|
|
220
|
+
/** A wake-up (timer, enqueue, reconnect) that arrived while a drain was running. */
|
|
221
|
+
outboxDrainRequested = false;
|
|
200
222
|
outboxDrainTimer;
|
|
201
223
|
sessionScopeHandlers = new Set();
|
|
202
224
|
errorReporter;
|
|
@@ -236,6 +258,10 @@ export class GonvexClient {
|
|
|
236
258
|
this.replicaSubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.replicaSubscriptionRetentionMs);
|
|
237
259
|
this.sharedOutboxStore = options.outbox?.store?.shared ? options.outbox.store : undefined;
|
|
238
260
|
this.reducerOutbox = createReducerOutbox(options.outbox);
|
|
261
|
+
this.outboxRetry = {
|
|
262
|
+
maxAttempts: positiveOr(options.outbox?.retry?.maxAttempts, DEFAULT_OUTBOX_MAX_ATTEMPTS),
|
|
263
|
+
maxBackoffMs: positiveOr(options.outbox?.retry?.maxBackoffMs, DEFAULT_OUTBOX_RETRY_MAX_BACKOFF_MS),
|
|
264
|
+
};
|
|
239
265
|
this.replica = new LocalReplica(options.localReplica?.storage, { maxResidentRows: options.localReplica?.maxResidentRows, maxResidentBytes: options.localReplica?.maxResidentBytes });
|
|
240
266
|
this.replicaView = createLocalReplicaView(this.replica);
|
|
241
267
|
if (this.localExecutor) {
|
|
@@ -254,6 +280,7 @@ export class GonvexClient {
|
|
|
254
280
|
this.refreshPeerOutbox();
|
|
255
281
|
});
|
|
256
282
|
this.unsubscribeOutbox = this.reducerOutbox.subscribe(() => {
|
|
283
|
+
this.refreshIntents();
|
|
257
284
|
void this.drainOutbox();
|
|
258
285
|
});
|
|
259
286
|
if (typeof globalThis.addEventListener === "function") {
|
|
@@ -292,11 +319,254 @@ export class GonvexClient {
|
|
|
292
319
|
if (this.localBinding)
|
|
293
320
|
this.outboxReady = this.restoreLocalSession(initialScope, this.outboxScopeGeneration);
|
|
294
321
|
}
|
|
295
|
-
/**
|
|
322
|
+
/**
|
|
323
|
+
* Authoritative sync failures arrive after an offline call returned locally.
|
|
324
|
+
* The intent also stays in the outbox as `rejected` until it is discarded
|
|
325
|
+
* or retried, so a UI that mounts later can still show it.
|
|
326
|
+
*/
|
|
296
327
|
onReducerRejection(listener) {
|
|
297
328
|
this.reducerRejectionHandlers.add(listener);
|
|
298
329
|
return () => this.reducerRejectionHandlers.delete(listener);
|
|
299
330
|
}
|
|
331
|
+
/** Every durable intent of the current identity, oldest first. */
|
|
332
|
+
async listIntents() {
|
|
333
|
+
await this.outboxReady;
|
|
334
|
+
return (await this.reducerOutbox.list(this.outboxScope)).map(outboxIntentFromEntry);
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Synchronous snapshot for external stores (React `useSyncExternalStore`).
|
|
338
|
+
* Kept current while at least one {@link subscribeIntents} listener exists.
|
|
339
|
+
*/
|
|
340
|
+
intentsSnapshot() {
|
|
341
|
+
return this.intentsSnapshotValue;
|
|
342
|
+
}
|
|
343
|
+
/** Observe intent changes; call {@link intentsSnapshot} for the new value. */
|
|
344
|
+
subscribeIntents(listener) {
|
|
345
|
+
this.intentListeners.add(listener);
|
|
346
|
+
if (this.intentListeners.size === 1)
|
|
347
|
+
this.refreshIntents();
|
|
348
|
+
return () => { this.intentListeners.delete(listener); };
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Delivery status for one row from the current snapshot: `failed` or
|
|
352
|
+
* `rejected` when an intent touching it needs attention, `syncing` while one
|
|
353
|
+
* is still queued, otherwise undefined.
|
|
354
|
+
*/
|
|
355
|
+
entityIntentStatus(entity, id) {
|
|
356
|
+
return entityStatusFromIntents(this.intentsSnapshotValue, entity, id);
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Re-arm a `failed` or `rejected` intent with a fresh retry budget. The
|
|
360
|
+
* original idempotency key is reused, so an attempt that had actually
|
|
361
|
+
* committed is replayed by the server rather than applied twice.
|
|
362
|
+
*/
|
|
363
|
+
async retryIntent(id) {
|
|
364
|
+
await this.outboxReady;
|
|
365
|
+
const retried = await this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
|
|
366
|
+
const scope = this.outboxScope;
|
|
367
|
+
const entry = (await this.reducerOutbox.list(scope)).find((candidate) => candidate.idempotencyKey === id);
|
|
368
|
+
if (!entry || scope !== this.outboxScope)
|
|
369
|
+
return false;
|
|
370
|
+
const updated = await this.reducerOutbox.retry(entry.id);
|
|
371
|
+
if (!updated)
|
|
372
|
+
return false;
|
|
373
|
+
this.optimisticOutboxEntryIds.set(id, updated.id);
|
|
374
|
+
if (!outboxEntryIsLive(entry)) {
|
|
375
|
+
// A rejection rolled the prediction back; restore it at its original
|
|
376
|
+
// position in the causal chain.
|
|
377
|
+
if (updated.localExecution && this.localExecutor)
|
|
378
|
+
await this.rebaseLocalEntriesLocked(true);
|
|
379
|
+
else
|
|
380
|
+
this.addOptimisticReducer(id, updated.patches ?? []);
|
|
381
|
+
}
|
|
382
|
+
return true;
|
|
383
|
+
}));
|
|
384
|
+
if (retried) {
|
|
385
|
+
this.unauthenticatedRetries = 0;
|
|
386
|
+
void this.drainOutbox();
|
|
387
|
+
}
|
|
388
|
+
return retried;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Drop a `pending`, `failed` or `rejected` intent: it will never be sent,
|
|
392
|
+
* its optimistic prediction is removed and later local intents are rebased.
|
|
393
|
+
* Inflight and committed intents cannot be discarded. Discarding an intent
|
|
394
|
+
* whose earlier attempt timed out does not undo a commit that the server
|
|
395
|
+
* may already have applied; the replica then shows the server's truth.
|
|
396
|
+
*/
|
|
397
|
+
async discardIntent(id) {
|
|
398
|
+
await this.outboxReady;
|
|
399
|
+
return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
|
|
400
|
+
const scope = this.outboxScope;
|
|
401
|
+
const entry = (await this.reducerOutbox.list(scope)).find((candidate) => candidate.idempotencyKey === id);
|
|
402
|
+
if (!entry || scope !== this.outboxScope)
|
|
403
|
+
return false;
|
|
404
|
+
const removed = await this.reducerOutbox.discard(entry.id, ["pending", "failed", "rejected"]);
|
|
405
|
+
if (!removed)
|
|
406
|
+
return false;
|
|
407
|
+
this.optimisticOutboxEntryIds.delete(id);
|
|
408
|
+
this.optimisticReducerIds.delete(id);
|
|
409
|
+
if (outboxEntryIsLive(removed)) {
|
|
410
|
+
this.replacingLocal = true;
|
|
411
|
+
try {
|
|
412
|
+
this.replica.rejectCommand(id);
|
|
413
|
+
}
|
|
414
|
+
finally {
|
|
415
|
+
this.replacingLocal = false;
|
|
416
|
+
}
|
|
417
|
+
if (removed.localExecution && this.localExecutor)
|
|
418
|
+
await this.rebaseLocalEntriesLocked(true);
|
|
419
|
+
}
|
|
420
|
+
return true;
|
|
421
|
+
}));
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Discard the persisted Local Replica of the active identity (IndexedDB,
|
|
425
|
+
* Expo SQLite or any configured storage) and rehydrate it from the server.
|
|
426
|
+
*
|
|
427
|
+
* Online only: without an authenticated connection the call rejects with a
|
|
428
|
+
* `GonvexClientError` (`code: "disconnected"`) and changes nothing, so the
|
|
429
|
+
* user is never left with an empty cache that cannot be refilled. The saved
|
|
430
|
+
* offline session (identity and replica directive) is kept.
|
|
431
|
+
*
|
|
432
|
+
* By default the outbox is untouched: pending, failed and rejected intents
|
|
433
|
+
* stay durable, and the predictions of every live intent are re-applied
|
|
434
|
+
* immediately and recomputed as server data arrives. Active Replica
|
|
435
|
+
* Collections and Live Queries are re-requested without their old cursors,
|
|
436
|
+
* so the server sends full snapshots.
|
|
437
|
+
*/
|
|
438
|
+
async resetLocalReplica(options = {}) {
|
|
439
|
+
const refuse = (message) => new GonvexClientError(message, { code: "disconnected", operation: "query" });
|
|
440
|
+
if (this.manuallyClosed)
|
|
441
|
+
throw new GonvexClientError("Gonvex client is closed", { code: "closed", operation: "query" });
|
|
442
|
+
await this.outboxReady;
|
|
443
|
+
await this.replicaReady;
|
|
444
|
+
if (!this.canSendReducerNow() || !this.hasAuthoritativeReplicaScope) {
|
|
445
|
+
throw refuse("Cannot reset local data while offline. Reconnect and try again; cached data was kept.");
|
|
446
|
+
}
|
|
447
|
+
const keepOutbox = options.keepOutbox !== false;
|
|
448
|
+
return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
|
|
449
|
+
const scope = this.outboxScope;
|
|
450
|
+
const replicaScope = this.replicaScope;
|
|
451
|
+
// Re-check under the lock: the connection may have dropped while waiting.
|
|
452
|
+
if (!this.canSendReducerNow() || !this.hasAuthoritativeReplicaScope) {
|
|
453
|
+
throw refuse("Cannot reset local data while offline. Reconnect and try again; cached data was kept.");
|
|
454
|
+
}
|
|
455
|
+
// Stop the server streaming into subscription ids we are about to retire.
|
|
456
|
+
for (const subscription of this.replicaSubscriptions.values()) {
|
|
457
|
+
if (subscription.socketGeneration !== undefined)
|
|
458
|
+
this.send({ type: "replica.close", id: subscription.id });
|
|
459
|
+
}
|
|
460
|
+
for (const subscription of this.querySubscriptions.values()) {
|
|
461
|
+
if (subscription.socketGeneration !== undefined)
|
|
462
|
+
this.send({ type: "query.unsubscribe", id: subscription.id });
|
|
463
|
+
}
|
|
464
|
+
this.pendingReplicaTransactions.length = 0;
|
|
465
|
+
await this.replica.clear(replicaScope);
|
|
466
|
+
this.resetReplicaScopeState();
|
|
467
|
+
this.rotateSubscriptionScopes();
|
|
468
|
+
let discardedIntents = 0;
|
|
469
|
+
if (!keepOutbox) {
|
|
470
|
+
for (const entry of await this.reducerOutbox.list(scope)) {
|
|
471
|
+
if (await this.reducerOutbox.discard(entry.id, ["pending", "failed", "rejected"]))
|
|
472
|
+
discardedIntents += 1;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// replica.clear() dropped every overlay. Restore the stored predictions
|
|
476
|
+
// of live intents now; local intents are re-executed by the replay that
|
|
477
|
+
// each arriving snapshot schedules.
|
|
478
|
+
const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
|
|
479
|
+
this.replacingLocal = true;
|
|
480
|
+
try {
|
|
481
|
+
this.replaceLocalPredictions(entries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
this.replacingLocal = false;
|
|
485
|
+
}
|
|
486
|
+
this.optimisticReducerIds.clear();
|
|
487
|
+
this.optimisticOutboxEntryIds.clear();
|
|
488
|
+
for (const entry of entries) {
|
|
489
|
+
this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
|
|
490
|
+
if (entry.state !== "committed" && entry.patches?.length)
|
|
491
|
+
this.optimisticReducerIds.add(entry.idempotencyKey);
|
|
492
|
+
}
|
|
493
|
+
let resubscribed = 0;
|
|
494
|
+
if (scope === this.outboxScope && replicaScope === this.replicaScope) {
|
|
495
|
+
for (const subscription of this.replicaSubscriptions.values())
|
|
496
|
+
if (subscription.listeners.size > 0)
|
|
497
|
+
resubscribed += 1;
|
|
498
|
+
for (const subscription of this.querySubscriptions.values())
|
|
499
|
+
if (subscription.listeners.size > 0)
|
|
500
|
+
resubscribed += 1;
|
|
501
|
+
this.resumeQuerySubscriptions(true);
|
|
502
|
+
this.resumeReplicaSubscriptions();
|
|
503
|
+
}
|
|
504
|
+
this.refreshIntents();
|
|
505
|
+
return { discardedIntents, resubscribed };
|
|
506
|
+
}));
|
|
507
|
+
}
|
|
508
|
+
/** Outbox owners with durable entries, including identities that never returned. */
|
|
509
|
+
async listOutboxScopes() {
|
|
510
|
+
await this.outboxReady;
|
|
511
|
+
const current = this.outboxScope;
|
|
512
|
+
return (await this.reducerOutbox.listScopes()).map((scope) => ({ ...scope, current: scope.scope === current }));
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Permanently delete another identity's durable intents. The active scope
|
|
516
|
+
* cannot be purged this way; use {@link discardIntent} for its entries.
|
|
517
|
+
*/
|
|
518
|
+
async purgeOutboxScope(scope) {
|
|
519
|
+
await this.outboxReady;
|
|
520
|
+
if (scope === this.outboxScope)
|
|
521
|
+
throw new Error("Cannot purge the active outbox scope");
|
|
522
|
+
return this.reducerOutbox.purgeScope(scope);
|
|
523
|
+
}
|
|
524
|
+
/** Purge every outbox scope except the current identity's. Never runs automatically. */
|
|
525
|
+
async purgeForeignOutboxScopes() {
|
|
526
|
+
let removed = 0;
|
|
527
|
+
for (const scope of await this.listOutboxScopes()) {
|
|
528
|
+
if (!scope.current)
|
|
529
|
+
removed += await this.purgeOutboxScope(scope.scope);
|
|
530
|
+
}
|
|
531
|
+
return removed;
|
|
532
|
+
}
|
|
533
|
+
refreshIntents() {
|
|
534
|
+
if (this.intentListeners.size === 0 || this.manuallyClosed)
|
|
535
|
+
return;
|
|
536
|
+
this.intentsRefreshDirty = true;
|
|
537
|
+
if (this.intentsRefreshRunning)
|
|
538
|
+
return;
|
|
539
|
+
this.intentsRefreshRunning = true;
|
|
540
|
+
void (async () => {
|
|
541
|
+
try {
|
|
542
|
+
while (this.intentsRefreshDirty && !this.manuallyClosed) {
|
|
543
|
+
this.intentsRefreshDirty = false;
|
|
544
|
+
await this.outboxReady.catch(() => undefined);
|
|
545
|
+
const scope = this.outboxScope;
|
|
546
|
+
const entries = await this.reducerOutbox.list(scope).catch(() => undefined);
|
|
547
|
+
if (!entries || scope !== this.outboxScope) {
|
|
548
|
+
this.intentsRefreshDirty ||= scope !== this.outboxScope;
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
this.publishIntents(entries.map(outboxIntentFromEntry));
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
finally {
|
|
555
|
+
this.intentsRefreshRunning = false;
|
|
556
|
+
}
|
|
557
|
+
})();
|
|
558
|
+
}
|
|
559
|
+
publishIntents(next) {
|
|
560
|
+
if (sameIntents(this.intentsSnapshotValue, next))
|
|
561
|
+
return;
|
|
562
|
+
this.intentsSnapshotValue = next;
|
|
563
|
+
for (const listener of this.intentListeners) {
|
|
564
|
+
try {
|
|
565
|
+
listener();
|
|
566
|
+
}
|
|
567
|
+
catch { /* A listener must not break delivery. */ }
|
|
568
|
+
}
|
|
569
|
+
}
|
|
300
570
|
async restoreLocalSession(scope, generation) {
|
|
301
571
|
if (!this.localBinding || !scope.startsWith("identity\u0000"))
|
|
302
572
|
return;
|
|
@@ -438,7 +708,7 @@ export class GonvexClient {
|
|
|
438
708
|
await this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
|
|
439
709
|
const scope = this.outboxScope;
|
|
440
710
|
await this.replica.synchronizeStorage();
|
|
441
|
-
const entries = await this.reducerOutbox.list(scope);
|
|
711
|
+
const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
|
|
442
712
|
if (this.manuallyClosed || scope !== this.outboxScope)
|
|
443
713
|
return;
|
|
444
714
|
this.replaceLocalPredictions(entries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
|
|
@@ -451,6 +721,7 @@ export class GonvexClient {
|
|
|
451
721
|
}
|
|
452
722
|
}));
|
|
453
723
|
} while (this.peerRefreshDirty && !this.manuallyClosed);
|
|
724
|
+
this.refreshIntents();
|
|
454
725
|
void this.drainOutbox();
|
|
455
726
|
})().catch(() => undefined).finally(() => { this.peerRefreshScheduled = false; });
|
|
456
727
|
}
|
|
@@ -471,8 +742,12 @@ export class GonvexClient {
|
|
|
471
742
|
}
|
|
472
743
|
reportLocalRejection(reducerId, path, error) {
|
|
473
744
|
const message = reducerErrorMessage(error);
|
|
474
|
-
for (const listener of this.reducerRejectionHandlers)
|
|
475
|
-
|
|
745
|
+
for (const listener of this.reducerRejectionHandlers) {
|
|
746
|
+
try {
|
|
747
|
+
listener({ reducerId, path, error: message, errorClass: "rejected" });
|
|
748
|
+
}
|
|
749
|
+
catch { /* A listener must not break delivery. */ }
|
|
750
|
+
}
|
|
476
751
|
}
|
|
477
752
|
rebaseLocalEntries(afterRejection = false) {
|
|
478
753
|
return this.coordinateOutbox("intent", () => this.rebaseLocalEntriesLocked(afterRejection));
|
|
@@ -488,7 +763,9 @@ export class GonvexClient {
|
|
|
488
763
|
await this.replica.synchronizeStorage();
|
|
489
764
|
const scope = this.outboxScope;
|
|
490
765
|
const replicaScope = this.replicaScope;
|
|
491
|
-
|
|
766
|
+
// Rejected records keep their payload for the UI but are never predicted.
|
|
767
|
+
// Failed (parked) intents stay predicted until retried or discarded.
|
|
768
|
+
const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
|
|
492
769
|
if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
|
|
493
770
|
return;
|
|
494
771
|
if (entries.length === 0) {
|
|
@@ -619,7 +896,7 @@ export class GonvexClient {
|
|
|
619
896
|
const scope = this.outboxScope;
|
|
620
897
|
if (!execution || execution.scope !== this.replicaScope)
|
|
621
898
|
throw new GonvexClientError("Session changed before local reducer execution", { code: 'superseded' });
|
|
622
|
-
const sharedEntries = await this.reducerOutbox.list(scope);
|
|
899
|
+
const sharedEntries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
|
|
623
900
|
const sharedPatches = sharedEntries?.filter(entry => entry.state !== "committed").flatMap(entry => (entry.patches ?? []).map(patch => patch.op === "delete"
|
|
624
901
|
? { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: "delete" }
|
|
625
902
|
: { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: patch.op === "upsert" ? "insert" : patch.op, fields: patch.fields }));
|
|
@@ -767,7 +1044,7 @@ export class GonvexClient {
|
|
|
767
1044
|
},
|
|
768
1045
|
});
|
|
769
1046
|
}
|
|
770
|
-
/** Number of reducers
|
|
1047
|
+
/** Number of reducers still queued for delivery (excludes failed and rejected intents). */
|
|
771
1048
|
async outboxCount() {
|
|
772
1049
|
await this.outboxReady;
|
|
773
1050
|
return this.reducerOutbox.count(this.outboxScope);
|
|
@@ -2363,6 +2640,9 @@ export class GonvexClient {
|
|
|
2363
2640
|
? this.restoreOutbox(scope, generation)
|
|
2364
2641
|
: this.restoreLocalSession(scope, generation);
|
|
2365
2642
|
this.outboxReady = ready;
|
|
2643
|
+
// Never show the previous identity's intents under the new one.
|
|
2644
|
+
this.publishIntents([]);
|
|
2645
|
+
this.refreshIntents();
|
|
2366
2646
|
return ready;
|
|
2367
2647
|
}
|
|
2368
2648
|
async activateReplicaDirective(directive) {
|
|
@@ -2441,6 +2721,8 @@ export class GonvexClient {
|
|
|
2441
2721
|
for (const entry of entries) {
|
|
2442
2722
|
if (entry.localExecution && this.localExecutor)
|
|
2443
2723
|
continue;
|
|
2724
|
+
if (!outboxEntryIsLive(entry))
|
|
2725
|
+
continue;
|
|
2444
2726
|
if (entry.state === "committed" && (entry.patches?.length ?? 0) === 0) {
|
|
2445
2727
|
await this.reducerOutbox.ack(entry.id);
|
|
2446
2728
|
continue;
|
|
@@ -2457,6 +2739,7 @@ export class GonvexClient {
|
|
|
2457
2739
|
// If this scope was installed after the socket authenticated, no reconnect
|
|
2458
2740
|
// or new enqueue may occur to wake the queue. The await inside drainOutbox
|
|
2459
2741
|
// yields until this restore promise resolves, then safely resumes it.
|
|
2742
|
+
this.refreshIntents();
|
|
2460
2743
|
void this.drainOutbox();
|
|
2461
2744
|
}
|
|
2462
2745
|
addOptimisticReducer(reducerId, patches, accepted = false) {
|
|
@@ -2485,12 +2768,17 @@ export class GonvexClient {
|
|
|
2485
2768
|
}
|
|
2486
2769
|
async drainOutbox() {
|
|
2487
2770
|
await this.outboxReady;
|
|
2488
|
-
if (this.drainingOutbox
|
|
2489
|
-
|
|
2490
|
-
|
|
2771
|
+
if (this.drainingOutbox) {
|
|
2772
|
+
// The running drain may already be past the entry this wake-up is for
|
|
2773
|
+
// (for example a short backoff timer firing before the drain returned).
|
|
2774
|
+
this.outboxDrainRequested = true;
|
|
2775
|
+
return;
|
|
2776
|
+
}
|
|
2777
|
+
if (this.manuallyClosed || !this.canSendReducerNow())
|
|
2491
2778
|
return;
|
|
2492
2779
|
const drainScope = this.outboxScope;
|
|
2493
2780
|
this.drainingOutbox = true;
|
|
2781
|
+
this.outboxDrainRequested = false;
|
|
2494
2782
|
try {
|
|
2495
2783
|
await this.coordinateOutbox("delivery", async () => {
|
|
2496
2784
|
if (this.manuallyClosed || drainScope !== this.outboxScope || !this.canSendReducerNow())
|
|
@@ -2501,12 +2789,18 @@ export class GonvexClient {
|
|
|
2501
2789
|
const scope = this.outboxScope;
|
|
2502
2790
|
const entry = await this.inLocalLane(async () => {
|
|
2503
2791
|
const next = await this.reducerOutbox.nextReady(scope, Date.now());
|
|
2504
|
-
|
|
2505
|
-
|
|
2792
|
+
// A concurrent discard (this or another tab) fences the entry; a
|
|
2793
|
+
// refused transition means it must not be sent.
|
|
2794
|
+
if (next && (await this.reducerOutbox.markInflight(next.id)) === false)
|
|
2795
|
+
return null;
|
|
2506
2796
|
return next;
|
|
2507
2797
|
});
|
|
2508
|
-
if (
|
|
2798
|
+
if (entry === null)
|
|
2799
|
+
continue;
|
|
2800
|
+
if (!entry) {
|
|
2801
|
+
await this.scheduleNextOutboxAttempt(scope);
|
|
2509
2802
|
return;
|
|
2803
|
+
}
|
|
2510
2804
|
if (scope !== this.outboxScope)
|
|
2511
2805
|
return;
|
|
2512
2806
|
if (this.directOutboxReducerIds.has(entry.idempotencyKey)) {
|
|
@@ -2522,7 +2816,8 @@ export class GonvexClient {
|
|
|
2522
2816
|
await this.reducerOutbox.markPending(entry.id);
|
|
2523
2817
|
return;
|
|
2524
2818
|
}
|
|
2525
|
-
await this.reducerOutbox.markInflight(entry.id)
|
|
2819
|
+
if ((await this.reducerOutbox.markInflight(entry.id)) === false)
|
|
2820
|
+
continue;
|
|
2526
2821
|
if (scope !== this.outboxScope)
|
|
2527
2822
|
return;
|
|
2528
2823
|
if (!this.canSendReducerNow()) {
|
|
@@ -2531,6 +2826,7 @@ export class GonvexClient {
|
|
|
2531
2826
|
}
|
|
2532
2827
|
try {
|
|
2533
2828
|
await this.call("reducer", { kind: "reducer", path: entry.path, receiptPath: entry.receiptPath, ...(entry.localExecution ? { artifactHash: entry.localExecution.artifactHash, intentEntropy: entry.localExecution.intentEntropy } : {}) }, entry.args, this.timeouts.reducerTimeoutMs, entry.idempotencyKey, entry.idempotencyKey);
|
|
2829
|
+
this.unauthenticatedRetries = 0;
|
|
2534
2830
|
await this.reducerOutbox.markCommitted(entry.id);
|
|
2535
2831
|
if (entry.localExecution && this.localExecutor) {
|
|
2536
2832
|
await this.inLocalLane(async () => {
|
|
@@ -2547,27 +2843,34 @@ export class GonvexClient {
|
|
|
2547
2843
|
}
|
|
2548
2844
|
}
|
|
2549
2845
|
catch (error) {
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
}
|
|
2555
|
-
if (error instanceof GonvexClientError && error.code === "server") {
|
|
2846
|
+
const disposition = deliveryErrorClass(error);
|
|
2847
|
+
if (disposition === "rejected") {
|
|
2848
|
+
// Keep today's rollback and rebase, but record the rejection
|
|
2849
|
+
// durably instead of deleting the intent without a trace.
|
|
2556
2850
|
if (entry.localExecution && this.localExecutor) {
|
|
2557
2851
|
await this.inLocalLane(async () => {
|
|
2558
|
-
await this.reducerOutbox.
|
|
2852
|
+
await this.reducerOutbox.reject(entry.id, reducerErrorMessage(error));
|
|
2559
2853
|
if (scope === this.outboxScope) {
|
|
2560
2854
|
await this.rebaseLocalEntries(true);
|
|
2561
2855
|
this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
|
|
2562
2856
|
}
|
|
2563
2857
|
});
|
|
2564
2858
|
}
|
|
2565
|
-
else
|
|
2566
|
-
|
|
2859
|
+
else {
|
|
2860
|
+
this.optimisticReducerIds.delete(entry.idempotencyKey);
|
|
2861
|
+
this.optimisticOutboxEntryIds.delete(entry.idempotencyKey);
|
|
2862
|
+
this.replica.rejectCommand(entry.idempotencyKey);
|
|
2863
|
+
await this.reducerOutbox.reject(entry.id, reducerErrorMessage(error));
|
|
2864
|
+
if (scope === this.outboxScope)
|
|
2865
|
+
this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
|
|
2866
|
+
}
|
|
2567
2867
|
continue;
|
|
2568
2868
|
}
|
|
2569
|
-
await this.
|
|
2570
|
-
|
|
2869
|
+
const outcome = await this.recordDeliveryFailure(entry, error, disposition);
|
|
2870
|
+
// A parked intent keeps its prediction but no longer blocks the
|
|
2871
|
+
// chain; later intents proceed and the server validates them.
|
|
2872
|
+
if (outcome === "parked")
|
|
2873
|
+
continue;
|
|
2571
2874
|
return;
|
|
2572
2875
|
}
|
|
2573
2876
|
}
|
|
@@ -2576,11 +2879,74 @@ export class GonvexClient {
|
|
|
2576
2879
|
finally {
|
|
2577
2880
|
this.drainingOutbox = false;
|
|
2578
2881
|
this.scheduleLocalReplay();
|
|
2579
|
-
|
|
2882
|
+
const requested = this.outboxDrainRequested;
|
|
2883
|
+
this.outboxDrainRequested = false;
|
|
2884
|
+
if (!this.manuallyClosed && (drainScope !== this.outboxScope || requested)) {
|
|
2580
2885
|
void this.drainOutbox();
|
|
2581
2886
|
}
|
|
2582
2887
|
}
|
|
2583
2888
|
}
|
|
2889
|
+
/**
|
|
2890
|
+
* Record a non-rejection delivery failure and arm the next attempt.
|
|
2891
|
+
* - update_required: keep the intent pending and stop for an app update.
|
|
2892
|
+
* - unauthenticated: keep the intent, re-authenticate, retry with backoff
|
|
2893
|
+
* that never spends the retry budget.
|
|
2894
|
+
* - network: connectivity loss; retried on reconnect without spending budget.
|
|
2895
|
+
* - transient (and timeouts): exponential backoff, parked as `failed` once
|
|
2896
|
+
* the retry budget is spent.
|
|
2897
|
+
*/
|
|
2898
|
+
async recordDeliveryFailure(entry, error, disposition) {
|
|
2899
|
+
const message = reducerErrorMessage(error);
|
|
2900
|
+
if (disposition === "update_required") {
|
|
2901
|
+
await this.reducerOutbox.fail(entry.id, message, { errorClass: disposition, countAttempt: false, delayMs: 0 });
|
|
2902
|
+
this.requireClientUpdate(message);
|
|
2903
|
+
return "update_required";
|
|
2904
|
+
}
|
|
2905
|
+
if (disposition === "unauthenticated") {
|
|
2906
|
+
const delay = outboxBackoffMs(this.unauthenticatedRetries++, this.outboxRetry.maxBackoffMs);
|
|
2907
|
+
await this.reducerOutbox.fail(entry.id, message, { errorClass: disposition, countAttempt: false, delayMs: delay });
|
|
2908
|
+
this.requestReauthentication();
|
|
2909
|
+
this.scheduleOutboxDrain(delay);
|
|
2910
|
+
return "retrying";
|
|
2911
|
+
}
|
|
2912
|
+
const counted = disposition === "transient";
|
|
2913
|
+
const updated = await this.reducerOutbox.fail(entry.id, message, {
|
|
2914
|
+
errorClass: disposition,
|
|
2915
|
+
countAttempt: counted,
|
|
2916
|
+
maxAttempts: this.outboxRetry.maxAttempts,
|
|
2917
|
+
maxBackoffMs: this.outboxRetry.maxBackoffMs,
|
|
2918
|
+
...(counted ? {} : { delayMs: 1_000 }),
|
|
2919
|
+
});
|
|
2920
|
+
if (updated && updated.state === "failed")
|
|
2921
|
+
return "parked";
|
|
2922
|
+
const delay = updated ? Math.max(0, updated.nextAttemptAt - Date.now()) : DEFAULT_OUTBOX_MAX_BACKOFF_MS;
|
|
2923
|
+
this.scheduleOutboxDrain(delay);
|
|
2924
|
+
return "retrying";
|
|
2925
|
+
}
|
|
2926
|
+
/**
|
|
2927
|
+
* Arm a timer for the earliest backed-off entry. Timer clocks and
|
|
2928
|
+
* `Date.now()` can disagree by a millisecond, so a wake-up may find its
|
|
2929
|
+
* entry not quite due; without this the queue would wait for an unrelated
|
|
2930
|
+
* event to resume.
|
|
2931
|
+
*/
|
|
2932
|
+
async scheduleNextOutboxAttempt(scope) {
|
|
2933
|
+
const now = Date.now();
|
|
2934
|
+
let next = Infinity;
|
|
2935
|
+
for (const entry of await this.reducerOutbox.list(scope)) {
|
|
2936
|
+
if (entry.state === "pending" && entry.nextAttemptAt > now)
|
|
2937
|
+
next = Math.min(next, entry.nextAttemptAt);
|
|
2938
|
+
}
|
|
2939
|
+
if (Number.isFinite(next) && scope === this.outboxScope)
|
|
2940
|
+
this.scheduleOutboxDrain(next - now + 1);
|
|
2941
|
+
}
|
|
2942
|
+
/** Re-send auth on the open socket so a lost tenant session is restored. */
|
|
2943
|
+
requestReauthentication() {
|
|
2944
|
+
if (this.manuallyClosed || this.authInFlight || this.socket?.readyState !== WebSocket.OPEN)
|
|
2945
|
+
return;
|
|
2946
|
+
if (!this.auth.project && !this.auth.tenant && !this.auth.token && !this.auth.fetchToken)
|
|
2947
|
+
return;
|
|
2948
|
+
this.sendAuth(false);
|
|
2949
|
+
}
|
|
2584
2950
|
scheduleOutboxDrain(delay) {
|
|
2585
2951
|
if (this.manuallyClosed)
|
|
2586
2952
|
return;
|
|
@@ -2620,7 +2986,6 @@ export class GonvexClient {
|
|
|
2620
2986
|
}
|
|
2621
2987
|
this.directOutboxReducerIds.add(reducerId);
|
|
2622
2988
|
let entryId;
|
|
2623
|
-
let entryAttempts = 0;
|
|
2624
2989
|
try {
|
|
2625
2990
|
const scope = this.outboxScope;
|
|
2626
2991
|
const entry = await this.reducerOutbox.enqueue({
|
|
@@ -2633,7 +2998,6 @@ export class GonvexClient {
|
|
|
2633
2998
|
state: "inflight",
|
|
2634
2999
|
});
|
|
2635
3000
|
entryId = entry.id;
|
|
2636
|
-
entryAttempts = entry.attempts;
|
|
2637
3001
|
if (this.manuallyClosed) {
|
|
2638
3002
|
await this.reducerOutbox.ack(entry.id);
|
|
2639
3003
|
throw new GonvexClientError(`Gonvex client was closed before reducer ${ref.path} could be sent.`, { code: "closed", path: ref.path, operation: "reducer" });
|
|
@@ -2661,14 +3025,13 @@ export class GonvexClient {
|
|
|
2661
3025
|
return result;
|
|
2662
3026
|
}
|
|
2663
3027
|
catch (error) {
|
|
2664
|
-
|
|
3028
|
+
const disposition = deliveryErrorClass(error);
|
|
3029
|
+
if (disposition !== "rejected" && options.offline === "queue") {
|
|
2665
3030
|
const queuedEntryId = this.optimisticOutboxEntryIds.get(reducerId) ?? entryId;
|
|
2666
3031
|
if (queuedEntryId !== undefined) {
|
|
2667
|
-
|
|
2668
|
-
//
|
|
2669
|
-
|
|
2670
|
-
// next deterministic drain just like the background drain path.
|
|
2671
|
-
this.scheduleOutboxDrain(Math.min(30_000, 1_000 * (2 ** (entryAttempts + 1))));
|
|
3032
|
+
// Records backoff (or parks the entry) and arms the client's timer,
|
|
3033
|
+
// exactly like the background drain path.
|
|
3034
|
+
await this.recordDeliveryFailure({ id: queuedEntryId }, error, disposition);
|
|
2672
3035
|
}
|
|
2673
3036
|
return { status: "queued", reducerId };
|
|
2674
3037
|
}
|
|
@@ -2917,7 +3280,11 @@ export class GonvexClient {
|
|
|
2917
3280
|
if (kind === "reducer" && message.type === "reducer.error") {
|
|
2918
3281
|
settle();
|
|
2919
3282
|
this.emitTelemetryFromCall(kind, id, ref.path, "error", clientSentAtMs, message.trace, message.error);
|
|
2920
|
-
|
|
3283
|
+
const errorClass = reducerErrorClassFromMessage(message);
|
|
3284
|
+
reject(new GonvexClientError(message.error, {
|
|
3285
|
+
code: "server", path: ref.path, operation: kind,
|
|
3286
|
+
...(errorClass ? { errorClass, retryable: message.retryable ?? (errorClass === "transient" || errorClass === "unauthenticated") } : {}),
|
|
3287
|
+
}));
|
|
2921
3288
|
}
|
|
2922
3289
|
if (kind === "action" && message.type === "action.result") {
|
|
2923
3290
|
const complete = () => {
|
|
@@ -3574,9 +3941,96 @@ function countPendingCalls(calls, kind) {
|
|
|
3574
3941
|
}
|
|
3575
3942
|
return count;
|
|
3576
3943
|
}
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3944
|
+
/**
|
|
3945
|
+
* How the outbox treats one failed delivery. Runtimes before
|
|
3946
|
+
* 0.5.2-staging.15 send no class: their server errors stay rejections, as
|
|
3947
|
+
* before, except the two cases the legacy runtime could only express in prose.
|
|
3948
|
+
*/
|
|
3949
|
+
function deliveryErrorClass(error) {
|
|
3950
|
+
if (!(error instanceof GonvexClientError))
|
|
3951
|
+
return "transient";
|
|
3952
|
+
switch (error.code) {
|
|
3953
|
+
case "server": return error.errorClass ?? "rejected";
|
|
3954
|
+
case "timeout": return "transient";
|
|
3955
|
+
default: return "network";
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3958
|
+
const legacyUnauthenticatedReducerError = "authenticate with an active tenant before calling a Reducer";
|
|
3959
|
+
function reducerErrorClassFromMessage(message) {
|
|
3960
|
+
if (message.class === "rejected" || message.class === "transient" || message.class === "update_required" || message.class === "unauthenticated") {
|
|
3961
|
+
return message.class;
|
|
3962
|
+
}
|
|
3963
|
+
// Legacy runtimes: the only structured signals were these literal messages.
|
|
3964
|
+
if (/STALE_REDUCER_ARTIFACT|CLIENT_UPDATE_REQUIRED/.test(message.error))
|
|
3965
|
+
return "update_required";
|
|
3966
|
+
if (message.error === legacyUnauthenticatedReducerError)
|
|
3967
|
+
return "unauthenticated";
|
|
3968
|
+
return undefined;
|
|
3969
|
+
}
|
|
3970
|
+
function positiveOr(value, fallback) {
|
|
3971
|
+
return typeof value === "number" && value > 0 ? value : fallback;
|
|
3972
|
+
}
|
|
3973
|
+
function summarizeArgs(args) {
|
|
3974
|
+
let text;
|
|
3975
|
+
try {
|
|
3976
|
+
text = JSON.stringify(args) ?? "";
|
|
3977
|
+
}
|
|
3978
|
+
catch {
|
|
3979
|
+
text = String(args);
|
|
3980
|
+
}
|
|
3981
|
+
return text.length > 160 ? `${text.slice(0, 157)}...` : text;
|
|
3982
|
+
}
|
|
3983
|
+
function outboxIntentFromEntry(entry) {
|
|
3984
|
+
const entities = new Map();
|
|
3985
|
+
for (const patch of entry.patches ?? []) {
|
|
3986
|
+
const entity = patch.entity ?? patch.collection;
|
|
3987
|
+
if (!entity)
|
|
3988
|
+
continue;
|
|
3989
|
+
entities.set(`${entity}\u0000${patch.rowId}`, { entity, id: patch.rowId });
|
|
3990
|
+
}
|
|
3991
|
+
return {
|
|
3992
|
+
id: entry.idempotencyKey,
|
|
3993
|
+
entryId: entry.id,
|
|
3994
|
+
reducer: entry.path,
|
|
3995
|
+
state: entry.state,
|
|
3996
|
+
attempts: entry.attempts,
|
|
3997
|
+
...(entry.lastError !== undefined ? { lastError: entry.lastError } : {}),
|
|
3998
|
+
...(entry.lastErrorClass ? { errorClass: entry.lastErrorClass } : {}),
|
|
3999
|
+
createdAt: entry.createdAt,
|
|
4000
|
+
nextAttemptAt: entry.nextAttemptAt,
|
|
4001
|
+
...(entry.settledAt !== undefined ? { settledAt: entry.settledAt } : {}),
|
|
4002
|
+
args: entry.args,
|
|
4003
|
+
argsSummary: summarizeArgs(entry.args),
|
|
4004
|
+
entities: [...entities.values()],
|
|
4005
|
+
};
|
|
4006
|
+
}
|
|
4007
|
+
function sameIntents(left, right) {
|
|
4008
|
+
if (left.length !== right.length)
|
|
4009
|
+
return false;
|
|
4010
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
4011
|
+
const a = left[index], b = right[index];
|
|
4012
|
+
if (a.id !== b.id || a.entryId !== b.entryId || a.state !== b.state || a.attempts !== b.attempts
|
|
4013
|
+
|| a.lastError !== b.lastError || a.errorClass !== b.errorClass || a.nextAttemptAt !== b.nextAttemptAt
|
|
4014
|
+
|| a.settledAt !== b.settledAt || a.entities.length !== b.entities.length
|
|
4015
|
+
|| a.entities.some((entity, position) => entity.entity !== b.entities[position].entity || entity.id !== b.entities[position].id))
|
|
4016
|
+
return false;
|
|
4017
|
+
}
|
|
4018
|
+
return true;
|
|
4019
|
+
}
|
|
4020
|
+
/** Row status from a list of intents: failed > rejected > syncing. */
|
|
4021
|
+
export function entityStatusFromIntents(intents, entity, id) {
|
|
4022
|
+
let status;
|
|
4023
|
+
for (const intent of intents) {
|
|
4024
|
+
if (!intent.entities.some((candidate) => candidate.entity === entity && candidate.id === id))
|
|
4025
|
+
continue;
|
|
4026
|
+
if (intent.state === "failed")
|
|
4027
|
+
return "failed";
|
|
4028
|
+
if (intent.state === "rejected")
|
|
4029
|
+
status = "rejected";
|
|
4030
|
+
else if (intent.state !== "committed" && status === undefined)
|
|
4031
|
+
status = "syncing";
|
|
4032
|
+
}
|
|
4033
|
+
return status;
|
|
3580
4034
|
}
|
|
3581
4035
|
function reducerErrorMessage(error) {
|
|
3582
4036
|
return error instanceof Error ? error.message : String(error);
|