statewire 0.23.0 → 0.24.1
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/client/core.d.ts +4 -3
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +56 -45
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts +56 -12
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +632 -260
- package/dist/client/session.js.map +1 -1
- package/dist/client/sessionStore.d.ts +1 -0
- package/dist/client/sessionStore.d.ts.map +1 -1
- package/dist/client/sessionStore.js +1 -0
- package/dist/client/sessionStore.js.map +1 -1
- package/dist/host/host.d.ts +6 -5
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js.map +1 -1
- package/dist/indexedDb.d.ts +15 -0
- package/dist/indexedDb.d.ts.map +1 -0
- package/dist/indexedDb.js +171 -0
- package/dist/indexedDb.js.map +1 -0
- package/dist/statewire.d.ts +117 -59
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +13 -25
- package/dist/statewire.js.map +1 -1
- package/dist/storage.d.ts +42 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +387 -125
- package/dist/storage.js.map +1 -1
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +7 -9
- package/dist/transports/remote.js.map +1 -1
- package/dist/types.d.ts +6 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -1
- package/src/client/core.ts +81 -64
- package/src/client/session.ts +886 -424
- package/src/client/sessionStore.ts +2 -0
- package/src/host/host.ts +10 -3
- package/src/indexedDb.ts +167 -0
- package/src/statewire.ts +133 -86
- package/src/storage.ts +548 -112
- package/src/transports/remote.ts +13 -14
- package/src/types.ts +6 -7
package/src/client/session.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { encodeContext } from "../wire/context";
|
|
|
2
2
|
import { wrapStorageError } from "./storageError";
|
|
3
3
|
import {
|
|
4
4
|
DEFAULT_PROTOCOL,
|
|
5
|
+
GAP_FILL_VERSION,
|
|
5
6
|
WIRE_VERSION,
|
|
6
7
|
protocolOffers,
|
|
7
8
|
inRange,
|
|
@@ -52,13 +53,11 @@ type Invocation = {
|
|
|
52
53
|
-readonly [K in keyof CommandsInvocation]: CommandsInvocation[K];
|
|
53
54
|
};
|
|
54
55
|
|
|
55
|
-
type Hold = StatewireClient.OutboxCommand["hold"];
|
|
56
|
-
|
|
57
56
|
type Pending = {
|
|
58
57
|
readonly id: string;
|
|
59
58
|
/** Unset while in the outbox, or while another sharer owns its stamp. */
|
|
60
59
|
seq: number | undefined;
|
|
61
|
-
|
|
60
|
+
command: WireCommand;
|
|
62
61
|
readonly inv: Invocation;
|
|
63
62
|
/** Enqueued by another sharer: this client holds no promise for it. */
|
|
64
63
|
readonly adopted: boolean;
|
|
@@ -96,6 +95,8 @@ export type StatewireSessionOptions = StatewireProtocol.Options & {
|
|
|
96
95
|
context?: Readonly<Record<string, Statewire.Context>>;
|
|
97
96
|
sessionStore?: StatewireClient.SessionStorage | undefined;
|
|
98
97
|
onSessionLost?: StatewireClient.OnSessionLost | undefined;
|
|
98
|
+
onDeprecatedPath?: StatewireClient.OnDeprecatedPath | undefined;
|
|
99
|
+
onSettled?: StatewireClient.OnSettled | undefined;
|
|
99
100
|
onFlush?: StatewireClient.OnFlush | undefined;
|
|
100
101
|
canOvertake?:
|
|
101
102
|
| Readonly<Record<string, StatewireClient.CanOvertake>>
|
|
@@ -107,18 +108,35 @@ type Shared = {
|
|
|
107
108
|
txq: Promise<void>;
|
|
108
109
|
syncQueued: boolean;
|
|
109
110
|
parked: { protocol: string; verdict: StatewireClient.SessionVerdict }[];
|
|
111
|
+
/** Own commands settled since the last backlog sync: verdicts parked for them are moot. */
|
|
112
|
+
settled: Set<string>;
|
|
113
|
+
/** Settled updates waiting for the next backlog sync. */
|
|
114
|
+
notices: Notice[];
|
|
115
|
+
/** A locked transaction is running its mutation. */
|
|
116
|
+
inTx: boolean;
|
|
110
117
|
unsubscribe: (() => void) | null;
|
|
111
118
|
/** Owners whose departure this sharer awaits. */
|
|
112
119
|
watched: Set<string>;
|
|
113
120
|
abort: AbortController | null;
|
|
114
121
|
};
|
|
115
122
|
|
|
123
|
+
type Patch = {
|
|
124
|
+
held?: boolean;
|
|
125
|
+
localMeta?: { value: unknown };
|
|
126
|
+
params?: readonly unknown[];
|
|
127
|
+
};
|
|
128
|
+
|
|
116
129
|
type Flushing = {
|
|
117
130
|
ids: ReadonlySet<string>;
|
|
118
|
-
|
|
131
|
+
patches: Map<string, Patch>;
|
|
119
132
|
removed: Set<string>;
|
|
120
133
|
};
|
|
121
134
|
|
|
135
|
+
type Waiter = {
|
|
136
|
+
resolve: (payload: unknown) => void;
|
|
137
|
+
reject: (error: unknown) => void;
|
|
138
|
+
};
|
|
139
|
+
|
|
122
140
|
type Session = {
|
|
123
141
|
options: StatewireSessionOptions;
|
|
124
142
|
configuredContext: StatewireSessionOptions["context"];
|
|
@@ -147,8 +165,16 @@ type Session = {
|
|
|
147
165
|
resumeAt: number;
|
|
148
166
|
idle: boolean;
|
|
149
167
|
onSessionLost: StatewireClient.OnSessionLost | undefined;
|
|
168
|
+
onDeprecatedPath: StatewireClient.OnDeprecatedPath | undefined;
|
|
169
|
+
onSettled: StatewireClient.OnSettled | undefined;
|
|
150
170
|
onFlush: StatewireClient.OnFlush | undefined;
|
|
151
171
|
onDone: (() => void) | undefined;
|
|
172
|
+
/** `outbox.release` promises by entry id. */
|
|
173
|
+
releases: Map<string, Waiter[]>;
|
|
174
|
+
delivering: boolean;
|
|
175
|
+
/** Notices whose handler threw on this mount. */
|
|
176
|
+
skipped: Set<string>;
|
|
177
|
+
views: WeakMap<object, StatewireClient.OutboxEntry>;
|
|
152
178
|
/** Outbox and stamped ids stored before this client mounted. */
|
|
153
179
|
mountIds: Set<string>;
|
|
154
180
|
flushing: Flushing | null;
|
|
@@ -157,9 +183,11 @@ type Session = {
|
|
|
157
183
|
lanesOnAttach: boolean;
|
|
158
184
|
attachEntries: ReadonlySet<Pending>;
|
|
159
185
|
frameFailures: number;
|
|
186
|
+
/** A later hello decides stamps this one could not: another live sharer's stamp sat below them. */
|
|
187
|
+
recheck: ReturnType<typeof setTimeout> | undefined;
|
|
188
|
+
/** Hellos in a row that left stamps waiting at the same watermarks: the recheck backs off. */
|
|
189
|
+
rechecks: { watermarks: string; count: number };
|
|
160
190
|
pending: Pending[];
|
|
161
|
-
failedList: readonly StatewireClient.FailedCommand[];
|
|
162
|
-
failedParts: readonly (readonly StatewireClient.FailedCommand[])[];
|
|
163
191
|
canonical: unknown;
|
|
164
192
|
documentListeners: Set<() => void>;
|
|
165
193
|
documents: StatewireDocuments.State;
|
|
@@ -225,11 +253,12 @@ type RemovalMark = NonNullable<StatewireClient.SessionLane["removed"]>[number];
|
|
|
225
253
|
const marks = (
|
|
226
254
|
s: Session,
|
|
227
255
|
lane: StatewireClient.SessionLane,
|
|
228
|
-
removed: readonly
|
|
256
|
+
removed: readonly { id: string; by?: string | undefined }[],
|
|
257
|
+
failure?: StatewireClient.SendFailure,
|
|
229
258
|
): { removed?: readonly RemovalMark[] } => {
|
|
230
259
|
const added = removed
|
|
231
260
|
.filter((c) => c.by !== undefined && c.by !== s.store.owner?.id)
|
|
232
|
-
.map((c) => ({ id: c.id, by: c.by
|
|
261
|
+
.map((c) => ({ id: c.id, by: c.by!, ...(failure && { failure }) }));
|
|
233
262
|
const next = [...(lane.removed ?? []), ...added];
|
|
234
263
|
return next.length > 0 ? { removed: next } : {};
|
|
235
264
|
};
|
|
@@ -244,15 +273,12 @@ const withOutbox = (
|
|
|
244
273
|
|
|
245
274
|
const outboxOf = (lane: StatewireClient.SessionLane) => lane.outbox ?? [];
|
|
246
275
|
|
|
247
|
-
const sameHold = (a: Hold, b: Hold) =>
|
|
248
|
-
a === b || JSON.stringify(a) === JSON.stringify(b);
|
|
249
|
-
|
|
250
276
|
const changed = (s: Session, p: Pending) => s.lifecycle?.onChange?.(p.inv);
|
|
251
277
|
|
|
252
278
|
const stamp = (s: Session, p: Pending, seq: number) => {
|
|
253
279
|
p.seq = seq;
|
|
254
280
|
p.inv.seq = seq;
|
|
255
|
-
delete p.inv.
|
|
281
|
+
delete p.inv.held;
|
|
256
282
|
p.away = false;
|
|
257
283
|
p.attempted = false;
|
|
258
284
|
p.posted = false;
|
|
@@ -268,13 +294,41 @@ const unstamp = (s: Session, p: Pending) => {
|
|
|
268
294
|
changed(s, p);
|
|
269
295
|
};
|
|
270
296
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
297
|
+
// The pending invocation follows its stored entry: hold, local meta, params.
|
|
298
|
+
const follow = (
|
|
299
|
+
s: Session,
|
|
300
|
+
p: Pending,
|
|
301
|
+
stored: StatewireClient.OutboxCommand,
|
|
302
|
+
) => {
|
|
303
|
+
let dirty = false;
|
|
304
|
+
if ((p.inv.held === true) !== (stored.held === true)) {
|
|
305
|
+
if (stored.held) p.inv.held = true;
|
|
306
|
+
else delete p.inv.held;
|
|
307
|
+
dirty = true;
|
|
308
|
+
}
|
|
309
|
+
if (p.inv.localMeta !== stored.localMeta) {
|
|
310
|
+
if (stored.localMeta === undefined) delete p.inv.localMeta;
|
|
311
|
+
else p.inv.localMeta = stored.localMeta;
|
|
312
|
+
dirty = true;
|
|
313
|
+
}
|
|
314
|
+
if (p.command.params !== stored.params) {
|
|
315
|
+
p.command = {
|
|
316
|
+
...p.command,
|
|
317
|
+
params: stored.params,
|
|
318
|
+
...(stored.meta === undefined ? {} : { meta: stored.meta }),
|
|
319
|
+
};
|
|
320
|
+
if (stored.meta === undefined) delete p.command.meta;
|
|
321
|
+
p.inv.params = paramsOf(stored);
|
|
322
|
+
dirty = true;
|
|
323
|
+
}
|
|
324
|
+
if (dirty) changed(s, p);
|
|
276
325
|
};
|
|
277
326
|
|
|
327
|
+
const paramsOf = (stored: { params: unknown[]; meta?: unknown }) =>
|
|
328
|
+
stored.meta === undefined
|
|
329
|
+
? stored.params
|
|
330
|
+
: (reviveValue(structuredClone(stored.params), stored.meta) as unknown[]);
|
|
331
|
+
|
|
278
332
|
const dropPending = (s: Session, p: Pending) => {
|
|
279
333
|
s.pending = s.pending.filter((q) => q !== p);
|
|
280
334
|
s.lifecycle?.onCovered?.(p.inv);
|
|
@@ -319,11 +373,13 @@ const setRecord = (s: Session, record: StatewireClient.SessionRecord) => {
|
|
|
319
373
|
try {
|
|
320
374
|
s.store.save(record);
|
|
321
375
|
} catch (cause) {
|
|
376
|
+
if (isQuota(cause)) throw cause;
|
|
322
377
|
storageFailed(s, cause);
|
|
323
378
|
throw s.storageError;
|
|
324
379
|
}
|
|
325
380
|
}
|
|
326
381
|
s.record = record;
|
|
382
|
+
s.notify();
|
|
327
383
|
};
|
|
328
384
|
|
|
329
385
|
const toOutbox = (p: Pending): StatewireClient.OutboxCommand => {
|
|
@@ -333,7 +389,8 @@ const toOutbox = (p: Pending): StatewireClient.OutboxCommand => {
|
|
|
333
389
|
...command,
|
|
334
390
|
ts: p.inv.ts,
|
|
335
391
|
...(p.by !== undefined && { by: p.by }),
|
|
336
|
-
...(p.inv.
|
|
392
|
+
...(p.inv.held && { held: true as const }),
|
|
393
|
+
...(p.inv.localMeta !== undefined && { localMeta: p.inv.localMeta }),
|
|
337
394
|
};
|
|
338
395
|
};
|
|
339
396
|
|
|
@@ -346,6 +403,7 @@ const toStored = (s: Session, p: Stamped): StatewireClient.SessionCommand => {
|
|
|
346
403
|
...(p.by !== undefined && { by: p.by }),
|
|
347
404
|
...command,
|
|
348
405
|
ts: p.inv.ts,
|
|
406
|
+
...(p.inv.localMeta !== undefined && { localMeta: p.inv.localMeta }),
|
|
349
407
|
};
|
|
350
408
|
};
|
|
351
409
|
|
|
@@ -383,7 +441,11 @@ const MAX_PARKED_VERDICTS = 64;
|
|
|
383
441
|
// Storage holds what a reload must resend: a command leaves at its dur, at a
|
|
384
442
|
// server verdict, or at a record reset — never at a local channel death.
|
|
385
443
|
const syncBacklogInner = (s: Session) => {
|
|
444
|
+
if (s.shared && s.shared.notices.length > 0)
|
|
445
|
+
noticeInner(s, s.shared.notices.splice(0));
|
|
386
446
|
const parked = s.shared ? s.shared.parked.splice(0) : [];
|
|
447
|
+
const settled = s.shared ? new Set(s.shared.settled) : new Set<string>();
|
|
448
|
+
if (s.shared) s.shared.settled.clear();
|
|
387
449
|
let lanes = s.record.lanes;
|
|
388
450
|
for (const protocol of durableLanes(s)) {
|
|
389
451
|
const lane = laneOf(s.record, protocol);
|
|
@@ -391,18 +453,23 @@ const syncBacklogInner = (s: Session) => {
|
|
|
391
453
|
// stamp; the enqueuer finds the verdict by that id.
|
|
392
454
|
const parkedHere = parked
|
|
393
455
|
.filter((v) => v.protocol === protocol)
|
|
394
|
-
.
|
|
456
|
+
.flatMap(({ verdict }) => {
|
|
395
457
|
const id =
|
|
396
458
|
verdict.id ?? lane.commands.find((c) => c.seq === verdict.seq)?.id;
|
|
397
|
-
|
|
459
|
+
// No stamp left to map it: its owner already settled the command.
|
|
460
|
+
if (id === undefined || settled.has(id)) return [];
|
|
461
|
+
return [{ ...verdict, id }];
|
|
398
462
|
});
|
|
399
463
|
const parkedSeqs = new Set(parkedHere.map((v) => v.seq));
|
|
400
464
|
const commands = mergeBacklog(s, protocol).filter(
|
|
401
465
|
(c) => !parkedSeqs.has(c.seq),
|
|
402
466
|
);
|
|
467
|
+
const kept = (lane.verdicts ?? []).filter(
|
|
468
|
+
(v) => v.id === undefined || !settled.has(v.id),
|
|
469
|
+
);
|
|
403
470
|
const verdicts =
|
|
404
|
-
parkedHere.length > 0
|
|
405
|
-
? [...
|
|
471
|
+
parkedHere.length > 0 || kept.length !== (lane.verdicts ?? []).length
|
|
472
|
+
? [...kept, ...parkedHere].slice(-MAX_PARKED_VERDICTS)
|
|
406
473
|
: lane.verdicts;
|
|
407
474
|
const unchanged =
|
|
408
475
|
verdicts === lane.verdicts &&
|
|
@@ -443,8 +510,14 @@ const transact = (s: Session, fn: () => void): Promise<void> => {
|
|
|
443
510
|
const shared = s.shared;
|
|
444
511
|
const run = shared.txq.then(() =>
|
|
445
512
|
s.store.lock!(async () => {
|
|
446
|
-
|
|
447
|
-
|
|
513
|
+
const stored = await readStored(s);
|
|
514
|
+
shared.inTx = true;
|
|
515
|
+
try {
|
|
516
|
+
refresh(s, stored);
|
|
517
|
+
fn();
|
|
518
|
+
} finally {
|
|
519
|
+
shared.inTx = false;
|
|
520
|
+
}
|
|
448
521
|
watchOwners(s);
|
|
449
522
|
s.notify();
|
|
450
523
|
const persistence = s.store.flush?.();
|
|
@@ -462,16 +535,19 @@ const transact = (s: Session, fn: () => void): Promise<void> => {
|
|
|
462
535
|
return result;
|
|
463
536
|
};
|
|
464
537
|
|
|
538
|
+
const readStored = async (s: Session) =>
|
|
539
|
+
s.store.read ? await s.store.read() : s.store.load();
|
|
540
|
+
|
|
465
541
|
// Under the lock: the stored record is the truth about which sharer stamped
|
|
466
542
|
// what; this client's entries follow it by id.
|
|
467
|
-
const refresh = (s: Session) => {
|
|
468
|
-
const stored = s.store.load();
|
|
543
|
+
const refresh = (s: Session, stored: StatewireClient.SessionRecord | null) => {
|
|
469
544
|
if (stored !== null && stored.clientId !== s.record.clientId) {
|
|
470
545
|
adoptReset(s, stored);
|
|
471
546
|
return;
|
|
472
547
|
}
|
|
473
|
-
if (stored !== null) s.record =
|
|
548
|
+
if (stored !== null) s.record = boundInbox(s.store, stored);
|
|
474
549
|
reconcileLocal(s);
|
|
550
|
+
followReleases(s);
|
|
475
551
|
};
|
|
476
552
|
|
|
477
553
|
const reconcileLocal = (s: Session) => {
|
|
@@ -490,7 +566,7 @@ const reconcileLocal = (s: Session) => {
|
|
|
490
566
|
}
|
|
491
567
|
unstamp(s, p);
|
|
492
568
|
}
|
|
493
|
-
|
|
569
|
+
follow(s, p, queued);
|
|
494
570
|
} else if (stamped && stamped.owner !== me) {
|
|
495
571
|
if (p.adopted) dropPending(s, p);
|
|
496
572
|
else if (!p.away) {
|
|
@@ -499,14 +575,24 @@ const reconcileLocal = (s: Session) => {
|
|
|
499
575
|
}
|
|
500
576
|
} else if (stamped) {
|
|
501
577
|
if (p.seq !== stamped.seq) stamp(s, p, stamped.seq);
|
|
502
|
-
} else if (isOutboxed(p)) {
|
|
503
|
-
|
|
578
|
+
} else if (!p.adopted && (isOutboxed(p) || p.away)) {
|
|
579
|
+
const mark = lane.removed?.find((m) => m.id === p.id);
|
|
580
|
+
if (mark === undefined) {
|
|
504
581
|
p.away = true;
|
|
505
582
|
continue;
|
|
506
583
|
}
|
|
507
|
-
|
|
584
|
+
s.lifecycle?.onCovered?.(p.inv);
|
|
585
|
+
settleEntry(
|
|
586
|
+
s,
|
|
587
|
+
p,
|
|
588
|
+
{
|
|
589
|
+
ok: false,
|
|
590
|
+
failure: mark.failure ?? { fate: "aborted", cause: "removed" },
|
|
591
|
+
},
|
|
592
|
+
mark.failure ? "noticed" : "memory",
|
|
593
|
+
);
|
|
508
594
|
const { removed: _removed, ...rest } = lane;
|
|
509
|
-
const left = lane.removed
|
|
595
|
+
const left = lane.removed!.filter((m) => m.id !== p.id);
|
|
510
596
|
setRecord(
|
|
511
597
|
s,
|
|
512
598
|
withLane(s.record, protocol, {
|
|
@@ -518,6 +604,73 @@ const reconcileLocal = (s: Session) => {
|
|
|
518
604
|
}
|
|
519
605
|
};
|
|
520
606
|
|
|
607
|
+
const locate = (
|
|
608
|
+
s: Session,
|
|
609
|
+
id: string,
|
|
610
|
+
):
|
|
611
|
+
| { protocol: string; stored: StatewireClient.OutboxCommand }
|
|
612
|
+
| { protocol: string; stored?: undefined }
|
|
613
|
+
| undefined => {
|
|
614
|
+
for (const [protocol, lane] of Object.entries(s.record.lanes)) {
|
|
615
|
+
const stored = outboxOf(lane).find((c) => c.id === id);
|
|
616
|
+
if (stored) return { protocol, stored };
|
|
617
|
+
if (lane.commands.some((c) => c.id === id)) return { protocol };
|
|
618
|
+
}
|
|
619
|
+
const p = s.pending.find((q) => q.id === id);
|
|
620
|
+
if (p === undefined) return undefined;
|
|
621
|
+
return isOutboxed(p) && isEphemeral(s, p.command.protocol)
|
|
622
|
+
? { protocol: p.command.protocol, stored: toOutbox(p) }
|
|
623
|
+
: { protocol: p.command.protocol };
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const settleReleases = (
|
|
627
|
+
s: Session,
|
|
628
|
+
id: string,
|
|
629
|
+
outcome:
|
|
630
|
+
| { ok: true; payload: unknown }
|
|
631
|
+
| { ok: false; failure: StatewireClient.SendFailure },
|
|
632
|
+
) => {
|
|
633
|
+
const waiters = s.releases.get(id);
|
|
634
|
+
if (waiters === undefined) return;
|
|
635
|
+
s.releases.delete(id);
|
|
636
|
+
for (const waiter of waiters) {
|
|
637
|
+
if (outcome.ok) waiter.resolve(outcome.payload);
|
|
638
|
+
else waiter.reject(new StatewireSendError(outcome.failure));
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
// A release whose command another sharer settled: the record shows only that
|
|
643
|
+
// it left, failed (a notice) or was taken unsent (a mark).
|
|
644
|
+
const followReleases = (s: Session) => {
|
|
645
|
+
for (const id of [...s.releases.keys()]) {
|
|
646
|
+
if (locate(s, id) !== undefined) continue;
|
|
647
|
+
const lanes = Object.values(s.record.lanes);
|
|
648
|
+
const notice = lanes
|
|
649
|
+
.flatMap((lane) => lane.inbox ?? [])
|
|
650
|
+
.find((n) => n.id === id);
|
|
651
|
+
const mark = lanes
|
|
652
|
+
.flatMap((lane) => lane.removed ?? [])
|
|
653
|
+
.find((m) => m.id === id);
|
|
654
|
+
const failure = notice?.failure ?? mark?.failure;
|
|
655
|
+
settleReleases(
|
|
656
|
+
s,
|
|
657
|
+
id,
|
|
658
|
+
failure
|
|
659
|
+
? { ok: false, failure }
|
|
660
|
+
: {
|
|
661
|
+
ok: true,
|
|
662
|
+
payload:
|
|
663
|
+
notice?.result === undefined
|
|
664
|
+
? undefined
|
|
665
|
+
: paramsOf({
|
|
666
|
+
params: notice.result.value,
|
|
667
|
+
meta: notice.result.meta,
|
|
668
|
+
})[0],
|
|
669
|
+
},
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
|
|
521
674
|
// A sharer that stamped commands and left: once its owner lock frees,
|
|
522
675
|
// another sharer takes its stamps over and reattaches, so the hello's
|
|
523
676
|
// watermark settles them.
|
|
@@ -659,113 +812,159 @@ const cover = (s: Session, entry: Pending) => {
|
|
|
659
812
|
s.lifecycle?.onCovered?.(entry.inv);
|
|
660
813
|
};
|
|
661
814
|
|
|
662
|
-
export const
|
|
663
|
-
s: Session,
|
|
664
|
-
): readonly StatewireClient.FailedCommand[] => {
|
|
665
|
-
const parts = Object.values(s.record.lanes).flatMap((lane) =>
|
|
666
|
-
lane.failed === undefined ? [] : [lane.failed],
|
|
667
|
-
);
|
|
668
|
-
const same =
|
|
669
|
-
parts.length === s.failedParts.length &&
|
|
670
|
-
parts.every((part, i) => part === s.failedParts[i]);
|
|
671
|
-
if (!same) {
|
|
672
|
-
s.failedParts = parts;
|
|
673
|
-
s.failedList = parts.flat();
|
|
674
|
-
}
|
|
675
|
-
return s.failedList;
|
|
676
|
-
};
|
|
815
|
+
export const MAX_INBOX = 256;
|
|
677
816
|
|
|
678
|
-
|
|
817
|
+
type Outcome =
|
|
818
|
+
| { ok: true; payload: unknown }
|
|
819
|
+
| { ok: false; failure: StatewireClient.SendFailure };
|
|
679
820
|
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
821
|
+
const updateOf = (
|
|
822
|
+
protocol: string,
|
|
823
|
+
stored: StatewireClient.StoredSettled,
|
|
824
|
+
): Statewire.CommandUpdate => ({
|
|
825
|
+
protocol,
|
|
826
|
+
id: stored.id,
|
|
827
|
+
method: stored.method,
|
|
828
|
+
params: paramsOf(stored) as never[],
|
|
829
|
+
...(stored.seq !== undefined && { seq: stored.seq }),
|
|
830
|
+
status: "settled",
|
|
831
|
+
...(stored.result !== undefined && {
|
|
832
|
+
result: paramsOf({
|
|
833
|
+
params: stored.result.value,
|
|
834
|
+
meta: stored.result.meta,
|
|
835
|
+
})[0],
|
|
836
|
+
}),
|
|
837
|
+
...(stored.failure !== undefined && { failure: stored.failure }),
|
|
838
|
+
...(stored.localMeta !== undefined && { localMeta: stored.localMeta }),
|
|
839
|
+
});
|
|
684
840
|
|
|
685
|
-
|
|
841
|
+
// The inbox keeps the newest notices; the rest are reported as evicted.
|
|
842
|
+
const boundInbox = (
|
|
843
|
+
store: StatewireClient.SessionStorage,
|
|
844
|
+
record: StatewireClient.SessionRecord,
|
|
845
|
+
): StatewireClient.SessionRecord => {
|
|
686
846
|
let lanes = record.lanes;
|
|
847
|
+
const dropped: Statewire.CommandUpdate[] = [];
|
|
687
848
|
for (const [protocol, lane] of Object.entries(lanes)) {
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
849
|
+
const inbox = lane.inbox;
|
|
850
|
+
if (inbox === undefined || inbox.length <= MAX_INBOX) continue;
|
|
851
|
+
const sorted = [...inbox].sort((x, y) => x.ts - y.ts);
|
|
852
|
+
const cut = sorted.length - MAX_INBOX;
|
|
853
|
+
dropped.push(...sorted.slice(0, cut).map((n) => updateOf(protocol, n)));
|
|
854
|
+
lanes = { ...lanes, [protocol]: { ...lane, inbox: sorted.slice(cut) } };
|
|
693
855
|
}
|
|
856
|
+
if (dropped.length > 0) store.onEvict?.({ reason: "inbox", dropped });
|
|
694
857
|
return lanes === record.lanes ? record : { ...record, lanes };
|
|
695
858
|
};
|
|
696
859
|
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
failure,
|
|
718
|
-
ts: entry.inv.ts,
|
|
719
|
-
seq: entry.seq,
|
|
720
|
-
},
|
|
721
|
-
]),
|
|
860
|
+
const toNotice = (
|
|
861
|
+
stored: Omit<StatewireClient.SessionCommand, "seq"> & { seq?: number },
|
|
862
|
+
outcome: Outcome,
|
|
863
|
+
): StatewireClient.StoredSettled => {
|
|
864
|
+
const result = outcome.ok
|
|
865
|
+
? encodeParams(outcome.payload === undefined ? [] : [outcome.payload])
|
|
866
|
+
: undefined;
|
|
867
|
+
return {
|
|
868
|
+
id: stored.id,
|
|
869
|
+
method: stored.method,
|
|
870
|
+
params: stored.params,
|
|
871
|
+
...(stored.meta !== undefined && { meta: stored.meta }),
|
|
872
|
+
ts: stored.ts,
|
|
873
|
+
...(stored.seq !== undefined && { seq: stored.seq }),
|
|
874
|
+
...(stored.localMeta !== undefined && { localMeta: stored.localMeta }),
|
|
875
|
+
...(result !== undefined && {
|
|
876
|
+
result: {
|
|
877
|
+
value: result.params,
|
|
878
|
+
...(result.meta !== undefined && { meta: result.meta }),
|
|
879
|
+
},
|
|
722
880
|
}),
|
|
723
|
-
|
|
724
|
-
|
|
881
|
+
...(!outcome.ok && { failure: outcome.failure }),
|
|
882
|
+
};
|
|
725
883
|
};
|
|
726
884
|
|
|
727
|
-
const
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
885
|
+
const lostNotice = (
|
|
886
|
+
stored: Omit<StatewireClient.SessionCommand, "seq"> & { seq?: number },
|
|
887
|
+
) => toNotice(stored, { ok: false, failure: LOST });
|
|
888
|
+
|
|
889
|
+
const LOST = { fate: "lost", cause: "session-lost" } as const;
|
|
890
|
+
|
|
891
|
+
const withNotices = (
|
|
892
|
+
lane: StatewireClient.SessionLane,
|
|
893
|
+
notices: readonly StatewireClient.StoredSettled[],
|
|
894
|
+
): StatewireClient.SessionLane => {
|
|
895
|
+
const known = new Set((lane.inbox ?? []).map((n) => n.id));
|
|
896
|
+
const added = notices.filter((n) => !known.has(n.id));
|
|
897
|
+
return added.length === 0
|
|
898
|
+
? lane
|
|
899
|
+
: { ...lane, inbox: [...(lane.inbox ?? []), ...added] };
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
// The settled update lands in the same write that drops the stamp: a reload
|
|
903
|
+
// either resends the command or delivers the update.
|
|
904
|
+
type Notice = { entry: Stamped; outcome: Outcome };
|
|
905
|
+
|
|
906
|
+
const noticeInner = (s: Session, notices: readonly Notice[]) => {
|
|
907
|
+
let record = s.record;
|
|
908
|
+
for (const { entry, outcome } of notices) {
|
|
909
|
+
const protocol = entry.command.protocol;
|
|
910
|
+
const lane = laneOf(record, protocol);
|
|
911
|
+
record = withLane(record, protocol, {
|
|
912
|
+
...withNotices(lane, [toNotice(toStored(s, entry), outcome)]),
|
|
913
|
+
commands: lane.commands.filter((c) => c.id !== entry.id),
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
setRecord(s, boundInbox(s.store, record));
|
|
917
|
+
deliver(s);
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
// Shared storage: the update rides the next backlog sync's locked write.
|
|
921
|
+
const notice = (s: Session, entry: Stamped, outcome: Outcome) => {
|
|
922
|
+
if (!s.shared || s.shared.inTx) {
|
|
923
|
+
noticeInner(s, [{ entry, outcome }]);
|
|
734
924
|
return;
|
|
735
925
|
}
|
|
736
|
-
|
|
926
|
+
s.shared.notices.push({ entry, outcome });
|
|
927
|
+
syncBacklog(s);
|
|
737
928
|
};
|
|
738
929
|
|
|
930
|
+
/**
|
|
931
|
+
* "notice": a stamped durable command persists its settled update; "noticed":
|
|
932
|
+
* the caller already did; "memory": the update is best-effort only.
|
|
933
|
+
*/
|
|
934
|
+
type SettleMode = "notice" | "noticed" | "memory";
|
|
935
|
+
|
|
739
936
|
const settleEntry = (
|
|
740
937
|
s: Session,
|
|
741
938
|
entry: Pending,
|
|
742
|
-
outcome:
|
|
743
|
-
|
|
744
|
-
| { ok: false; failure: StatewireClient.SendFailure },
|
|
939
|
+
outcome: Outcome,
|
|
940
|
+
mode: SettleMode = "notice",
|
|
745
941
|
) => {
|
|
746
942
|
s.pending = s.pending.filter((p) => p !== entry);
|
|
943
|
+
if (s.shared && !entry.adopted) s.shared.settled.add(entry.id);
|
|
944
|
+
settleReleases(s, entry.id, outcome);
|
|
945
|
+
// The record keeps every unsettled command for the next attach; only a
|
|
946
|
+
// command it no longer owns becomes a durable settled update.
|
|
947
|
+
const durable =
|
|
948
|
+
mode === "noticed" ||
|
|
949
|
+
(mode === "notice" &&
|
|
950
|
+
(outcome.ok || outcome.failure.cause !== "unmount") &&
|
|
951
|
+
isStamped(entry) &&
|
|
952
|
+
!isEphemeral(s, entry.command.protocol));
|
|
953
|
+
if (durable && mode === "notice") notice(s, entry as Stamped, outcome);
|
|
747
954
|
if (outcome.ok) {
|
|
748
955
|
entry.resolve(outcome.payload);
|
|
749
|
-
s.lifecycle?.onSettle(
|
|
750
|
-
|
|
751
|
-
payload: outcome.payload,
|
|
752
|
-
|
|
956
|
+
s.lifecycle?.onSettle(
|
|
957
|
+
entry.inv,
|
|
958
|
+
{ type: "accepted", payload: outcome.payload },
|
|
959
|
+
durable,
|
|
960
|
+
);
|
|
753
961
|
} else {
|
|
754
|
-
// The record keeps every unsettled command for the next attach; only
|
|
755
|
-
// invocations it no longer owns become failed history.
|
|
756
|
-
const rides = outcome.failure.cause === "unmount";
|
|
757
|
-
if (
|
|
758
|
-
!rides &&
|
|
759
|
-
isStamped(entry) &&
|
|
760
|
-
!entry.adopted &&
|
|
761
|
-
!isEphemeral(s, entry.command.protocol)
|
|
762
|
-
)
|
|
763
|
-
retainFailed(s, entry, outcome.failure);
|
|
764
962
|
entry.reject(new StatewireSendError(outcome.failure));
|
|
765
|
-
s.lifecycle?.onSettle(
|
|
766
|
-
|
|
767
|
-
error: new StatewireSendError(outcome.failure),
|
|
768
|
-
|
|
963
|
+
s.lifecycle?.onSettle(
|
|
964
|
+
entry.inv,
|
|
965
|
+
{ type: "rejected", error: new StatewireSendError(outcome.failure) },
|
|
966
|
+
durable,
|
|
967
|
+
);
|
|
769
968
|
}
|
|
770
969
|
};
|
|
771
970
|
|
|
@@ -817,14 +1016,10 @@ const settleRemoved = (s: Session, entry: Pending) => {
|
|
|
817
1016
|
const requeue = (s: Session, survivors: Pending[]) => {
|
|
818
1017
|
for (const old of survivors) {
|
|
819
1018
|
s.lifecycle?.onCovered?.(old.inv);
|
|
820
|
-
enqueueInner(
|
|
821
|
-
|
|
822
|
-
old.
|
|
823
|
-
|
|
824
|
-
old.resolve,
|
|
825
|
-
old.reject,
|
|
826
|
-
old.inv.hold,
|
|
827
|
-
);
|
|
1019
|
+
enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject, {
|
|
1020
|
+
...(old.inv.held && { hold: true }),
|
|
1021
|
+
...(old.inv.localMeta !== undefined && { localMeta: old.inv.localMeta }),
|
|
1022
|
+
});
|
|
828
1023
|
}
|
|
829
1024
|
};
|
|
830
1025
|
|
|
@@ -842,22 +1037,14 @@ const rebaseRecord = (s: Session, adopt: () => void) => {
|
|
|
842
1037
|
for (const p of s.pending) if (p.adopted) s.lifecycle?.onCovered?.(p.inv);
|
|
843
1038
|
s.pending = [];
|
|
844
1039
|
s.durableSeq = {};
|
|
845
|
-
//
|
|
1040
|
+
// Failure notices ride through every record replacement.
|
|
846
1041
|
const previous = s.record.lanes;
|
|
847
1042
|
adopt();
|
|
848
1043
|
let lanes = s.record.lanes;
|
|
849
1044
|
for (const [protocol, lane] of Object.entries(previous)) {
|
|
850
|
-
const
|
|
851
|
-
if (
|
|
852
|
-
|
|
853
|
-
const current = laneOf(s.record, protocol);
|
|
854
|
-
lanes = {
|
|
855
|
-
...lanes,
|
|
856
|
-
[protocol]: {
|
|
857
|
-
...current,
|
|
858
|
-
failed: boundFailed([...(current.failed ?? []), ...failed]),
|
|
859
|
-
},
|
|
860
|
-
};
|
|
1045
|
+
const current = laneOf({ ...s.record, lanes }, protocol);
|
|
1046
|
+
if (!lane.inbox?.length || current.inbox === lane.inbox) continue;
|
|
1047
|
+
lanes = { ...lanes, [protocol]: withNotices(current, lane.inbox) };
|
|
861
1048
|
}
|
|
862
1049
|
if (lanes !== s.record.lanes) setRecord(s, { ...s.record, lanes });
|
|
863
1050
|
requeue(s, survivors);
|
|
@@ -873,7 +1060,7 @@ const resetRecord = (s: Session, clientId: string) =>
|
|
|
873
1060
|
// attempted history and our survivors re-stamp on the stored clocks.
|
|
874
1061
|
const adoptReset = (s: Session, stored: StatewireClient.SessionRecord) => {
|
|
875
1062
|
rebaseRecord(s, () => {
|
|
876
|
-
s.record =
|
|
1063
|
+
s.record = boundInbox(s.store, stored);
|
|
877
1064
|
});
|
|
878
1065
|
// The host attributes frames by lease, and the lease is bound to the id
|
|
879
1066
|
// this attach was opened with.
|
|
@@ -1090,21 +1277,11 @@ const publishIngested = (s: Session) => {
|
|
|
1090
1277
|
// watermark+1 proves the record that admitted the missing prefix is gone —
|
|
1091
1278
|
// its history settles lost and the lane clock restarts at the watermark.
|
|
1092
1279
|
const sessionLostInner = (s: Session, protocol: string, seqWm: number) => {
|
|
1280
|
+
const lane = laneOf(s.record, protocol);
|
|
1281
|
+
const failure = { fate: "lost", cause: "session-lost" } as const;
|
|
1093
1282
|
const held = laneEntries(s, protocol);
|
|
1094
|
-
settleLost(s, held);
|
|
1095
1283
|
s.durableSeq[protocol] = seqWm;
|
|
1096
|
-
const
|
|
1097
|
-
setRecord(
|
|
1098
|
-
s,
|
|
1099
|
-
withLane(s.record, protocol, {
|
|
1100
|
-
nextSeq: seqWm + 1,
|
|
1101
|
-
commands: [],
|
|
1102
|
-
...(outbox !== undefined && { outbox }),
|
|
1103
|
-
...(removed !== undefined && { removed }),
|
|
1104
|
-
...(failed !== undefined && { failed }),
|
|
1105
|
-
}),
|
|
1106
|
-
);
|
|
1107
|
-
if (
|
|
1284
|
+
const requeue =
|
|
1108
1285
|
held.length > 0 &&
|
|
1109
1286
|
s.onSessionLost?.(
|
|
1110
1287
|
held.map((p) => ({
|
|
@@ -1115,30 +1292,85 @@ const sessionLostInner = (s: Session, protocol: string, seqWm: number) => {
|
|
|
1115
1292
|
seq: p.seq,
|
|
1116
1293
|
...(p.inv.clientId !== undefined && { clientId: p.inv.clientId }),
|
|
1117
1294
|
})),
|
|
1118
|
-
) === "requeue"
|
|
1119
|
-
) {
|
|
1120
|
-
|
|
1295
|
+
) === "requeue";
|
|
1296
|
+
if (requeue) {
|
|
1297
|
+
if (s.onDeprecatedPath)
|
|
1298
|
+
s.onDeprecatedPath({ kind: "session-lost-requeue", count: held.length });
|
|
1299
|
+
else
|
|
1300
|
+
console.warn(
|
|
1301
|
+
`statewire: onSessionLost "requeue" re-enqueued ${held.length} commands; it is deprecated and will be removed`,
|
|
1302
|
+
);
|
|
1303
|
+
for (const p of held) {
|
|
1304
|
+
s.lifecycle?.onCovered?.(p.inv);
|
|
1305
|
+
settleEntry(s, p, { ok: false, failure }, "memory");
|
|
1306
|
+
}
|
|
1307
|
+
setRecord(
|
|
1308
|
+
s,
|
|
1309
|
+
withLane(s.record, protocol, {
|
|
1310
|
+
...lane,
|
|
1311
|
+
nextSeq: seqWm + 1,
|
|
1312
|
+
commands: [],
|
|
1313
|
+
}),
|
|
1314
|
+
);
|
|
1315
|
+
for (const old of held)
|
|
1121
1316
|
enqueueInner(
|
|
1122
1317
|
s,
|
|
1123
1318
|
old.command,
|
|
1124
1319
|
old.inv.params,
|
|
1125
1320
|
() => {},
|
|
1126
1321
|
() => {},
|
|
1322
|
+
{
|
|
1323
|
+
...(old.inv.localMeta !== undefined && {
|
|
1324
|
+
localMeta: old.inv.localMeta,
|
|
1325
|
+
}),
|
|
1326
|
+
},
|
|
1127
1327
|
);
|
|
1328
|
+
} else {
|
|
1329
|
+
// Default: the whole queue goes, each command a failure notice.
|
|
1330
|
+
const stamps = mergeBacklog(s, protocol);
|
|
1331
|
+
const queued = outboxOf(lane);
|
|
1332
|
+
const { outbox: _outbox, ...rest } = lane;
|
|
1333
|
+
setRecord(
|
|
1334
|
+
s,
|
|
1335
|
+
withLane(s.record, protocol, {
|
|
1336
|
+
...withNotices(rest, [
|
|
1337
|
+
...stamps.map((c) => lostNotice(c)),
|
|
1338
|
+
...queued.map((c) => lostNotice(c)),
|
|
1339
|
+
]),
|
|
1340
|
+
nextSeq: seqWm + 1,
|
|
1341
|
+
commands: [],
|
|
1342
|
+
...marks(
|
|
1343
|
+
s,
|
|
1344
|
+
lane,
|
|
1345
|
+
[
|
|
1346
|
+
...stamps.map((c) => ({ id: c.id, by: c.by ?? c.owner })),
|
|
1347
|
+
...queued,
|
|
1348
|
+
],
|
|
1349
|
+
failure,
|
|
1350
|
+
),
|
|
1351
|
+
}),
|
|
1352
|
+
);
|
|
1353
|
+
for (const p of s.pending.filter((q) => q.command.protocol === protocol)) {
|
|
1354
|
+
s.lifecycle?.onCovered?.(p.inv);
|
|
1355
|
+
settleEntry(s, p, { ok: false, failure }, "noticed");
|
|
1128
1356
|
}
|
|
1357
|
+
deliver(s);
|
|
1129
1358
|
}
|
|
1130
1359
|
publish(s);
|
|
1131
1360
|
wake(s);
|
|
1132
1361
|
};
|
|
1133
1362
|
|
|
1134
|
-
// Every stamp above the watermark
|
|
1135
|
-
//
|
|
1136
|
-
// admitted
|
|
1363
|
+
// Every stamp above the watermark returns to the front of the outbox. A stamp
|
|
1364
|
+
// past watermark+1, or one this client saw admitted, proves the server record
|
|
1365
|
+
// that admitted it is gone. Another live sharer's stamp may still be on its
|
|
1366
|
+
// way on that sharer's attach: it and every stamp after it wait for a later
|
|
1367
|
+
// hello; the stamps before it never arrived and resend under their seqs.
|
|
1137
1368
|
const demote = (
|
|
1138
1369
|
s: Session,
|
|
1139
1370
|
protocol: string,
|
|
1140
1371
|
watermark: number,
|
|
1141
1372
|
demoted: Set<string>,
|
|
1373
|
+
waiting: Set<Pending>,
|
|
1142
1374
|
) => {
|
|
1143
1375
|
const lane = laneOf(s.record, protocol);
|
|
1144
1376
|
const local = laneEntries(s, protocol).filter((p) => p.seq > watermark);
|
|
@@ -1156,6 +1388,17 @@ const demote = (
|
|
|
1156
1388
|
sessionLostInner(s, protocol, watermark);
|
|
1157
1389
|
return;
|
|
1158
1390
|
}
|
|
1391
|
+
const me = s.store.owner?.id;
|
|
1392
|
+
const foreign = moved.find(
|
|
1393
|
+
(c) =>
|
|
1394
|
+
c.owner !== undefined &&
|
|
1395
|
+
c.owner !== me &&
|
|
1396
|
+
!local.some((p) => p.id === c.id),
|
|
1397
|
+
);
|
|
1398
|
+
if (foreign !== undefined) {
|
|
1399
|
+
for (const p of local) if (p.seq > foreign.seq) waiting.add(p);
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1159
1402
|
setRecord(
|
|
1160
1403
|
s,
|
|
1161
1404
|
withLane(s.record, protocol, {
|
|
@@ -1175,6 +1418,24 @@ const demote = (
|
|
|
1175
1418
|
sortPending(s);
|
|
1176
1419
|
};
|
|
1177
1420
|
|
|
1421
|
+
const recheckLater = (s: Session, watermarks: string) => {
|
|
1422
|
+
if (s.rechecks.watermarks !== watermarks)
|
|
1423
|
+
s.rechecks = { watermarks, count: 0 };
|
|
1424
|
+
s.rechecks.count += 1;
|
|
1425
|
+
if (s.recheck !== undefined) return;
|
|
1426
|
+
s.recheck = setTimeout(
|
|
1427
|
+
() => {
|
|
1428
|
+
s.recheck = undefined;
|
|
1429
|
+
if (s.running && s.attach === "attached")
|
|
1430
|
+
s.transport?.connection.reconnect();
|
|
1431
|
+
},
|
|
1432
|
+
backoff(s.rechecks.count + 1),
|
|
1433
|
+
);
|
|
1434
|
+
};
|
|
1435
|
+
|
|
1436
|
+
const gapFill = (s: Session) =>
|
|
1437
|
+
s.selection !== undefined && s.selection.version >= GAP_FILL_VERSION;
|
|
1438
|
+
|
|
1178
1439
|
const selectionOf = (
|
|
1179
1440
|
syn: StatewireSyn,
|
|
1180
1441
|
): StatewireProtocol.Selection | undefined => {
|
|
@@ -1222,6 +1483,7 @@ const handleHello = (
|
|
|
1222
1483
|
// verdicts (the server never re-runs them); seqs above it never arrived
|
|
1223
1484
|
// and go back to the outbox for fresh ones.
|
|
1224
1485
|
const demoted = new Set<string>();
|
|
1486
|
+
const waiting = new Set<Pending>();
|
|
1225
1487
|
for (const lane of lanes) {
|
|
1226
1488
|
if (lane.record === "ephemeral") {
|
|
1227
1489
|
s.ephemeralSeqs[lane.name] = Math.max(
|
|
@@ -1232,17 +1494,24 @@ const handleHello = (
|
|
|
1232
1494
|
);
|
|
1233
1495
|
continue;
|
|
1234
1496
|
}
|
|
1235
|
-
demote(s, lane.name, lane.seq ?? 0, demoted);
|
|
1497
|
+
demote(s, lane.name, lane.seq ?? 0, demoted, waiting);
|
|
1236
1498
|
s.durableSeq[lane.name] = lane.seq ?? 0;
|
|
1237
1499
|
}
|
|
1238
1500
|
for (const entry of s.pending) {
|
|
1239
1501
|
entry.posted =
|
|
1240
1502
|
attached.has(entry) ||
|
|
1503
|
+
waiting.has(entry) ||
|
|
1241
1504
|
(isStamped(entry) &&
|
|
1242
1505
|
entry.seq <= durableSeqOf(s, entry.command.protocol) &&
|
|
1243
1506
|
!isEphemeral(s, entry.command.protocol));
|
|
1244
1507
|
entry.stale = false;
|
|
1245
1508
|
}
|
|
1509
|
+
if (waiting.size > 0)
|
|
1510
|
+
recheckLater(
|
|
1511
|
+
s,
|
|
1512
|
+
JSON.stringify(lanes.map((lane) => [lane.name, lane.seq])),
|
|
1513
|
+
);
|
|
1514
|
+
else s.rechecks = { watermarks: "", count: 0 };
|
|
1246
1515
|
syncBacklogInner(s);
|
|
1247
1516
|
s.sessionKnown = true;
|
|
1248
1517
|
flushInner(s, demoted);
|
|
@@ -1251,11 +1520,13 @@ const handleHello = (
|
|
|
1251
1520
|
reconcile();
|
|
1252
1521
|
publishIngested(s);
|
|
1253
1522
|
void pump(s);
|
|
1523
|
+
deliver(s);
|
|
1254
1524
|
return;
|
|
1255
1525
|
}
|
|
1256
1526
|
transactBackground(s, reconcile, () => {
|
|
1257
1527
|
publishIngested(s);
|
|
1258
1528
|
void pump(s);
|
|
1529
|
+
deliver(s);
|
|
1259
1530
|
});
|
|
1260
1531
|
};
|
|
1261
1532
|
|
|
@@ -1458,22 +1729,23 @@ const pump = async (s: Session) => {
|
|
|
1458
1729
|
if (!s.hydrated) await s.ready;
|
|
1459
1730
|
const transport = s.transport;
|
|
1460
1731
|
const flushable = flushCandidates(s).length > 0;
|
|
1461
|
-
const queued =
|
|
1462
|
-
(p) => isOutboxed(p) && p.inv.
|
|
1463
|
-
|
|
1732
|
+
const queued =
|
|
1733
|
+
s.pending.some((p) => isOutboxed(p) && !p.inv.held) ||
|
|
1734
|
+
queuedInRecord(s);
|
|
1464
1735
|
if (
|
|
1465
1736
|
buildLanes(s) === null &&
|
|
1466
1737
|
!flushable &&
|
|
1467
1738
|
(!queued || s.attach === "attached")
|
|
1468
1739
|
)
|
|
1469
1740
|
return;
|
|
1470
|
-
|
|
1741
|
+
const waiting = s.pending.length > 0 || queued;
|
|
1742
|
+
if (transport?.parked && !waiting) return;
|
|
1471
1743
|
if (s.attach !== "attached" || !transport) {
|
|
1472
|
-
if (
|
|
1744
|
+
if (!waiting) return;
|
|
1473
1745
|
if (
|
|
1474
1746
|
(transport?.connection.status === "standby" &&
|
|
1475
1747
|
transport.connection.reason === "idle") ||
|
|
1476
|
-
|
|
1748
|
+
transport?.parked
|
|
1477
1749
|
) {
|
|
1478
1750
|
transport.connection.reconnect();
|
|
1479
1751
|
}
|
|
@@ -1510,8 +1782,8 @@ const pump = async (s: Session) => {
|
|
|
1510
1782
|
return transport.send(built.frame);
|
|
1511
1783
|
};
|
|
1512
1784
|
result = await (s.shared && !s.deactivated && lanes.entries.length > 0
|
|
1513
|
-
? s.store.lock!(() => {
|
|
1514
|
-
refresh(s);
|
|
1785
|
+
? s.store.lock!(async () => {
|
|
1786
|
+
refresh(s, await readStored(s));
|
|
1515
1787
|
lanes = buildLanes(s);
|
|
1516
1788
|
if (!lanes || epoch !== s.attachEpoch) return null;
|
|
1517
1789
|
return send(lanes);
|
|
@@ -1519,6 +1791,18 @@ const pump = async (s: Session) => {
|
|
|
1519
1791
|
: send(lanes));
|
|
1520
1792
|
} catch {
|
|
1521
1793
|
if (!s.running || isFinished(s)) return;
|
|
1794
|
+
// The frame may have arrived. Under the gap-fill wire its seqs never
|
|
1795
|
+
// go out again: the next hello answers what arrived and frees the rest.
|
|
1796
|
+
if (lanes !== null && lanes.entries.length > 0 && gapFill(s)) {
|
|
1797
|
+
if (epoch !== s.attachEpoch) continue;
|
|
1798
|
+
for (const entry of lanes.entries)
|
|
1799
|
+
if (s.pending.includes(entry)) entry.posted = true;
|
|
1800
|
+
s.frameFailures += 1;
|
|
1801
|
+
await sleepUntilWake(s, backoff(s.frameFailures));
|
|
1802
|
+
if (s.running && epoch === s.attachEpoch && s.attach === "attached")
|
|
1803
|
+
transport.connection.reconnect();
|
|
1804
|
+
continue;
|
|
1805
|
+
}
|
|
1522
1806
|
if (lanes?.frame.ctx?.length) {
|
|
1523
1807
|
s.lanesOnAttach = true;
|
|
1524
1808
|
transport.connection.reconnect();
|
|
@@ -1596,6 +1880,7 @@ const start = (s: Session) => {
|
|
|
1596
1880
|
() => {
|
|
1597
1881
|
wake(s);
|
|
1598
1882
|
void pump(s);
|
|
1883
|
+
deliver(s);
|
|
1599
1884
|
},
|
|
1600
1885
|
);
|
|
1601
1886
|
});
|
|
@@ -1608,7 +1893,10 @@ const start = (s: Session) => {
|
|
|
1608
1893
|
adoptOwner(s, undefined);
|
|
1609
1894
|
consumeVerdicts(s);
|
|
1610
1895
|
},
|
|
1611
|
-
() =>
|
|
1896
|
+
() => {
|
|
1897
|
+
void pump(s);
|
|
1898
|
+
deliver(s);
|
|
1899
|
+
},
|
|
1612
1900
|
);
|
|
1613
1901
|
return;
|
|
1614
1902
|
}
|
|
@@ -1619,10 +1907,13 @@ const start = (s: Session) => {
|
|
|
1619
1907
|
adoptOwner(s, undefined);
|
|
1620
1908
|
s.running = true;
|
|
1621
1909
|
void pump(s);
|
|
1910
|
+
deliver(s);
|
|
1622
1911
|
};
|
|
1623
1912
|
|
|
1624
1913
|
const stop = (s: Session) => {
|
|
1625
1914
|
s.running = false;
|
|
1915
|
+
clearTimeout(s.recheck);
|
|
1916
|
+
s.recheck = undefined;
|
|
1626
1917
|
if (s.shared) {
|
|
1627
1918
|
s.shared.unsubscribe?.();
|
|
1628
1919
|
s.shared.unsubscribe = null;
|
|
@@ -1640,13 +1931,23 @@ const setTransport = (s: Session, transport: RemoteLink) => {
|
|
|
1640
1931
|
wake(s);
|
|
1641
1932
|
};
|
|
1642
1933
|
|
|
1934
|
+
type EnqueueOptions = {
|
|
1935
|
+
hold?: boolean;
|
|
1936
|
+
localMeta?: unknown;
|
|
1937
|
+
};
|
|
1938
|
+
|
|
1939
|
+
const QUOTA: StatewireClient.SendFailure = { fate: "aborted", cause: "quota" };
|
|
1940
|
+
|
|
1941
|
+
const isQuota = (error: unknown) =>
|
|
1942
|
+
error instanceof Error && error.name === "StatewireQuotaError";
|
|
1943
|
+
|
|
1643
1944
|
const enqueueInner = (
|
|
1644
1945
|
s: Session,
|
|
1645
1946
|
command: WireCommand,
|
|
1646
1947
|
params: readonly unknown[],
|
|
1647
1948
|
resolve: (payload: unknown) => void,
|
|
1648
1949
|
reject: (error: unknown) => void,
|
|
1649
|
-
|
|
1950
|
+
options: EnqueueOptions = {},
|
|
1650
1951
|
) => {
|
|
1651
1952
|
const protocol = command.protocol;
|
|
1652
1953
|
const id = newClientId();
|
|
@@ -1661,7 +1962,8 @@ const enqueueInner = (
|
|
|
1661
1962
|
ts: Date.now(),
|
|
1662
1963
|
id,
|
|
1663
1964
|
clientId: s.record.clientId,
|
|
1664
|
-
...(hold
|
|
1965
|
+
...(options.hold && { held: true as const }),
|
|
1966
|
+
...(options.localMeta !== undefined && { localMeta: options.localMeta }),
|
|
1665
1967
|
},
|
|
1666
1968
|
adopted: false,
|
|
1667
1969
|
by: s.store.owner?.id,
|
|
@@ -1673,19 +1975,25 @@ const enqueueInner = (
|
|
|
1673
1975
|
resolve,
|
|
1674
1976
|
reject,
|
|
1675
1977
|
};
|
|
1676
|
-
s.pending.push(entry);
|
|
1677
1978
|
if (!isEphemeral(s, protocol)) {
|
|
1678
1979
|
const lane = laneOf(s.record, protocol);
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1980
|
+
try {
|
|
1981
|
+
setRecord(
|
|
1982
|
+
s,
|
|
1983
|
+
withLane(
|
|
1984
|
+
s.record,
|
|
1985
|
+
protocol,
|
|
1986
|
+
withOutbox(lane, [...outboxOf(lane), toOutbox(entry)]),
|
|
1987
|
+
),
|
|
1988
|
+
);
|
|
1989
|
+
} catch (error) {
|
|
1990
|
+
if (!isQuota(error)) throw error;
|
|
1991
|
+
reject(new StatewireSendError(QUOTA));
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
s.pending.push(entry);
|
|
1687
1995
|
reorder(s, protocol);
|
|
1688
|
-
}
|
|
1996
|
+
} else s.pending.push(entry);
|
|
1689
1997
|
s.lifecycle?.onStart(entry.inv);
|
|
1690
1998
|
s.notify();
|
|
1691
1999
|
};
|
|
@@ -1698,33 +2006,26 @@ type Candidate = {
|
|
|
1698
2006
|
stored: StatewireClient.OutboxCommand;
|
|
1699
2007
|
};
|
|
1700
2008
|
|
|
1701
|
-
const views = new WeakMap<
|
|
1702
|
-
StatewireClient.OutboxCommand,
|
|
1703
|
-
StatewireClient.OutboxEntry
|
|
1704
|
-
>();
|
|
1705
|
-
|
|
1706
2009
|
const viewOf = (
|
|
2010
|
+
s: Session,
|
|
1707
2011
|
protocol: string,
|
|
1708
|
-
stored: StatewireClient.OutboxCommand,
|
|
2012
|
+
stored: StatewireClient.OutboxCommand | StatewireClient.SessionCommand,
|
|
2013
|
+
state?: "sending",
|
|
1709
2014
|
): StatewireClient.OutboxEntry => {
|
|
1710
|
-
const cached = views.get(stored);
|
|
2015
|
+
const cached = s.views.get(stored);
|
|
1711
2016
|
if (cached?.protocol === protocol) return cached;
|
|
1712
2017
|
const view: StatewireClient.OutboxEntry = {
|
|
1713
2018
|
id: stored.id,
|
|
1714
2019
|
protocol,
|
|
1715
|
-
command: {
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
stored.meta,
|
|
1723
|
-
) as unknown[]),
|
|
1724
|
-
},
|
|
1725
|
-
held: stored.hold === undefined ? null : { meta: stored.hold.meta },
|
|
2020
|
+
command: { method: stored.method, params: paramsOf(stored) },
|
|
2021
|
+
ts: stored.ts,
|
|
2022
|
+
state:
|
|
2023
|
+
state ??
|
|
2024
|
+
((stored as StatewireClient.OutboxCommand).held ? "held" : "queued"),
|
|
2025
|
+
localMeta: stored.localMeta,
|
|
2026
|
+
durable: s.store.durable !== false && !isEphemeral(s, protocol),
|
|
1726
2027
|
};
|
|
1727
|
-
views.set(stored, view);
|
|
2028
|
+
s.views.set(stored, view);
|
|
1728
2029
|
return view;
|
|
1729
2030
|
};
|
|
1730
2031
|
|
|
@@ -1751,7 +2052,7 @@ const bubble = (
|
|
|
1751
2052
|
const order: { stored: StatewireClient.OutboxCommand; blocked: boolean }[] =
|
|
1752
2053
|
[];
|
|
1753
2054
|
for (const stored of queue) {
|
|
1754
|
-
if (stored.
|
|
2055
|
+
if (stored.held) {
|
|
1755
2056
|
order.push({ stored, blocked: true });
|
|
1756
2057
|
continue;
|
|
1757
2058
|
}
|
|
@@ -1760,7 +2061,10 @@ const bubble = (
|
|
|
1760
2061
|
at > 0 &&
|
|
1761
2062
|
order[at - 1]!.blocked &&
|
|
1762
2063
|
may !== undefined &&
|
|
1763
|
-
may(
|
|
2064
|
+
may(
|
|
2065
|
+
viewOf(s, protocol, stored),
|
|
2066
|
+
viewOf(s, protocol, order[at - 1]!.stored),
|
|
2067
|
+
)
|
|
1764
2068
|
)
|
|
1765
2069
|
at--;
|
|
1766
2070
|
order.splice(at, 0, { stored, blocked: at > 0 && order[at - 1]!.blocked });
|
|
@@ -1796,6 +2100,16 @@ const flushCandidates = (s: Session): Candidate[] =>
|
|
|
1796
2100
|
return order.slice(0, free).map((stored) => ({ protocol, stored }));
|
|
1797
2101
|
});
|
|
1798
2102
|
|
|
2103
|
+
// An entry the record holds that a flush would stamp once attached, whether
|
|
2104
|
+
// or not this page made the send: inserted, or kept from an earlier mount or
|
|
2105
|
+
// another sharer. It dials a parked or idle transport as a send does.
|
|
2106
|
+
const queuedInRecord = (s: Session) =>
|
|
2107
|
+
Object.keys(s.record.lanes).some(
|
|
2108
|
+
(protocol) =>
|
|
2109
|
+
(s.selection ? negotiated(s, protocol) : offered(s, protocol)) &&
|
|
2110
|
+
bubble(s, protocol, laneQueue(s, protocol)).free > 0,
|
|
2111
|
+
);
|
|
2112
|
+
|
|
1799
2113
|
const reasonOf = (
|
|
1800
2114
|
s: Session,
|
|
1801
2115
|
stored: StatewireClient.OutboxCommand,
|
|
@@ -1811,6 +2125,34 @@ const reasonOf = (
|
|
|
1811
2125
|
|
|
1812
2126
|
const NO_IDS: ReadonlySet<string> = new Set();
|
|
1813
2127
|
|
|
2128
|
+
const patched = (
|
|
2129
|
+
stored: StatewireClient.OutboxCommand,
|
|
2130
|
+
patch: Patch,
|
|
2131
|
+
): StatewireClient.OutboxCommand => {
|
|
2132
|
+
const {
|
|
2133
|
+
held: _held,
|
|
2134
|
+
released: _released,
|
|
2135
|
+
localMeta: _meta,
|
|
2136
|
+
...rest
|
|
2137
|
+
} = stored;
|
|
2138
|
+
const held = patch.held ?? stored.held === true;
|
|
2139
|
+
const localMeta =
|
|
2140
|
+
patch.localMeta === undefined ? stored.localMeta : patch.localMeta.value;
|
|
2141
|
+
let next: StatewireClient.OutboxCommand = {
|
|
2142
|
+
...rest,
|
|
2143
|
+
...(held && { held: true as const }),
|
|
2144
|
+
...(!held &&
|
|
2145
|
+
(stored.held || stored.released) && { released: true as const }),
|
|
2146
|
+
...(localMeta !== undefined && { localMeta }),
|
|
2147
|
+
};
|
|
2148
|
+
if (patch.params !== undefined) {
|
|
2149
|
+
const { params, meta } = encodeParams(patch.params);
|
|
2150
|
+
const { meta: _old, ...plain } = next;
|
|
2151
|
+
next = { ...plain, params, ...(meta !== undefined && { meta }) };
|
|
2152
|
+
}
|
|
2153
|
+
return next;
|
|
2154
|
+
};
|
|
2155
|
+
|
|
1814
2156
|
// Seqs are allocated here, under the lock, against the attach's watermark:
|
|
1815
2157
|
// each outbox entry is stamped by exactly one sharer, in the bubbled order.
|
|
1816
2158
|
const flushInner = (s: Session, demoted: ReadonlySet<string> = NO_IDS) => {
|
|
@@ -1826,12 +2168,12 @@ const flushInner = (s: Session, demoted: ReadonlySet<string> = NO_IDS) => {
|
|
|
1826
2168
|
const reason = reasonOf(s, stored, demoted);
|
|
1827
2169
|
groups.set(reason, [
|
|
1828
2170
|
...(groups.get(reason) ?? []),
|
|
1829
|
-
viewOf(protocol, stored),
|
|
2171
|
+
viewOf(s, protocol, stored),
|
|
1830
2172
|
]);
|
|
1831
2173
|
}
|
|
1832
2174
|
const flushing: Flushing = {
|
|
1833
2175
|
ids: new Set(candidates.map((c) => c.stored.id)),
|
|
1834
|
-
|
|
2176
|
+
patches: new Map(),
|
|
1835
2177
|
removed: new Set(),
|
|
1836
2178
|
};
|
|
1837
2179
|
s.flushing = flushing;
|
|
@@ -1859,14 +2201,14 @@ const flushInner = (s: Session, demoted: ReadonlySet<string> = NO_IDS) => {
|
|
|
1859
2201
|
if (own) settleRemoved(s, own);
|
|
1860
2202
|
continue;
|
|
1861
2203
|
}
|
|
1862
|
-
const
|
|
1863
|
-
if (
|
|
2204
|
+
const patch = flushing.patches.get(stored.id);
|
|
2205
|
+
if (patch === undefined) {
|
|
1864
2206
|
queue.push(stored);
|
|
1865
2207
|
continue;
|
|
1866
2208
|
}
|
|
1867
|
-
const
|
|
1868
|
-
queue.push(
|
|
1869
|
-
if (own)
|
|
2209
|
+
const next = patched(stored, patch);
|
|
2210
|
+
queue.push(next);
|
|
2211
|
+
if (own) follow(s, own, next);
|
|
1870
2212
|
}
|
|
1871
2213
|
const { order, free } = bubble(s, protocol, queue);
|
|
1872
2214
|
let nextSeq = lane.nextSeq;
|
|
@@ -1885,7 +2227,7 @@ const flushInner = (s: Session, demoted: ReadonlySet<string> = NO_IDS) => {
|
|
|
1885
2227
|
}
|
|
1886
2228
|
const seq = Math.max(nextSeq, durableSeqOf(s, protocol) + 1);
|
|
1887
2229
|
nextSeq = seq + 1;
|
|
1888
|
-
const {
|
|
2230
|
+
const { held: _held, released: _released, ...command } = stored;
|
|
1889
2231
|
const stamped: StatewireClient.SessionCommand = {
|
|
1890
2232
|
...command,
|
|
1891
2233
|
seq,
|
|
@@ -1932,12 +2274,14 @@ export const outboxEntries = (
|
|
|
1932
2274
|
s: Session,
|
|
1933
2275
|
): readonly StatewireClient.OutboxEntry[] => {
|
|
1934
2276
|
const local = s.pending.filter(
|
|
1935
|
-
(p) =>
|
|
2277
|
+
(p) => isEphemeral(s, p.command.protocol) && !p.away,
|
|
1936
2278
|
);
|
|
1937
2279
|
const parts: unknown[] = [
|
|
1938
|
-
...Object.values(s.record.lanes).
|
|
1939
|
-
|
|
1940
|
-
|
|
2280
|
+
...Object.values(s.record.lanes).flatMap((lane) => [
|
|
2281
|
+
lane.commands,
|
|
2282
|
+
lane.outbox,
|
|
2283
|
+
]),
|
|
2284
|
+
...local.flatMap((p) => [p, p.seq, p.inv.held, p.inv.localMeta]),
|
|
1941
2285
|
];
|
|
1942
2286
|
const same =
|
|
1943
2287
|
parts.length === s.outboxParts.length &&
|
|
@@ -1945,130 +2289,342 @@ export const outboxEntries = (
|
|
|
1945
2289
|
if (!same) {
|
|
1946
2290
|
s.outboxParts = parts;
|
|
1947
2291
|
s.outboxList = [
|
|
1948
|
-
...Object.entries(s.record.lanes).flatMap(([protocol, lane]) =>
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
2292
|
+
...Object.entries(s.record.lanes).flatMap(([protocol, lane]) => [
|
|
2293
|
+
...[...lane.commands]
|
|
2294
|
+
.sort((a, b) => a.seq - b.seq)
|
|
2295
|
+
.map((c) => viewOf(s, protocol, c, "sending")),
|
|
2296
|
+
...outboxOf(lane).map((c) => viewOf(s, protocol, c)),
|
|
2297
|
+
]),
|
|
2298
|
+
...[...local]
|
|
2299
|
+
.sort((a, b) => (a.seq ?? Infinity) - (b.seq ?? Infinity))
|
|
2300
|
+
.map((p) =>
|
|
2301
|
+
isStamped(p)
|
|
2302
|
+
? viewOf(s, p.command.protocol, toStored(s, p), "sending")
|
|
2303
|
+
: viewOf(s, p.command.protocol, toOutbox(p)),
|
|
2304
|
+
),
|
|
1952
2305
|
];
|
|
1953
2306
|
}
|
|
1954
2307
|
return s.outboxList;
|
|
1955
2308
|
};
|
|
1956
2309
|
|
|
1957
2310
|
type OutboxOp =
|
|
1958
|
-
| { type: "
|
|
1959
|
-
| { type: "release" }
|
|
2311
|
+
| { type: "patch"; patch: (entry: StatewireClient.OutboxEntry) => Patch }
|
|
1960
2312
|
| { type: "remove" };
|
|
1961
2313
|
|
|
1962
|
-
|
|
2314
|
+
const entryOf = (s: Session, id: string) => {
|
|
2315
|
+
const at = locate(s, id);
|
|
2316
|
+
if (at === undefined) throw new Error(`outbox: no entry ${id}`);
|
|
2317
|
+
if (at.stored === undefined) throw new Error(`outbox: ${id} is in flight`);
|
|
2318
|
+
return { protocol: at.protocol, stored: at.stored };
|
|
2319
|
+
};
|
|
1963
2320
|
|
|
1964
|
-
const applyOutboxOp = (
|
|
1965
|
-
s
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
op: OutboxOp,
|
|
1969
|
-
): OutboxStatus => {
|
|
1970
|
-
const done: OutboxStatus =
|
|
1971
|
-
op.type === "hold"
|
|
1972
|
-
? "held"
|
|
1973
|
-
: op.type === "release"
|
|
1974
|
-
? "released"
|
|
1975
|
-
: "removed";
|
|
2321
|
+
const applyOutboxOp = (s: Session, id: string, op: OutboxOp) => {
|
|
2322
|
+
const at = locate(s, id);
|
|
2323
|
+
if (at?.stored === undefined) return;
|
|
2324
|
+
const { protocol, stored } = at;
|
|
1976
2325
|
const own = s.pending.find((p) => p.id === id && isOutboxed(p));
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
}),
|
|
1993
|
-
);
|
|
2326
|
+
if (op.type === "remove") {
|
|
2327
|
+
if (!isEphemeral(s, protocol)) {
|
|
2328
|
+
const lane = laneOf(s.record, protocol);
|
|
2329
|
+
setRecord(
|
|
2330
|
+
s,
|
|
2331
|
+
withLane(s.record, protocol, {
|
|
2332
|
+
...withOutbox(
|
|
2333
|
+
lane,
|
|
2334
|
+
outboxOf(lane).filter((c) => c.id !== id),
|
|
2335
|
+
),
|
|
2336
|
+
...marks(s, lane, [stored]),
|
|
2337
|
+
}),
|
|
2338
|
+
);
|
|
2339
|
+
}
|
|
2340
|
+
if (own) settleRemoved(s, own);
|
|
1994
2341
|
} else {
|
|
1995
|
-
const
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
withOutbox(
|
|
2008
|
-
lane,
|
|
2009
|
-
outboxOf(lane).map((c) => (c.id === id ? next : c)),
|
|
2342
|
+
const next = patched(stored, op.patch(viewOf(s, protocol, stored)));
|
|
2343
|
+
if (!isEphemeral(s, protocol)) {
|
|
2344
|
+
const lane = laneOf(s.record, protocol);
|
|
2345
|
+
setRecord(
|
|
2346
|
+
s,
|
|
2347
|
+
withLane(
|
|
2348
|
+
s.record,
|
|
2349
|
+
protocol,
|
|
2350
|
+
withOutbox(
|
|
2351
|
+
lane,
|
|
2352
|
+
outboxOf(lane).map((c) => (c.id === id ? next : c)),
|
|
2353
|
+
),
|
|
2010
2354
|
),
|
|
2011
|
-
)
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
if (own) {
|
|
2015
|
-
if (op.type === "remove") settleRemoved(s, own);
|
|
2016
|
-
else setHold(s, own, op.type === "hold" ? op.hold : undefined);
|
|
2355
|
+
);
|
|
2356
|
+
}
|
|
2357
|
+
if (own) follow(s, own, next);
|
|
2017
2358
|
}
|
|
2018
2359
|
reorder(s, protocol);
|
|
2019
2360
|
s.notify();
|
|
2020
|
-
return done;
|
|
2021
2361
|
};
|
|
2022
2362
|
|
|
2023
|
-
const
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2363
|
+
const toPatch = (patch: StatewireClient.OutboxPatch): Patch => {
|
|
2364
|
+
if (typeof patch !== "object" || patch === null)
|
|
2365
|
+
throw new Error("outbox: update must return a patch object");
|
|
2366
|
+
if (patch.held !== undefined && typeof patch.held !== "boolean")
|
|
2367
|
+
throw new Error("outbox: held must be a boolean");
|
|
2368
|
+
if (
|
|
2369
|
+
patch.command !== undefined &&
|
|
2370
|
+
(Object.keys(patch.command).some((k) => k !== "params") ||
|
|
2371
|
+
!Array.isArray(patch.command.params))
|
|
2372
|
+
)
|
|
2373
|
+
throw new Error("outbox: a patch may only replace command.params");
|
|
2374
|
+
return {
|
|
2375
|
+
...(patch.held !== undefined && { held: patch.held }),
|
|
2376
|
+
...("localMeta" in patch && { localMeta: { value: patch.localMeta } }),
|
|
2377
|
+
...(patch.command !== undefined && { params: patch.command.params }),
|
|
2378
|
+
};
|
|
2379
|
+
};
|
|
2380
|
+
|
|
2381
|
+
const outboxOp = (s: Session, id: string, op: OutboxOp): Promise<void> => {
|
|
2382
|
+
entryOf(s, id);
|
|
2028
2383
|
const flushing = s.flushing;
|
|
2029
|
-
if (flushing?.ids.has(
|
|
2030
|
-
if (op.type === "remove") flushing.removed.add(
|
|
2031
|
-
else
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
? "released"
|
|
2038
|
-
: "removed",
|
|
2039
|
-
);
|
|
2384
|
+
if (flushing?.ids.has(id)) {
|
|
2385
|
+
if (op.type === "remove") flushing.removed.add(id);
|
|
2386
|
+
else {
|
|
2387
|
+
const at = entryOf(s, id);
|
|
2388
|
+
const patch = op.patch(viewOf(s, at.protocol, at.stored));
|
|
2389
|
+
flushing.patches.set(id, { ...flushing.patches.get(id), ...patch });
|
|
2390
|
+
}
|
|
2391
|
+
return Promise.resolve();
|
|
2040
2392
|
}
|
|
2041
|
-
|
|
2393
|
+
const done = () => {
|
|
2394
|
+
wake(s);
|
|
2395
|
+
void pump(s);
|
|
2396
|
+
};
|
|
2397
|
+
if (!s.shared) {
|
|
2398
|
+
applyOutboxOp(s, id, op);
|
|
2399
|
+
done();
|
|
2400
|
+
return Promise.resolve();
|
|
2401
|
+
}
|
|
2402
|
+
return transact(s, () => applyOutboxOp(s, id, op)).then(done);
|
|
2403
|
+
};
|
|
2404
|
+
|
|
2405
|
+
const background = (promise: Promise<void>) => {
|
|
2406
|
+
promise.catch(() => {});
|
|
2407
|
+
};
|
|
2408
|
+
|
|
2409
|
+
export const holdOutbox = (s: Session, id: string) =>
|
|
2410
|
+
background(outboxOp(s, id, { type: "patch", patch: () => ({ held: true }) }));
|
|
2411
|
+
|
|
2412
|
+
export const releaseOutbox = (s: Session, id: string): Promise<unknown> => {
|
|
2413
|
+
entryOf(s, id);
|
|
2414
|
+
const result = new Promise<unknown>((resolve, reject) => {
|
|
2415
|
+
const waiters = s.releases.get(id) ?? [];
|
|
2416
|
+
waiters.push({ resolve, reject });
|
|
2417
|
+
s.releases.set(id, waiters);
|
|
2418
|
+
});
|
|
2419
|
+
result.catch(() => {});
|
|
2420
|
+
outboxOp(s, id, { type: "patch", patch: () => ({ held: false }) }).catch(
|
|
2421
|
+
(error: unknown) => settleWaiters(s, id, error),
|
|
2422
|
+
);
|
|
2423
|
+
return result;
|
|
2424
|
+
};
|
|
2425
|
+
|
|
2426
|
+
const settleWaiters = (s: Session, id: string, error: unknown) => {
|
|
2427
|
+
const waiters = s.releases.get(id) ?? [];
|
|
2428
|
+
s.releases.delete(id);
|
|
2429
|
+
for (const waiter of waiters) waiter.reject(error);
|
|
2430
|
+
};
|
|
2431
|
+
|
|
2432
|
+
export const removeOutbox = (s: Session, id: string) =>
|
|
2433
|
+
background(outboxOp(s, id, { type: "remove" }));
|
|
2434
|
+
|
|
2435
|
+
export const updateOutbox = (
|
|
2436
|
+
s: Session,
|
|
2437
|
+
id: string,
|
|
2438
|
+
fn: (entry: StatewireClient.OutboxEntry) => StatewireClient.OutboxPatch,
|
|
2439
|
+
) =>
|
|
2440
|
+
background(
|
|
2441
|
+
outboxOp(s, id, { type: "patch", patch: (entry) => toPatch(fn(entry)) }),
|
|
2442
|
+
);
|
|
2443
|
+
|
|
2444
|
+
const producedOf = (
|
|
2445
|
+
s: Session,
|
|
2446
|
+
): NonNullable<StatewireClient.SessionCommand["produced"]> => ({
|
|
2447
|
+
wire: s.options.wireVersion ?? { version: WIRE_VERSION },
|
|
2448
|
+
protocols:
|
|
2449
|
+
protocolOffers(s.options) === "*" && s.selection
|
|
2450
|
+
? s.selection.protocols
|
|
2451
|
+
: protocolOffers(s.options),
|
|
2452
|
+
});
|
|
2453
|
+
|
|
2454
|
+
const placed = (
|
|
2455
|
+
queue: readonly StatewireClient.OutboxCommand[],
|
|
2456
|
+
entry: StatewireClient.OutboxCommand,
|
|
2457
|
+
{ after, before }: { after?: string; before?: string },
|
|
2458
|
+
) => {
|
|
2459
|
+
const anchor = after ?? before;
|
|
2460
|
+
if (anchor === undefined) return [...queue, entry];
|
|
2461
|
+
const index = queue.findIndex((c) => c.id === anchor);
|
|
2462
|
+
if (index === -1) return [...queue, entry];
|
|
2463
|
+
const at = after !== undefined ? index + 1 : index;
|
|
2464
|
+
return [...queue.slice(0, at), entry, ...queue.slice(at)];
|
|
2465
|
+
};
|
|
2466
|
+
|
|
2467
|
+
export const insertOutbox = (
|
|
2468
|
+
s: Session,
|
|
2469
|
+
entry: StatewireClient.OutboxInsert,
|
|
2470
|
+
): string => {
|
|
2471
|
+
if (!s.hydrated) throw new Error("outbox: storage is not hydrated yet");
|
|
2472
|
+
if (s.storageError !== undefined) throw s.storageError;
|
|
2473
|
+
if (entry.after !== undefined && entry.before !== undefined)
|
|
2474
|
+
throw new Error("outbox: after and before are exclusive");
|
|
2475
|
+
for (const anchor of [entry.after, entry.before])
|
|
2476
|
+
if (anchor !== undefined) entryOf(s, anchor);
|
|
2477
|
+
const { protocol } = entry;
|
|
2478
|
+
if (!(s.selection ? negotiated(s, protocol) : offered(s, protocol)))
|
|
2479
|
+
throw new Error(`statewire: protocol "${protocol}" is not negotiated`);
|
|
2480
|
+
const { params, meta } = encodeParams(entry.command.params);
|
|
2481
|
+
const id = entry.from === undefined ? newClientId() : `${entry.from}~`;
|
|
2482
|
+
const stored: StatewireClient.OutboxCommand = {
|
|
2483
|
+
id,
|
|
2484
|
+
produced: producedOf(s),
|
|
2485
|
+
method: entry.command.method,
|
|
2486
|
+
params,
|
|
2487
|
+
...(meta !== undefined && { meta }),
|
|
2488
|
+
ts: Date.now(),
|
|
2489
|
+
...(entry.held && { held: true as const }),
|
|
2490
|
+
...(entry.localMeta !== undefined && { localMeta: entry.localMeta }),
|
|
2491
|
+
...(s.store.owner !== undefined && { by: s.store.owner.id }),
|
|
2492
|
+
};
|
|
2042
2493
|
const run = () => {
|
|
2043
|
-
|
|
2494
|
+
if (isEphemeral(s, protocol)) {
|
|
2495
|
+
enqueueInner(
|
|
2496
|
+
s,
|
|
2497
|
+
{
|
|
2498
|
+
protocol,
|
|
2499
|
+
produced: stored.produced!,
|
|
2500
|
+
method: stored.method,
|
|
2501
|
+
params,
|
|
2502
|
+
...(meta !== undefined && { meta }),
|
|
2503
|
+
},
|
|
2504
|
+
entry.command.params,
|
|
2505
|
+
() => {},
|
|
2506
|
+
() => {},
|
|
2507
|
+
{
|
|
2508
|
+
...(entry.held && { hold: true }),
|
|
2509
|
+
...(entry.localMeta !== undefined && { localMeta: entry.localMeta }),
|
|
2510
|
+
},
|
|
2511
|
+
);
|
|
2512
|
+
return;
|
|
2513
|
+
}
|
|
2514
|
+
let base = s.record;
|
|
2515
|
+
if (entry.from !== undefined) {
|
|
2516
|
+
// Re-inserting a settled update consumes it in the same write: a
|
|
2517
|
+
// redelivery finds it gone.
|
|
2518
|
+
const source = Object.entries(base.lanes).find(([, l]) =>
|
|
2519
|
+
l.inbox?.some((n) => n.id === entry.from),
|
|
2520
|
+
);
|
|
2521
|
+
if (source === undefined || locate(s, id) !== undefined) return;
|
|
2522
|
+
base = withLane(base, source[0], withoutNotice(source[1], entry.from));
|
|
2523
|
+
}
|
|
2524
|
+
const lane = laneOf(base, protocol);
|
|
2525
|
+
try {
|
|
2526
|
+
setRecord(
|
|
2527
|
+
s,
|
|
2528
|
+
withLane(
|
|
2529
|
+
base,
|
|
2530
|
+
protocol,
|
|
2531
|
+
withOutbox(lane, placed(outboxOf(lane), stored, entry)),
|
|
2532
|
+
),
|
|
2533
|
+
);
|
|
2534
|
+
} catch (error) {
|
|
2535
|
+
if (!isQuota(error)) throw error;
|
|
2536
|
+
// The update stays unacked for the next mount.
|
|
2537
|
+
if (entry.from !== undefined) s.skipped.add(entry.from);
|
|
2538
|
+
settleReleases(s, id, { ok: false, failure: QUOTA });
|
|
2539
|
+
return;
|
|
2540
|
+
}
|
|
2541
|
+
s.mountIds.delete(id);
|
|
2542
|
+
reorder(s, protocol);
|
|
2543
|
+
s.notify();
|
|
2044
2544
|
};
|
|
2045
2545
|
const done = () => {
|
|
2046
2546
|
wake(s);
|
|
2047
2547
|
void pump(s);
|
|
2048
|
-
return status;
|
|
2049
2548
|
};
|
|
2050
2549
|
if (!s.shared) {
|
|
2051
2550
|
run();
|
|
2052
|
-
|
|
2551
|
+
done();
|
|
2552
|
+
} else background(transact(s, run).then(done));
|
|
2553
|
+
return id;
|
|
2554
|
+
};
|
|
2555
|
+
|
|
2556
|
+
// One sharer at a time runs the handler; the update leaves the record only
|
|
2557
|
+
// once it resolved, so a crash mid-handler redelivers it.
|
|
2558
|
+
const nextNotice = (s: Session) => {
|
|
2559
|
+
for (const [protocol, lane] of Object.entries(s.record.lanes)) {
|
|
2560
|
+
const found = (lane.inbox ?? []).find((n) => !s.skipped.has(n.id));
|
|
2561
|
+
if (found) return { protocol, found };
|
|
2053
2562
|
}
|
|
2054
|
-
return
|
|
2563
|
+
return undefined;
|
|
2055
2564
|
};
|
|
2056
2565
|
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
) =>
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
}) as Promise<"held" | "stamped">;
|
|
2566
|
+
const withoutNotice = (
|
|
2567
|
+
lane: StatewireClient.SessionLane,
|
|
2568
|
+
id: string,
|
|
2569
|
+
): StatewireClient.SessionLane => {
|
|
2570
|
+
const inbox = (lane.inbox ?? []).filter((n) => n.id !== id);
|
|
2571
|
+
const { inbox: _inbox, ...rest } = lane;
|
|
2572
|
+
return inbox.length > 0 ? { ...rest, inbox } : rest;
|
|
2573
|
+
};
|
|
2066
2574
|
|
|
2067
|
-
|
|
2068
|
-
|
|
2575
|
+
const ack = (s: Session, protocol: string, id: string) => {
|
|
2576
|
+
const lane = laneOf(s.record, protocol);
|
|
2577
|
+
if (s.skipped.has(id) || !lane.inbox?.some((n) => n.id === id)) return;
|
|
2578
|
+
setRecord(s, withLane(s.record, protocol, withoutNotice(lane, id)));
|
|
2579
|
+
};
|
|
2069
2580
|
|
|
2070
|
-
|
|
2071
|
-
|
|
2581
|
+
const deliver = (s: Session) => {
|
|
2582
|
+
const handler = s.onSettled;
|
|
2583
|
+
// Handlers get ctx.state: delivery waits for the first hello.
|
|
2584
|
+
if (
|
|
2585
|
+
handler === undefined ||
|
|
2586
|
+
s.delivering ||
|
|
2587
|
+
!s.running ||
|
|
2588
|
+
!s.hydrated ||
|
|
2589
|
+
!s.sessionKnown
|
|
2590
|
+
)
|
|
2591
|
+
return;
|
|
2592
|
+
if (nextNotice(s) === undefined) return;
|
|
2593
|
+
s.delivering = true;
|
|
2594
|
+
const run = async () => {
|
|
2595
|
+
for (;;) {
|
|
2596
|
+
if (s.shared) await transact(s, () => {});
|
|
2597
|
+
else await null;
|
|
2598
|
+
const next = nextNotice(s);
|
|
2599
|
+
if (next === undefined || !s.running) return;
|
|
2600
|
+
const { protocol, found } = next;
|
|
2601
|
+
try {
|
|
2602
|
+
await handler(updateOf(protocol, found));
|
|
2603
|
+
} catch (error) {
|
|
2604
|
+
console.error(
|
|
2605
|
+
"statewire: onCommandChange threw on a settled update; it stays for the next mount",
|
|
2606
|
+
error,
|
|
2607
|
+
);
|
|
2608
|
+
s.skipped.add(found.id);
|
|
2609
|
+
continue;
|
|
2610
|
+
}
|
|
2611
|
+
if (s.shared) await transact(s, () => ack(s, protocol, found.id));
|
|
2612
|
+
else ack(s, protocol, found.id);
|
|
2613
|
+
s.notify();
|
|
2614
|
+
}
|
|
2615
|
+
};
|
|
2616
|
+
const locked = s.store.notifyLock ? s.store.notifyLock(run) : run();
|
|
2617
|
+
locked.then(
|
|
2618
|
+
() => {
|
|
2619
|
+
s.delivering = false;
|
|
2620
|
+
deliver(s);
|
|
2621
|
+
},
|
|
2622
|
+
(error: unknown) => {
|
|
2623
|
+
s.delivering = false;
|
|
2624
|
+
console.error("statewire: settled update delivery stopped", error);
|
|
2625
|
+
},
|
|
2626
|
+
);
|
|
2627
|
+
};
|
|
2072
2628
|
|
|
2073
2629
|
const enqueue = (
|
|
2074
2630
|
s: Session,
|
|
@@ -2076,10 +2632,10 @@ const enqueue = (
|
|
|
2076
2632
|
params: readonly unknown[],
|
|
2077
2633
|
resolve: (payload: unknown) => void,
|
|
2078
2634
|
reject: (error: unknown) => void,
|
|
2079
|
-
|
|
2635
|
+
options?: EnqueueOptions,
|
|
2080
2636
|
) => {
|
|
2081
2637
|
if (!s.shared) {
|
|
2082
|
-
enqueueInner(s, command, params, resolve, reject,
|
|
2638
|
+
enqueueInner(s, command, params, resolve, reject, options);
|
|
2083
2639
|
wake(s);
|
|
2084
2640
|
void pump(s);
|
|
2085
2641
|
return;
|
|
@@ -2091,7 +2647,7 @@ const enqueue = (
|
|
|
2091
2647
|
reject(new StatewireSendError({ fate: "aborted", cause: "stream-gone" }));
|
|
2092
2648
|
return;
|
|
2093
2649
|
}
|
|
2094
|
-
enqueueInner(s, command, params, resolve, reject,
|
|
2650
|
+
enqueueInner(s, command, params, resolve, reject, options);
|
|
2095
2651
|
}).then(() => {
|
|
2096
2652
|
wake(s);
|
|
2097
2653
|
void pump(s);
|
|
@@ -2115,6 +2671,8 @@ const sendCommand = (
|
|
|
2115
2671
|
: offered(s, protocol);
|
|
2116
2672
|
if (!negotiated)
|
|
2117
2673
|
throw new Error(`statewire: protocol "${protocol}" is not negotiated`);
|
|
2674
|
+
if (options?.hold !== undefined && typeof options.hold !== "boolean")
|
|
2675
|
+
throw new Error("statewire: invoke hold must be a boolean");
|
|
2118
2676
|
if (!s.hydrated) {
|
|
2119
2677
|
const result = s.ready!.then(() =>
|
|
2120
2678
|
sendCommand(s, method, params, protocol, options),
|
|
@@ -2139,13 +2697,7 @@ const sendCommand = (
|
|
|
2139
2697
|
s,
|
|
2140
2698
|
{
|
|
2141
2699
|
protocol,
|
|
2142
|
-
produced:
|
|
2143
|
-
wire: s.options.wireVersion ?? { version: WIRE_VERSION },
|
|
2144
|
-
protocols:
|
|
2145
|
-
protocolOffers(s.options) === "*" && s.selection
|
|
2146
|
-
? s.selection.protocols
|
|
2147
|
-
: protocolOffers(s.options),
|
|
2148
|
-
},
|
|
2700
|
+
produced: producedOf(s),
|
|
2149
2701
|
method,
|
|
2150
2702
|
params: encoded,
|
|
2151
2703
|
...(meta !== undefined && { meta }),
|
|
@@ -2153,120 +2705,18 @@ const sendCommand = (
|
|
|
2153
2705
|
params,
|
|
2154
2706
|
resolve,
|
|
2155
2707
|
reject,
|
|
2156
|
-
|
|
2708
|
+
{
|
|
2709
|
+
...(options?.hold && { hold: true }),
|
|
2710
|
+
...(options?.localMeta !== undefined && {
|
|
2711
|
+
localMeta: options.localMeta,
|
|
2712
|
+
}),
|
|
2713
|
+
},
|
|
2157
2714
|
);
|
|
2158
2715
|
});
|
|
2159
2716
|
result.catch(() => {});
|
|
2160
2717
|
return result;
|
|
2161
2718
|
};
|
|
2162
2719
|
|
|
2163
|
-
const findFailed = (
|
|
2164
|
-
s: Session,
|
|
2165
|
-
entry: StatewireClient.FailedCommand,
|
|
2166
|
-
): StatewireClient.FailedCommand | undefined =>
|
|
2167
|
-
(laneOf(s.record, entry.protocol).failed ?? []).find(
|
|
2168
|
-
(f) =>
|
|
2169
|
-
f.seq === entry.seq && f.ts === entry.ts && f.command === entry.command,
|
|
2170
|
-
);
|
|
2171
|
-
|
|
2172
|
-
const unknownFailed = (entry: StatewireClient.FailedCommand) =>
|
|
2173
|
-
new Error(`statewire: unknown failed command "${entry.command}"`);
|
|
2174
|
-
|
|
2175
|
-
const takeFailed = (
|
|
2176
|
-
s: Session,
|
|
2177
|
-
entry: StatewireClient.FailedCommand,
|
|
2178
|
-
): StatewireClient.FailedCommand => {
|
|
2179
|
-
const lane = laneOf(s.record, entry.protocol);
|
|
2180
|
-
const found = findFailed(s, entry);
|
|
2181
|
-
if (found === undefined) throw unknownFailed(entry);
|
|
2182
|
-
setRecord(
|
|
2183
|
-
s,
|
|
2184
|
-
withLane(s.record, entry.protocol, {
|
|
2185
|
-
...lane,
|
|
2186
|
-
failed: (lane.failed ?? []).filter((f) => f !== found),
|
|
2187
|
-
}),
|
|
2188
|
-
);
|
|
2189
|
-
s.notify();
|
|
2190
|
-
return found;
|
|
2191
|
-
};
|
|
2192
|
-
|
|
2193
|
-
export const discardFailed = (
|
|
2194
|
-
s: Session,
|
|
2195
|
-
entry: StatewireClient.FailedCommand,
|
|
2196
|
-
): void => {
|
|
2197
|
-
if (!s.shared) {
|
|
2198
|
-
takeFailed(s, entry);
|
|
2199
|
-
return;
|
|
2200
|
-
}
|
|
2201
|
-
if (findFailed(s, entry) === undefined) throw unknownFailed(entry);
|
|
2202
|
-
transactBackground(s, () => {
|
|
2203
|
-
if (findFailed(s, entry) !== undefined) takeFailed(s, entry);
|
|
2204
|
-
});
|
|
2205
|
-
};
|
|
2206
|
-
|
|
2207
|
-
export const retryFailed = (
|
|
2208
|
-
s: Session,
|
|
2209
|
-
entry: StatewireClient.FailedCommand,
|
|
2210
|
-
): Promise<unknown> => {
|
|
2211
|
-
if (!s.running) {
|
|
2212
|
-
return rejectedSend({ fate: "aborted", cause: "unmount" });
|
|
2213
|
-
}
|
|
2214
|
-
if (isFinished(s)) {
|
|
2215
|
-
return rejectedSend({ fate: "aborted", cause: "stream-gone" });
|
|
2216
|
-
}
|
|
2217
|
-
const found = findFailed(s, entry);
|
|
2218
|
-
if (found === undefined) throw unknownFailed(entry);
|
|
2219
|
-
const params =
|
|
2220
|
-
found.meta === undefined
|
|
2221
|
-
? found.params
|
|
2222
|
-
: (reviveValue(structuredClone(found.params), found.meta) as unknown[]);
|
|
2223
|
-
const result = new Promise<unknown>((resolve, reject) => {
|
|
2224
|
-
const retry = () => {
|
|
2225
|
-
if (!s.running) {
|
|
2226
|
-
reject(new StatewireSendError({ fate: "aborted", cause: "unmount" }));
|
|
2227
|
-
return;
|
|
2228
|
-
}
|
|
2229
|
-
if (isFinished(s)) {
|
|
2230
|
-
reject(
|
|
2231
|
-
new StatewireSendError({ fate: "aborted", cause: "stream-gone" }),
|
|
2232
|
-
);
|
|
2233
|
-
return;
|
|
2234
|
-
}
|
|
2235
|
-
const current = findFailed(s, entry);
|
|
2236
|
-
if (current === undefined) {
|
|
2237
|
-
reject(unknownFailed(entry));
|
|
2238
|
-
return;
|
|
2239
|
-
}
|
|
2240
|
-
takeFailed(s, current);
|
|
2241
|
-
enqueueInner(
|
|
2242
|
-
s,
|
|
2243
|
-
{
|
|
2244
|
-
protocol: current.protocol,
|
|
2245
|
-
...(current.produced !== undefined && { produced: current.produced }),
|
|
2246
|
-
method: current.command,
|
|
2247
|
-
params: [...current.params],
|
|
2248
|
-
...(current.meta !== undefined && { meta: current.meta }),
|
|
2249
|
-
},
|
|
2250
|
-
params,
|
|
2251
|
-
resolve,
|
|
2252
|
-
reject,
|
|
2253
|
-
);
|
|
2254
|
-
};
|
|
2255
|
-
if (!s.shared) {
|
|
2256
|
-
retry();
|
|
2257
|
-
wake(s);
|
|
2258
|
-
void pump(s);
|
|
2259
|
-
return;
|
|
2260
|
-
}
|
|
2261
|
-
void transact(s, retry).then(() => {
|
|
2262
|
-
wake(s);
|
|
2263
|
-
void pump(s);
|
|
2264
|
-
}, reject);
|
|
2265
|
-
});
|
|
2266
|
-
result.catch(() => {});
|
|
2267
|
-
return result;
|
|
2268
|
-
};
|
|
2269
|
-
|
|
2270
2720
|
export const documentSource = (s: Session): StatewireDocuments.Source => ({
|
|
2271
2721
|
get: (protocol, key, prefix = s.options.keyPrefixes?.[protocol]) => {
|
|
2272
2722
|
const mounted = findDocument(s.documents, protocol, key, prefix);
|
|
@@ -2373,7 +2823,7 @@ const loadSessionRecord = (
|
|
|
2373
2823
|
const record =
|
|
2374
2824
|
stored === null
|
|
2375
2825
|
? { clientId: newClientId(), lanes: {} }
|
|
2376
|
-
:
|
|
2826
|
+
: boundInbox(store, stored);
|
|
2377
2827
|
if (record !== stored) store.save(record);
|
|
2378
2828
|
return record;
|
|
2379
2829
|
};
|
|
@@ -2417,10 +2867,11 @@ export const useStatewireSession = (
|
|
|
2417
2867
|
const store = options.sessionStore ?? mounted ?? defaultStore;
|
|
2418
2868
|
if (
|
|
2419
2869
|
(store.lock === undefined) !== (store.subscribe === undefined) ||
|
|
2420
|
-
(store.lock === undefined) !== (store.owner === undefined)
|
|
2870
|
+
(store.lock === undefined) !== (store.owner === undefined) ||
|
|
2871
|
+
(store.lock === undefined) !== (store.notifyLock === undefined)
|
|
2421
2872
|
) {
|
|
2422
2873
|
throw new Error(
|
|
2423
|
-
"statewire: session storage must provide lock, subscribe and
|
|
2874
|
+
"statewire: session storage must provide lock, subscribe, owner and notifyLock together",
|
|
2424
2875
|
);
|
|
2425
2876
|
}
|
|
2426
2877
|
const [s] = useState((): Session => {
|
|
@@ -2468,6 +2919,9 @@ export const useStatewireSession = (
|
|
|
2468
2919
|
txq: Promise.resolve(),
|
|
2469
2920
|
syncQueued: false,
|
|
2470
2921
|
parked: [],
|
|
2922
|
+
settled: new Set(),
|
|
2923
|
+
notices: [],
|
|
2924
|
+
inTx: false,
|
|
2471
2925
|
unsubscribe: null,
|
|
2472
2926
|
watched: new Set(),
|
|
2473
2927
|
abort: null,
|
|
@@ -2481,6 +2935,12 @@ export const useStatewireSession = (
|
|
|
2481
2935
|
resumeAt: 0,
|
|
2482
2936
|
idle: false,
|
|
2483
2937
|
onSessionLost: undefined,
|
|
2938
|
+
onDeprecatedPath: undefined,
|
|
2939
|
+
onSettled: undefined,
|
|
2940
|
+
releases: new Map(),
|
|
2941
|
+
delivering: false,
|
|
2942
|
+
skipped: new Set(),
|
|
2943
|
+
views: new WeakMap(),
|
|
2484
2944
|
onFlush: undefined,
|
|
2485
2945
|
onDone: undefined,
|
|
2486
2946
|
mountIds: storedIds(record),
|
|
@@ -2490,9 +2950,9 @@ export const useStatewireSession = (
|
|
|
2490
2950
|
lanesOnAttach: false,
|
|
2491
2951
|
attachEntries: new Set(),
|
|
2492
2952
|
frameFailures: 0,
|
|
2953
|
+
recheck: undefined,
|
|
2954
|
+
rechecks: { watermarks: "", count: 0 },
|
|
2493
2955
|
pending: [],
|
|
2494
|
-
failedList: [],
|
|
2495
|
-
failedParts: [],
|
|
2496
2956
|
canonical: undefined,
|
|
2497
2957
|
documentListeners: new Set(),
|
|
2498
2958
|
documents: { mode: null, documents: new Map() },
|
|
@@ -2559,6 +3019,8 @@ export const useStatewireSession = (
|
|
|
2559
3019
|
s.store = store;
|
|
2560
3020
|
s.documentStore = documentStore;
|
|
2561
3021
|
s.onSessionLost = options.onSessionLost;
|
|
3022
|
+
s.onDeprecatedPath = options.onDeprecatedPath;
|
|
3023
|
+
s.onSettled = options.onSettled;
|
|
2562
3024
|
s.onFlush = options.onFlush;
|
|
2563
3025
|
s.onDone = options.onDone;
|
|
2564
3026
|
const keyPrefixes = JSON.stringify(options.keyPrefixes);
|