@gonvex/client 0.5.2-staging.14 → 0.5.2-staging.16
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 +170 -9
- package/dist/index.js +504 -42
- 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") {
|
|
@@ -274,7 +301,7 @@ export class GonvexClient {
|
|
|
274
301
|
this.outboxScope = initialScope;
|
|
275
302
|
this.replicaScope = ["awaiting-server-scope", this.url, this.outboxEphemeralScope].join("\u0000");
|
|
276
303
|
this.outboxScopeGeneration += 1;
|
|
277
|
-
this.
|
|
304
|
+
this.setReplicaReady(this.replica.activateScope(this.replicaScope, true));
|
|
278
305
|
this.outboxReady = Promise.resolve();
|
|
279
306
|
this.timeouts = {
|
|
280
307
|
queryTimeoutMs: options.timeouts?.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
|
|
@@ -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) {
|
|
@@ -2385,7 +2665,7 @@ export class GonvexClient {
|
|
|
2385
2665
|
this.resetReplicaScopeState();
|
|
2386
2666
|
this.replicaScope = scope;
|
|
2387
2667
|
this.hasAuthoritativeReplicaScope = true;
|
|
2388
|
-
this.
|
|
2668
|
+
this.setReplicaReady(this.replica.activateScope(scope));
|
|
2389
2669
|
this.rotateSubscriptionScopes();
|
|
2390
2670
|
const generation = this.outboxScopeGeneration;
|
|
2391
2671
|
// Publish the recovery barrier before yielding to Replica storage. A
|
|
@@ -2397,6 +2677,14 @@ export class GonvexClient {
|
|
|
2397
2677
|
this.outboxReady = this.restoreOutbox(this.outboxScope, generation);
|
|
2398
2678
|
await this.outboxReady;
|
|
2399
2679
|
}
|
|
2680
|
+
// Scope activation rejects once the replica is disposed (for example when
|
|
2681
|
+
// an auth error quarantines a client that was already closed). Callers that
|
|
2682
|
+
// await replicaReady still see the rejection; the stored promise itself must
|
|
2683
|
+
// not surface as an unhandled rejection that kills Node processes.
|
|
2684
|
+
setReplicaReady(ready) {
|
|
2685
|
+
this.replicaReady = ready;
|
|
2686
|
+
ready.catch(() => undefined);
|
|
2687
|
+
}
|
|
2400
2688
|
quarantineReplicaScope() {
|
|
2401
2689
|
this.localIdentity = undefined;
|
|
2402
2690
|
// Keep the durable prior identity scope intact for an authorized future
|
|
@@ -2410,7 +2698,7 @@ export class GonvexClient {
|
|
|
2410
2698
|
this.resetReplicaScopeState();
|
|
2411
2699
|
this.replicaScope = scope;
|
|
2412
2700
|
this.hasAuthoritativeReplicaScope = false;
|
|
2413
|
-
this.
|
|
2701
|
+
this.setReplicaReady(this.replica.activateScope(scope, true));
|
|
2414
2702
|
this.rotateSubscriptionScopes();
|
|
2415
2703
|
}
|
|
2416
2704
|
rejectMissingReplicaDirective() {
|
|
@@ -2441,6 +2729,8 @@ export class GonvexClient {
|
|
|
2441
2729
|
for (const entry of entries) {
|
|
2442
2730
|
if (entry.localExecution && this.localExecutor)
|
|
2443
2731
|
continue;
|
|
2732
|
+
if (!outboxEntryIsLive(entry))
|
|
2733
|
+
continue;
|
|
2444
2734
|
if (entry.state === "committed" && (entry.patches?.length ?? 0) === 0) {
|
|
2445
2735
|
await this.reducerOutbox.ack(entry.id);
|
|
2446
2736
|
continue;
|
|
@@ -2457,6 +2747,7 @@ export class GonvexClient {
|
|
|
2457
2747
|
// If this scope was installed after the socket authenticated, no reconnect
|
|
2458
2748
|
// or new enqueue may occur to wake the queue. The await inside drainOutbox
|
|
2459
2749
|
// yields until this restore promise resolves, then safely resumes it.
|
|
2750
|
+
this.refreshIntents();
|
|
2460
2751
|
void this.drainOutbox();
|
|
2461
2752
|
}
|
|
2462
2753
|
addOptimisticReducer(reducerId, patches, accepted = false) {
|
|
@@ -2485,12 +2776,17 @@ export class GonvexClient {
|
|
|
2485
2776
|
}
|
|
2486
2777
|
async drainOutbox() {
|
|
2487
2778
|
await this.outboxReady;
|
|
2488
|
-
if (this.drainingOutbox
|
|
2489
|
-
|
|
2490
|
-
|
|
2779
|
+
if (this.drainingOutbox) {
|
|
2780
|
+
// The running drain may already be past the entry this wake-up is for
|
|
2781
|
+
// (for example a short backoff timer firing before the drain returned).
|
|
2782
|
+
this.outboxDrainRequested = true;
|
|
2783
|
+
return;
|
|
2784
|
+
}
|
|
2785
|
+
if (this.manuallyClosed || !this.canSendReducerNow())
|
|
2491
2786
|
return;
|
|
2492
2787
|
const drainScope = this.outboxScope;
|
|
2493
2788
|
this.drainingOutbox = true;
|
|
2789
|
+
this.outboxDrainRequested = false;
|
|
2494
2790
|
try {
|
|
2495
2791
|
await this.coordinateOutbox("delivery", async () => {
|
|
2496
2792
|
if (this.manuallyClosed || drainScope !== this.outboxScope || !this.canSendReducerNow())
|
|
@@ -2501,12 +2797,18 @@ export class GonvexClient {
|
|
|
2501
2797
|
const scope = this.outboxScope;
|
|
2502
2798
|
const entry = await this.inLocalLane(async () => {
|
|
2503
2799
|
const next = await this.reducerOutbox.nextReady(scope, Date.now());
|
|
2504
|
-
|
|
2505
|
-
|
|
2800
|
+
// A concurrent discard (this or another tab) fences the entry; a
|
|
2801
|
+
// refused transition means it must not be sent.
|
|
2802
|
+
if (next && (await this.reducerOutbox.markInflight(next.id)) === false)
|
|
2803
|
+
return null;
|
|
2506
2804
|
return next;
|
|
2507
2805
|
});
|
|
2508
|
-
if (
|
|
2806
|
+
if (entry === null)
|
|
2807
|
+
continue;
|
|
2808
|
+
if (!entry) {
|
|
2809
|
+
await this.scheduleNextOutboxAttempt(scope);
|
|
2509
2810
|
return;
|
|
2811
|
+
}
|
|
2510
2812
|
if (scope !== this.outboxScope)
|
|
2511
2813
|
return;
|
|
2512
2814
|
if (this.directOutboxReducerIds.has(entry.idempotencyKey)) {
|
|
@@ -2522,7 +2824,8 @@ export class GonvexClient {
|
|
|
2522
2824
|
await this.reducerOutbox.markPending(entry.id);
|
|
2523
2825
|
return;
|
|
2524
2826
|
}
|
|
2525
|
-
await this.reducerOutbox.markInflight(entry.id)
|
|
2827
|
+
if ((await this.reducerOutbox.markInflight(entry.id)) === false)
|
|
2828
|
+
continue;
|
|
2526
2829
|
if (scope !== this.outboxScope)
|
|
2527
2830
|
return;
|
|
2528
2831
|
if (!this.canSendReducerNow()) {
|
|
@@ -2531,6 +2834,7 @@ export class GonvexClient {
|
|
|
2531
2834
|
}
|
|
2532
2835
|
try {
|
|
2533
2836
|
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);
|
|
2837
|
+
this.unauthenticatedRetries = 0;
|
|
2534
2838
|
await this.reducerOutbox.markCommitted(entry.id);
|
|
2535
2839
|
if (entry.localExecution && this.localExecutor) {
|
|
2536
2840
|
await this.inLocalLane(async () => {
|
|
@@ -2547,27 +2851,34 @@ export class GonvexClient {
|
|
|
2547
2851
|
}
|
|
2548
2852
|
}
|
|
2549
2853
|
catch (error) {
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
}
|
|
2555
|
-
if (error instanceof GonvexClientError && error.code === "server") {
|
|
2854
|
+
const disposition = deliveryErrorClass(error);
|
|
2855
|
+
if (disposition === "rejected") {
|
|
2856
|
+
// Keep today's rollback and rebase, but record the rejection
|
|
2857
|
+
// durably instead of deleting the intent without a trace.
|
|
2556
2858
|
if (entry.localExecution && this.localExecutor) {
|
|
2557
2859
|
await this.inLocalLane(async () => {
|
|
2558
|
-
await this.reducerOutbox.
|
|
2860
|
+
await this.reducerOutbox.reject(entry.id, reducerErrorMessage(error));
|
|
2559
2861
|
if (scope === this.outboxScope) {
|
|
2560
2862
|
await this.rebaseLocalEntries(true);
|
|
2561
2863
|
this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
|
|
2562
2864
|
}
|
|
2563
2865
|
});
|
|
2564
2866
|
}
|
|
2565
|
-
else
|
|
2566
|
-
|
|
2867
|
+
else {
|
|
2868
|
+
this.optimisticReducerIds.delete(entry.idempotencyKey);
|
|
2869
|
+
this.optimisticOutboxEntryIds.delete(entry.idempotencyKey);
|
|
2870
|
+
this.replica.rejectCommand(entry.idempotencyKey);
|
|
2871
|
+
await this.reducerOutbox.reject(entry.id, reducerErrorMessage(error));
|
|
2872
|
+
if (scope === this.outboxScope)
|
|
2873
|
+
this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
|
|
2874
|
+
}
|
|
2567
2875
|
continue;
|
|
2568
2876
|
}
|
|
2569
|
-
await this.
|
|
2570
|
-
|
|
2877
|
+
const outcome = await this.recordDeliveryFailure(entry, error, disposition);
|
|
2878
|
+
// A parked intent keeps its prediction but no longer blocks the
|
|
2879
|
+
// chain; later intents proceed and the server validates them.
|
|
2880
|
+
if (outcome === "parked")
|
|
2881
|
+
continue;
|
|
2571
2882
|
return;
|
|
2572
2883
|
}
|
|
2573
2884
|
}
|
|
@@ -2576,11 +2887,74 @@ export class GonvexClient {
|
|
|
2576
2887
|
finally {
|
|
2577
2888
|
this.drainingOutbox = false;
|
|
2578
2889
|
this.scheduleLocalReplay();
|
|
2579
|
-
|
|
2890
|
+
const requested = this.outboxDrainRequested;
|
|
2891
|
+
this.outboxDrainRequested = false;
|
|
2892
|
+
if (!this.manuallyClosed && (drainScope !== this.outboxScope || requested)) {
|
|
2580
2893
|
void this.drainOutbox();
|
|
2581
2894
|
}
|
|
2582
2895
|
}
|
|
2583
2896
|
}
|
|
2897
|
+
/**
|
|
2898
|
+
* Record a non-rejection delivery failure and arm the next attempt.
|
|
2899
|
+
* - update_required: keep the intent pending and stop for an app update.
|
|
2900
|
+
* - unauthenticated: keep the intent, re-authenticate, retry with backoff
|
|
2901
|
+
* that never spends the retry budget.
|
|
2902
|
+
* - network: connectivity loss; retried on reconnect without spending budget.
|
|
2903
|
+
* - transient (and timeouts): exponential backoff, parked as `failed` once
|
|
2904
|
+
* the retry budget is spent.
|
|
2905
|
+
*/
|
|
2906
|
+
async recordDeliveryFailure(entry, error, disposition) {
|
|
2907
|
+
const message = reducerErrorMessage(error);
|
|
2908
|
+
if (disposition === "update_required") {
|
|
2909
|
+
await this.reducerOutbox.fail(entry.id, message, { errorClass: disposition, countAttempt: false, delayMs: 0 });
|
|
2910
|
+
this.requireClientUpdate(message);
|
|
2911
|
+
return "update_required";
|
|
2912
|
+
}
|
|
2913
|
+
if (disposition === "unauthenticated") {
|
|
2914
|
+
const delay = outboxBackoffMs(this.unauthenticatedRetries++, this.outboxRetry.maxBackoffMs);
|
|
2915
|
+
await this.reducerOutbox.fail(entry.id, message, { errorClass: disposition, countAttempt: false, delayMs: delay });
|
|
2916
|
+
this.requestReauthentication();
|
|
2917
|
+
this.scheduleOutboxDrain(delay);
|
|
2918
|
+
return "retrying";
|
|
2919
|
+
}
|
|
2920
|
+
const counted = disposition === "transient";
|
|
2921
|
+
const updated = await this.reducerOutbox.fail(entry.id, message, {
|
|
2922
|
+
errorClass: disposition,
|
|
2923
|
+
countAttempt: counted,
|
|
2924
|
+
maxAttempts: this.outboxRetry.maxAttempts,
|
|
2925
|
+
maxBackoffMs: this.outboxRetry.maxBackoffMs,
|
|
2926
|
+
...(counted ? {} : { delayMs: 1_000 }),
|
|
2927
|
+
});
|
|
2928
|
+
if (updated && updated.state === "failed")
|
|
2929
|
+
return "parked";
|
|
2930
|
+
const delay = updated ? Math.max(0, updated.nextAttemptAt - Date.now()) : DEFAULT_OUTBOX_MAX_BACKOFF_MS;
|
|
2931
|
+
this.scheduleOutboxDrain(delay);
|
|
2932
|
+
return "retrying";
|
|
2933
|
+
}
|
|
2934
|
+
/**
|
|
2935
|
+
* Arm a timer for the earliest backed-off entry. Timer clocks and
|
|
2936
|
+
* `Date.now()` can disagree by a millisecond, so a wake-up may find its
|
|
2937
|
+
* entry not quite due; without this the queue would wait for an unrelated
|
|
2938
|
+
* event to resume.
|
|
2939
|
+
*/
|
|
2940
|
+
async scheduleNextOutboxAttempt(scope) {
|
|
2941
|
+
const now = Date.now();
|
|
2942
|
+
let next = Infinity;
|
|
2943
|
+
for (const entry of await this.reducerOutbox.list(scope)) {
|
|
2944
|
+
if (entry.state === "pending" && entry.nextAttemptAt > now)
|
|
2945
|
+
next = Math.min(next, entry.nextAttemptAt);
|
|
2946
|
+
}
|
|
2947
|
+
if (Number.isFinite(next) && scope === this.outboxScope)
|
|
2948
|
+
this.scheduleOutboxDrain(next - now + 1);
|
|
2949
|
+
}
|
|
2950
|
+
/** Re-send auth on the open socket so a lost tenant session is restored. */
|
|
2951
|
+
requestReauthentication() {
|
|
2952
|
+
if (this.manuallyClosed || this.authInFlight || this.socket?.readyState !== WebSocket.OPEN)
|
|
2953
|
+
return;
|
|
2954
|
+
if (!this.auth.project && !this.auth.tenant && !this.auth.token && !this.auth.fetchToken)
|
|
2955
|
+
return;
|
|
2956
|
+
this.sendAuth(false);
|
|
2957
|
+
}
|
|
2584
2958
|
scheduleOutboxDrain(delay) {
|
|
2585
2959
|
if (this.manuallyClosed)
|
|
2586
2960
|
return;
|
|
@@ -2620,7 +2994,6 @@ export class GonvexClient {
|
|
|
2620
2994
|
}
|
|
2621
2995
|
this.directOutboxReducerIds.add(reducerId);
|
|
2622
2996
|
let entryId;
|
|
2623
|
-
let entryAttempts = 0;
|
|
2624
2997
|
try {
|
|
2625
2998
|
const scope = this.outboxScope;
|
|
2626
2999
|
const entry = await this.reducerOutbox.enqueue({
|
|
@@ -2633,7 +3006,6 @@ export class GonvexClient {
|
|
|
2633
3006
|
state: "inflight",
|
|
2634
3007
|
});
|
|
2635
3008
|
entryId = entry.id;
|
|
2636
|
-
entryAttempts = entry.attempts;
|
|
2637
3009
|
if (this.manuallyClosed) {
|
|
2638
3010
|
await this.reducerOutbox.ack(entry.id);
|
|
2639
3011
|
throw new GonvexClientError(`Gonvex client was closed before reducer ${ref.path} could be sent.`, { code: "closed", path: ref.path, operation: "reducer" });
|
|
@@ -2661,14 +3033,13 @@ export class GonvexClient {
|
|
|
2661
3033
|
return result;
|
|
2662
3034
|
}
|
|
2663
3035
|
catch (error) {
|
|
2664
|
-
|
|
3036
|
+
const disposition = deliveryErrorClass(error);
|
|
3037
|
+
if (disposition !== "rejected" && options.offline === "queue") {
|
|
2665
3038
|
const queuedEntryId = this.optimisticOutboxEntryIds.get(reducerId) ?? entryId;
|
|
2666
3039
|
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))));
|
|
3040
|
+
// Records backoff (or parks the entry) and arms the client's timer,
|
|
3041
|
+
// exactly like the background drain path.
|
|
3042
|
+
await this.recordDeliveryFailure({ id: queuedEntryId }, error, disposition);
|
|
2672
3043
|
}
|
|
2673
3044
|
return { status: "queued", reducerId };
|
|
2674
3045
|
}
|
|
@@ -2917,7 +3288,11 @@ export class GonvexClient {
|
|
|
2917
3288
|
if (kind === "reducer" && message.type === "reducer.error") {
|
|
2918
3289
|
settle();
|
|
2919
3290
|
this.emitTelemetryFromCall(kind, id, ref.path, "error", clientSentAtMs, message.trace, message.error);
|
|
2920
|
-
|
|
3291
|
+
const errorClass = reducerErrorClassFromMessage(message);
|
|
3292
|
+
reject(new GonvexClientError(message.error, {
|
|
3293
|
+
code: "server", path: ref.path, operation: kind,
|
|
3294
|
+
...(errorClass ? { errorClass, retryable: message.retryable ?? (errorClass === "transient" || errorClass === "unauthenticated") } : {}),
|
|
3295
|
+
}));
|
|
2921
3296
|
}
|
|
2922
3297
|
if (kind === "action" && message.type === "action.result") {
|
|
2923
3298
|
const complete = () => {
|
|
@@ -3574,9 +3949,96 @@ function countPendingCalls(calls, kind) {
|
|
|
3574
3949
|
}
|
|
3575
3950
|
return count;
|
|
3576
3951
|
}
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3952
|
+
/**
|
|
3953
|
+
* How the outbox treats one failed delivery. Runtimes before
|
|
3954
|
+
* 0.5.2-staging.15 send no class: their server errors stay rejections, as
|
|
3955
|
+
* before, except the two cases the legacy runtime could only express in prose.
|
|
3956
|
+
*/
|
|
3957
|
+
function deliveryErrorClass(error) {
|
|
3958
|
+
if (!(error instanceof GonvexClientError))
|
|
3959
|
+
return "transient";
|
|
3960
|
+
switch (error.code) {
|
|
3961
|
+
case "server": return error.errorClass ?? "rejected";
|
|
3962
|
+
case "timeout": return "transient";
|
|
3963
|
+
default: return "network";
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
const legacyUnauthenticatedReducerError = "authenticate with an active tenant before calling a Reducer";
|
|
3967
|
+
function reducerErrorClassFromMessage(message) {
|
|
3968
|
+
if (message.class === "rejected" || message.class === "transient" || message.class === "update_required" || message.class === "unauthenticated") {
|
|
3969
|
+
return message.class;
|
|
3970
|
+
}
|
|
3971
|
+
// Legacy runtimes: the only structured signals were these literal messages.
|
|
3972
|
+
if (/STALE_REDUCER_ARTIFACT|CLIENT_UPDATE_REQUIRED/.test(message.error))
|
|
3973
|
+
return "update_required";
|
|
3974
|
+
if (message.error === legacyUnauthenticatedReducerError)
|
|
3975
|
+
return "unauthenticated";
|
|
3976
|
+
return undefined;
|
|
3977
|
+
}
|
|
3978
|
+
function positiveOr(value, fallback) {
|
|
3979
|
+
return typeof value === "number" && value > 0 ? value : fallback;
|
|
3980
|
+
}
|
|
3981
|
+
function summarizeArgs(args) {
|
|
3982
|
+
let text;
|
|
3983
|
+
try {
|
|
3984
|
+
text = JSON.stringify(args) ?? "";
|
|
3985
|
+
}
|
|
3986
|
+
catch {
|
|
3987
|
+
text = String(args);
|
|
3988
|
+
}
|
|
3989
|
+
return text.length > 160 ? `${text.slice(0, 157)}...` : text;
|
|
3990
|
+
}
|
|
3991
|
+
function outboxIntentFromEntry(entry) {
|
|
3992
|
+
const entities = new Map();
|
|
3993
|
+
for (const patch of entry.patches ?? []) {
|
|
3994
|
+
const entity = patch.entity ?? patch.collection;
|
|
3995
|
+
if (!entity)
|
|
3996
|
+
continue;
|
|
3997
|
+
entities.set(`${entity}\u0000${patch.rowId}`, { entity, id: patch.rowId });
|
|
3998
|
+
}
|
|
3999
|
+
return {
|
|
4000
|
+
id: entry.idempotencyKey,
|
|
4001
|
+
entryId: entry.id,
|
|
4002
|
+
reducer: entry.path,
|
|
4003
|
+
state: entry.state,
|
|
4004
|
+
attempts: entry.attempts,
|
|
4005
|
+
...(entry.lastError !== undefined ? { lastError: entry.lastError } : {}),
|
|
4006
|
+
...(entry.lastErrorClass ? { errorClass: entry.lastErrorClass } : {}),
|
|
4007
|
+
createdAt: entry.createdAt,
|
|
4008
|
+
nextAttemptAt: entry.nextAttemptAt,
|
|
4009
|
+
...(entry.settledAt !== undefined ? { settledAt: entry.settledAt } : {}),
|
|
4010
|
+
args: entry.args,
|
|
4011
|
+
argsSummary: summarizeArgs(entry.args),
|
|
4012
|
+
entities: [...entities.values()],
|
|
4013
|
+
};
|
|
4014
|
+
}
|
|
4015
|
+
function sameIntents(left, right) {
|
|
4016
|
+
if (left.length !== right.length)
|
|
4017
|
+
return false;
|
|
4018
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
4019
|
+
const a = left[index], b = right[index];
|
|
4020
|
+
if (a.id !== b.id || a.entryId !== b.entryId || a.state !== b.state || a.attempts !== b.attempts
|
|
4021
|
+
|| a.lastError !== b.lastError || a.errorClass !== b.errorClass || a.nextAttemptAt !== b.nextAttemptAt
|
|
4022
|
+
|| a.settledAt !== b.settledAt || a.entities.length !== b.entities.length
|
|
4023
|
+
|| a.entities.some((entity, position) => entity.entity !== b.entities[position].entity || entity.id !== b.entities[position].id))
|
|
4024
|
+
return false;
|
|
4025
|
+
}
|
|
4026
|
+
return true;
|
|
4027
|
+
}
|
|
4028
|
+
/** Row status from a list of intents: failed > rejected > syncing. */
|
|
4029
|
+
export function entityStatusFromIntents(intents, entity, id) {
|
|
4030
|
+
let status;
|
|
4031
|
+
for (const intent of intents) {
|
|
4032
|
+
if (!intent.entities.some((candidate) => candidate.entity === entity && candidate.id === id))
|
|
4033
|
+
continue;
|
|
4034
|
+
if (intent.state === "failed")
|
|
4035
|
+
return "failed";
|
|
4036
|
+
if (intent.state === "rejected")
|
|
4037
|
+
status = "rejected";
|
|
4038
|
+
else if (intent.state !== "committed" && status === undefined)
|
|
4039
|
+
status = "syncing";
|
|
4040
|
+
}
|
|
4041
|
+
return status;
|
|
3580
4042
|
}
|
|
3581
4043
|
function reducerErrorMessage(error) {
|
|
3582
4044
|
return error instanceof Error ? error.message : String(error);
|