@mostra/react 0.0.0-stage → 0.1.0
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/LICENSE +202 -0
- package/README.md +16 -2
- package/dist/form.d.ts +20 -0
- package/dist/form.d.ts.map +1 -0
- package/dist/form.js +259 -0
- package/dist/form.js.map +1 -0
- package/dist/hooks-Dh_l8KqJ.js +2737 -0
- package/dist/hooks-Dh_l8KqJ.js.map +1 -0
- package/dist/index.d.ts +412 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +199 -0
- package/dist/index.js.map +1 -0
- package/dist/mount-Bpg0M5ln.js +516 -0
- package/dist/mount-Bpg0M5ln.js.map +1 -0
- package/dist/router.d.ts +28 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +52 -0
- package/dist/router.js.map +1 -0
- package/package.json +117 -3
- package/src/form.ts +326 -0
- package/src/hooks.ts +530 -0
- package/src/index.ts +75 -0
- package/src/mount.ts +78 -0
- package/src/router.ts +84 -0
- package/src/runtime/boot.ts +306 -0
- package/src/runtime/bridge-limits.ts +30 -0
- package/src/runtime/errors.ts +53 -0
- package/src/runtime/journal.ts +219 -0
- package/src/runtime/server-client.ts +745 -0
- package/src/runtime/store.ts +1641 -0
- package/src/runtime/transport.ts +342 -0
- package/src/runtime/verify-probe.ts +67 -0
- package/src/server-hooks.ts +291 -0
- package/src/server-reference.ts +54 -0
- package/src/shell-bridge.ts +212 -0
- package/src/storage.ts +165 -0
|
@@ -0,0 +1,1641 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client runtime's store (INTERFACES §2.3; D22, D52, D110; IN-01 to IN-04): one per page. It
|
|
3
|
+
* holds what the gateway says each field is, the viewer's edits it hasn't confirmed, and the
|
|
4
|
+
* mounted fields, and hooks read it through `useSyncExternalStore`.
|
|
5
|
+
*
|
|
6
|
+
* - **Edits** show at once, are journaled in IndexedDB at once, and are sent after a short
|
|
7
|
+
* debounce, or at once on blur, when the page is hidden or closed, and before a submit (IN-02).
|
|
8
|
+
* - **Bases.** Each edit remembers the revision the field had when the viewer made it, so the
|
|
9
|
+
* gateway turns an edit made over a change the viewer never saw into a conflict, never a silent
|
|
10
|
+
* overwrite (D22). The page's own answers move a field's revision; another writer's change moves
|
|
11
|
+
* it only when the event stream delivers it, in order.
|
|
12
|
+
* - **Conflicts** keep both values: the viewer's (mine) stays shown until they keep it or take
|
|
13
|
+
* theirs (FAILURE_MODEL §5).
|
|
14
|
+
* - **Offline**, mutations stay in the journal with their IDs and replay in order (IN-04); a tab
|
|
15
|
+
* that closed with unsent edits leaves them for the next page of the artifact to adopt.
|
|
16
|
+
* - **Tabs** converge through the event stream; a BroadcastChannel only shortens the delay.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
type Actor,
|
|
20
|
+
type EventRecord,
|
|
21
|
+
type FieldConflict,
|
|
22
|
+
type FieldOp,
|
|
23
|
+
type FieldScope,
|
|
24
|
+
isFieldId,
|
|
25
|
+
MutationId,
|
|
26
|
+
type MutationResult,
|
|
27
|
+
SIGNAL_NAME_PATTERN,
|
|
28
|
+
type StreamSnapshot,
|
|
29
|
+
type SubmissionSummary,
|
|
30
|
+
type SubmitBlocker,
|
|
31
|
+
type ViewContext,
|
|
32
|
+
type Viewer,
|
|
33
|
+
} from "@mostra/protocol";
|
|
34
|
+
import { MostraError, targetUnavailable } from "./errors.ts";
|
|
35
|
+
import type { Journal, JournalEntry, MutationEntry, PendingEntry } from "./journal.ts";
|
|
36
|
+
import { ServerClient } from "./server-client.ts";
|
|
37
|
+
import { NetworkError, type Transport } from "./transport.ts";
|
|
38
|
+
|
|
39
|
+
/** How long the page waits after the last edit before it saves (INTERFACES §2.3). */
|
|
40
|
+
export const SAVE_DEBOUNCE_MS = 400;
|
|
41
|
+
|
|
42
|
+
/** How long a submit waits for the event stream to reach the page's own last save. */
|
|
43
|
+
const CATCH_UP_MS = 5_000;
|
|
44
|
+
|
|
45
|
+
/** How soon waiting saves try again once the event stream shows the gateway answers. */
|
|
46
|
+
const RETRY_SOON_MS = 250;
|
|
47
|
+
|
|
48
|
+
/** The most ops one mutation carries (the browser protocol's limit). */
|
|
49
|
+
const OPS_PER_MUTATION = 1000;
|
|
50
|
+
|
|
51
|
+
/** A field's key in the store: its scope and ID. */
|
|
52
|
+
export type FieldKey = `${FieldScope}:${string}`;
|
|
53
|
+
|
|
54
|
+
/** A field's key. */
|
|
55
|
+
export const fieldKey = (scope: FieldScope, id: string): FieldKey => `${scope}:${id}`;
|
|
56
|
+
|
|
57
|
+
/** What the gateway says a field holds. */
|
|
58
|
+
interface ServerField {
|
|
59
|
+
readonly value: unknown;
|
|
60
|
+
/** The revision that last wrote it. */
|
|
61
|
+
readonly revision: number;
|
|
62
|
+
readonly deleted: boolean;
|
|
63
|
+
readonly updatedBy?: Actor;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A conflict the viewer hasn't resolved: what the field holds now. */
|
|
67
|
+
export interface OpenConflict {
|
|
68
|
+
readonly theirs: unknown;
|
|
69
|
+
readonly deleted: boolean;
|
|
70
|
+
/** The revision that wrote theirs: keeping mine writes over it. */
|
|
71
|
+
readonly revision: number;
|
|
72
|
+
readonly updatedBy?: Actor;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A mounted field, as `useField` declares it. */
|
|
76
|
+
export interface Registration {
|
|
77
|
+
readonly id: string;
|
|
78
|
+
readonly scope: FieldScope;
|
|
79
|
+
readonly initial: unknown;
|
|
80
|
+
readonly label?: string;
|
|
81
|
+
readonly required?: boolean;
|
|
82
|
+
readonly sensitive?: boolean;
|
|
83
|
+
/** The validation message of its current value, if any. */
|
|
84
|
+
readonly error?: string;
|
|
85
|
+
/** The hook that named it, for `verify`'s field-ID checks (D138): `useField` unless stated. */
|
|
86
|
+
readonly hook?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The page's save state (D47): what the shell's rail and `FieldMeta.save` show. */
|
|
90
|
+
export type PageSave = "saved" | "saving" | "offline" | "conflict";
|
|
91
|
+
|
|
92
|
+
/** Where the runtime runs and what it may do. */
|
|
93
|
+
export type RuntimeMode =
|
|
94
|
+
/** Behind Mostra's gateway in a shell's frame: everything works. */
|
|
95
|
+
| "live"
|
|
96
|
+
/** No gateway (a static export, a page opened outside its shell): values stay in this browser. */
|
|
97
|
+
| "local";
|
|
98
|
+
|
|
99
|
+
/** The submission state `useSubmit` reports (INTERFACES §2.3). */
|
|
100
|
+
export type SubmitState =
|
|
101
|
+
| "open"
|
|
102
|
+
| "submitting"
|
|
103
|
+
| "submitted"
|
|
104
|
+
| "delivered"
|
|
105
|
+
| "pending-delivery"
|
|
106
|
+
| "undeliverable"
|
|
107
|
+
| "closed";
|
|
108
|
+
|
|
109
|
+
/** Options for {@link Store}. */
|
|
110
|
+
export interface StoreOptions {
|
|
111
|
+
readonly mode: RuntimeMode;
|
|
112
|
+
/** The browser protocol, in live mode. */
|
|
113
|
+
readonly transport?: Transport;
|
|
114
|
+
/** The journal of unconfirmed edits. */
|
|
115
|
+
readonly journal: Journal;
|
|
116
|
+
/** The view this page is, in live mode (D104). */
|
|
117
|
+
readonly view?: ViewContext;
|
|
118
|
+
/** The link's scopes (D97): without `draft` the page saves nothing. */
|
|
119
|
+
readonly scopes?: readonly string[];
|
|
120
|
+
/**
|
|
121
|
+
* The viewer the page acts as, from its frame session, before the first snapshot: an edit made
|
|
122
|
+
* before it arrives is journaled like any other (Claude review finding 4).
|
|
123
|
+
*/
|
|
124
|
+
readonly viewer?: Viewer;
|
|
125
|
+
/** This tab's ID, unique per page load. */
|
|
126
|
+
readonly tab: string;
|
|
127
|
+
/** The tabs whose journal entries this page may adopt: those no longer open. */
|
|
128
|
+
readonly goneTabs?: (tabs: readonly string[]) => Promise<ReadonlySet<string>>;
|
|
129
|
+
/** Runs `fn` while no other page of the artifact adopts journal entries (Web Locks). */
|
|
130
|
+
readonly exclusive?: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
131
|
+
/** Where `local` mode keeps values; `localStorage` by default. */
|
|
132
|
+
readonly local?: Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
|
133
|
+
/** The same-browser latency path (D22); never a source of truth. */
|
|
134
|
+
readonly channel?: (name: string) => BroadcastChannel | undefined;
|
|
135
|
+
/** Timers, replaceable in tests. */
|
|
136
|
+
readonly timers?: {
|
|
137
|
+
setTimeout(fn: () => void, ms: number): unknown;
|
|
138
|
+
clearTimeout(handle: unknown): void;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** What a flush ends with: the draft revision every save reached, and whether some still wait. */
|
|
143
|
+
export interface FlushOutcome {
|
|
144
|
+
readonly revision: number;
|
|
145
|
+
readonly unsaved: boolean;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** How many early delivery events a page keeps. */
|
|
149
|
+
const EARLY_DELIVERIES_MAX = 64;
|
|
150
|
+
|
|
151
|
+
const DELIVERY_SEVERITY = { delivered: 0, pending: 1, rejected: 2, undeliverable: 3 } as const;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* One submission's two accounts merged: each delivery as its latest change says, so an answer
|
|
155
|
+
* built before newer events never takes the state back (Claude review finding 8).
|
|
156
|
+
*/
|
|
157
|
+
function mergedSubmission(
|
|
158
|
+
known: SubmissionSummary | undefined,
|
|
159
|
+
next: SubmissionSummary,
|
|
160
|
+
): SubmissionSummary {
|
|
161
|
+
if (known?.id !== next.id || known.deliveries === undefined) return next;
|
|
162
|
+
const newer = new Map((next.deliveries ?? []).map((delivery) => [delivery.deliveryId, delivery]));
|
|
163
|
+
for (const delivery of known.deliveries) {
|
|
164
|
+
const other = newer.get(delivery.deliveryId);
|
|
165
|
+
if (other === undefined || delivery.updatedAt > other.updatedAt) {
|
|
166
|
+
newer.set(delivery.deliveryId, delivery);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const deliveries = [...newer.values()];
|
|
170
|
+
let worst: SubmissionSummary["delivery"] = "delivered";
|
|
171
|
+
for (const delivery of deliveries) {
|
|
172
|
+
if (DELIVERY_SEVERITY[delivery.state] > DELIVERY_SEVERITY[worst]) worst = delivery.state;
|
|
173
|
+
}
|
|
174
|
+
return { ...next, deliveries, delivery: deliveries.length > 0 ? worst : next.delivery };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Deep equality for JSON values. */
|
|
178
|
+
function same(a: unknown, b: unknown): boolean {
|
|
179
|
+
if (a === b) return true;
|
|
180
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
181
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
182
|
+
const keysA = Object.keys(a as object);
|
|
183
|
+
const keysB = Object.keys(b as object);
|
|
184
|
+
if (keysA.length !== keysB.length) return false;
|
|
185
|
+
return keysA.every((key) =>
|
|
186
|
+
same((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Whether a required field's value counts as missing. */
|
|
191
|
+
export function isEmpty(value: unknown): boolean {
|
|
192
|
+
return (
|
|
193
|
+
value === undefined ||
|
|
194
|
+
value === null ||
|
|
195
|
+
(typeof value === "string" && value.trim() === "") ||
|
|
196
|
+
(Array.isArray(value) && value.length === 0)
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const keyParts = (key: FieldKey): { scope: FieldScope; id: string } => {
|
|
201
|
+
const at = key.indexOf(":");
|
|
202
|
+
return { scope: key.slice(0, at) as FieldScope, id: key.slice(at + 1) };
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/** The runtime's store of one page. */
|
|
206
|
+
export class Store {
|
|
207
|
+
readonly mode: RuntimeMode;
|
|
208
|
+
readonly view: ViewContext | undefined;
|
|
209
|
+
readonly #options: StoreOptions;
|
|
210
|
+
readonly #timers: NonNullable<StoreOptions["timers"]>;
|
|
211
|
+
|
|
212
|
+
// What the gateway says.
|
|
213
|
+
readonly #server = new Map<FieldKey, ServerField>();
|
|
214
|
+
/** The revision the event stream reached, per document: every change up to it was seen. */
|
|
215
|
+
readonly #seen: Record<FieldScope, number> = { viewer: 0, artifact: 0 };
|
|
216
|
+
#epoch = 1;
|
|
217
|
+
#viewer: Viewer | undefined;
|
|
218
|
+
/** Whether the first snapshot arrived. */
|
|
219
|
+
#loaded = false;
|
|
220
|
+
/** Delivery events for a submission not known yet (the stream may beat the submit's answer). */
|
|
221
|
+
#earlyDeliveries: EventRecord[] = [];
|
|
222
|
+
#submission: SubmissionSummary | undefined;
|
|
223
|
+
/** Whether a submission closed the viewer's draft (`once`, D106). */
|
|
224
|
+
#closed = false;
|
|
225
|
+
|
|
226
|
+
// What the page holds that the gateway hasn't confirmed.
|
|
227
|
+
readonly #pending = new Map<FieldKey, PendingEntry>();
|
|
228
|
+
readonly #inflight = new Map<string, MutationEntry>();
|
|
229
|
+
/** The value each in-flight mutation sends, per field. */
|
|
230
|
+
readonly #sending = new Map<FieldKey, { readonly value: unknown; readonly mutationId: string }>();
|
|
231
|
+
readonly #conflicts = new Map<FieldKey, OpenConflict>();
|
|
232
|
+
/** Edits the gateway refused for good (too large, invalid): kept, shown with the reason. */
|
|
233
|
+
readonly #refused = new Map<FieldKey, { readonly code: string; readonly message: string }>();
|
|
234
|
+
/** Mutations waiting for the network, in order. */
|
|
235
|
+
readonly #queue: MutationEntry[] = [];
|
|
236
|
+
|
|
237
|
+
// Mounted fields.
|
|
238
|
+
readonly #registrations = new Map<FieldKey, { registration: Registration; count: number }>();
|
|
239
|
+
/** Who hears each mount and unmount: `verify`'s field probe (D138). */
|
|
240
|
+
readonly #mountObservers = new Set<(registration: Registration, mounted: boolean) => void>();
|
|
241
|
+
|
|
242
|
+
#ready = false;
|
|
243
|
+
#readyWaiters: (() => void)[] = [];
|
|
244
|
+
#online = true;
|
|
245
|
+
#revoked = false;
|
|
246
|
+
#debounce: unknown;
|
|
247
|
+
#retry: unknown;
|
|
248
|
+
#retryMs = 1_000;
|
|
249
|
+
#sendingQueue = false;
|
|
250
|
+
#seq = 0;
|
|
251
|
+
#version = 0;
|
|
252
|
+
readonly #listeners = new Set<() => void>();
|
|
253
|
+
readonly #keyVersions = new Map<FieldKey, number>();
|
|
254
|
+
#broadcast: BroadcastChannel | undefined;
|
|
255
|
+
#submitState: SubmitState = "open";
|
|
256
|
+
#catchUp: (() => void)[] = [];
|
|
257
|
+
/**
|
|
258
|
+
* Fields another writer changed whose new value the page hasn't got (a sensitive value isn't in
|
|
259
|
+
* events, D52): an edit of one is based on what the page last had, so it conflicts instead of
|
|
260
|
+
* overwriting, and a submit waits until a snapshot brings the value.
|
|
261
|
+
*/
|
|
262
|
+
readonly #unknown = new Set<FieldKey>();
|
|
263
|
+
#resyncing: Promise<void> | undefined;
|
|
264
|
+
readonly #beforeSubmit = new Set<() => Promise<void>>();
|
|
265
|
+
#stopStream: (() => void) | undefined;
|
|
266
|
+
/** Plain calls, action runs and live sources (WP-08), over the same transport and view. */
|
|
267
|
+
readonly server: ServerClient;
|
|
268
|
+
|
|
269
|
+
constructor(options: StoreOptions) {
|
|
270
|
+
this.#options = options;
|
|
271
|
+
this.server = new ServerClient({
|
|
272
|
+
...(options.mode === "live" && options.transport ? { transport: options.transport } : {}),
|
|
273
|
+
...(options.mode === "live" && options.view ? { viewId: options.view.viewId } : {}),
|
|
274
|
+
...(options.timers ? { timers: options.timers } : {}),
|
|
275
|
+
});
|
|
276
|
+
this.mode = options.mode;
|
|
277
|
+
this.view = options.view;
|
|
278
|
+
this.#viewer = options.viewer;
|
|
279
|
+
this.#timers = options.timers ?? {
|
|
280
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
281
|
+
clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
282
|
+
};
|
|
283
|
+
if (options.view?.policy.policy === "none") this.#submitState = "closed";
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── Subscription ───────────────────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
/** Calls `listener` after every change; returns the function that stops. */
|
|
289
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
290
|
+
this.#listeners.add(listener);
|
|
291
|
+
return () => this.#listeners.delete(listener);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/** A number that changes with every change, for `useSyncExternalStore`. */
|
|
295
|
+
version = (): number => this.#version;
|
|
296
|
+
|
|
297
|
+
/** A number that changes whenever the field's value or meta may have. */
|
|
298
|
+
keyVersion(key: FieldKey): number {
|
|
299
|
+
return this.#keyVersions.get(key) ?? 0;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
#changed(keys: Iterable<FieldKey> = []): void {
|
|
303
|
+
this.#version += 1;
|
|
304
|
+
for (const key of keys) this.#keyVersions.set(key, (this.#keyVersions.get(key) ?? 0) + 1);
|
|
305
|
+
for (const listener of [...this.#listeners]) listener();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── Start ──────────────────────────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
/** Whether the first snapshot arrived (or, in local mode, at once). */
|
|
311
|
+
get ready(): boolean {
|
|
312
|
+
return this.#ready;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Resolves once the store is ready, or after `timeoutMs`. */
|
|
316
|
+
whenReady(timeoutMs: number = Number.POSITIVE_INFINITY): Promise<void> {
|
|
317
|
+
if (this.#ready) return Promise.resolve();
|
|
318
|
+
return new Promise((resolve) => {
|
|
319
|
+
this.#readyWaiters.push(resolve);
|
|
320
|
+
if (Number.isFinite(timeoutMs)) this.#timers.setTimeout(resolve, timeoutMs);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
#markReady(): void {
|
|
325
|
+
if (this.#ready) return;
|
|
326
|
+
this.#ready = true;
|
|
327
|
+
for (const resolve of this.#readyWaiters.splice(0)) resolve();
|
|
328
|
+
this.#changed(this.#allKeys());
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
#allKeys(): FieldKey[] {
|
|
332
|
+
return [...new Set([...this.#server.keys(), ...this.#registrations.keys()])];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Starts: subscribes to the event stream (live) or reads this browser's values (local). */
|
|
336
|
+
start(): void {
|
|
337
|
+
if (this.mode === "local") {
|
|
338
|
+
this.#markReady();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const transport = this.#transport();
|
|
342
|
+
this.#stopStream = transport.subscribe(undefined, {
|
|
343
|
+
onSnapshot: (snapshot) => this.#applySnapshot(snapshot),
|
|
344
|
+
onEvents: (events) => this.#applyEvents(events),
|
|
345
|
+
onStatus: (status) => {
|
|
346
|
+
if (status === "revoked") this.#revoked = true;
|
|
347
|
+
const online = status === "live";
|
|
348
|
+
if (online !== this.#online) {
|
|
349
|
+
this.#online = online;
|
|
350
|
+
if (online) {
|
|
351
|
+
this.#kickQueue();
|
|
352
|
+
// Run and live updates sent while the stream was down are gone: read them again.
|
|
353
|
+
this.server.resync();
|
|
354
|
+
}
|
|
355
|
+
this.#changed(this.#allKeys());
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
onEphemeral: (message) => this.server.receive(message),
|
|
359
|
+
});
|
|
360
|
+
if (typeof window !== "undefined") {
|
|
361
|
+
window.addEventListener("online", () => this.#kickQueue());
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Stops the stream and timers (the page is going away, or a test ends). */
|
|
366
|
+
stop(): void {
|
|
367
|
+
this.#stopStream?.();
|
|
368
|
+
this.server.stop();
|
|
369
|
+
this.#broadcast?.close();
|
|
370
|
+
if (this.#debounce !== undefined) this.#timers.clearTimeout(this.#debounce);
|
|
371
|
+
if (this.#retry !== undefined) this.#timers.clearTimeout(this.#retry);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
#transport(): Transport {
|
|
375
|
+
const transport = this.#options.transport;
|
|
376
|
+
if (!transport) throw targetUnavailable("Saving");
|
|
377
|
+
return transport;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ── What the gateway says ──────────────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
#applySnapshot(snapshot: StreamSnapshot): void {
|
|
383
|
+
this.#gatewayHeard();
|
|
384
|
+
const first = !this.#loaded;
|
|
385
|
+
this.#loaded = true;
|
|
386
|
+
this.#viewer = snapshot.viewer;
|
|
387
|
+
const touched = new Set<FieldKey>(this.#allKeys());
|
|
388
|
+
// A snapshot replaces a document the page knows no newer revision of. One that is older than
|
|
389
|
+
// events already applied (a resync answered after the stream moved on) only adds what it
|
|
390
|
+
// knows newer, field by field: the stream's cursor is past those events, so they would never
|
|
391
|
+
// come again (GPT review finding 11).
|
|
392
|
+
const load = (scope: FieldScope, document: StreamSnapshot["draft"]): boolean => {
|
|
393
|
+
const revision = Number(document.revision);
|
|
394
|
+
const replace = first || revision >= this.#seen[scope];
|
|
395
|
+
if (replace) {
|
|
396
|
+
for (const key of [...this.#server.keys()]) {
|
|
397
|
+
if (keyParts(key).scope === scope) this.#server.delete(key);
|
|
398
|
+
}
|
|
399
|
+
for (const key of [...this.#unknown]) {
|
|
400
|
+
if (keyParts(key).scope === scope) this.#unknown.delete(key);
|
|
401
|
+
}
|
|
402
|
+
this.#seen[scope] = revision;
|
|
403
|
+
}
|
|
404
|
+
for (const [id, field] of Object.entries(document.fields)) {
|
|
405
|
+
const key = fieldKey(scope, id);
|
|
406
|
+
const current = this.#server.get(key);
|
|
407
|
+
if (!replace && current && current.revision >= Number(field.revision)) continue;
|
|
408
|
+
touched.add(key);
|
|
409
|
+
this.#unknown.delete(key);
|
|
410
|
+
this.#server.set(key, {
|
|
411
|
+
value: field.value,
|
|
412
|
+
revision: Number(field.revision),
|
|
413
|
+
deleted: false,
|
|
414
|
+
...(field.updatedBy ? { updatedBy: field.updatedBy } : {}),
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
return replace;
|
|
418
|
+
};
|
|
419
|
+
if (load("viewer", snapshot.draft)) this.#epoch = snapshot.draft.epoch;
|
|
420
|
+
load("artifact", snapshot.state);
|
|
421
|
+
if (snapshot.submission) this.#adoptSubmission(snapshot.submission, true);
|
|
422
|
+
this.#resolveCatchUp();
|
|
423
|
+
this.#changed(touched);
|
|
424
|
+
if (first) {
|
|
425
|
+
this.#openChannel();
|
|
426
|
+
void this.#recoverJournal().finally(() => {
|
|
427
|
+
this.#markReady();
|
|
428
|
+
// Edits made before the first snapshot waited for it (Claude review finding 4).
|
|
429
|
+
if (this.#pending.size > 0) this.#schedule();
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
#applyEvents(events: readonly EventRecord[]): void {
|
|
435
|
+
this.#gatewayHeard();
|
|
436
|
+
const touched = new Set<FieldKey>();
|
|
437
|
+
let resync = false;
|
|
438
|
+
for (const event of events) {
|
|
439
|
+
const ownMutation =
|
|
440
|
+
event.causation?.["mutationId"] !== undefined &&
|
|
441
|
+
(this.#inflight.has(event.causation["mutationId"]) ||
|
|
442
|
+
[...this.#sending.values()].some(
|
|
443
|
+
(s) => s.mutationId === event.causation?.["mutationId"],
|
|
444
|
+
));
|
|
445
|
+
if (event.type === "draft.changed" || event.type === "state.changed") {
|
|
446
|
+
const scope: FieldScope = event.type === "draft.changed" ? "viewer" : "artifact";
|
|
447
|
+
const payload = event.payload as {
|
|
448
|
+
revision: string;
|
|
449
|
+
epoch?: number;
|
|
450
|
+
changes: { id: string; value?: unknown; deleted?: true; sensitive?: true }[];
|
|
451
|
+
};
|
|
452
|
+
const revision = Number(payload.revision);
|
|
453
|
+
if (scope === "viewer" && payload.epoch !== undefined && payload.epoch > this.#epoch) {
|
|
454
|
+
this.#epoch = payload.epoch;
|
|
455
|
+
}
|
|
456
|
+
for (const change of payload.changes) {
|
|
457
|
+
const key = fieldKey(scope, change.id);
|
|
458
|
+
touched.add(key);
|
|
459
|
+
const current = this.#server.get(key);
|
|
460
|
+
if (current && current.revision >= revision) continue;
|
|
461
|
+
if (change.sensitive && !change.deleted) {
|
|
462
|
+
// A sensitive value isn't in events (D52): the page's own value is known, another
|
|
463
|
+
// writer's comes with a fresh snapshot.
|
|
464
|
+
const mine = this.#sending.get(key);
|
|
465
|
+
if (ownMutation && mine) {
|
|
466
|
+
this.#server.set(key, {
|
|
467
|
+
value: mine.value,
|
|
468
|
+
revision,
|
|
469
|
+
deleted: false,
|
|
470
|
+
updatedBy: event.actor,
|
|
471
|
+
});
|
|
472
|
+
} else {
|
|
473
|
+
// The page still shows the old value: until the snapshot brings the new one, an
|
|
474
|
+
// edit conflicts and a submit waits (GPT review finding 4).
|
|
475
|
+
this.#unknown.add(key);
|
|
476
|
+
resync = true;
|
|
477
|
+
}
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
this.#unknown.delete(key);
|
|
481
|
+
this.#server.set(key, {
|
|
482
|
+
value: change.deleted ? undefined : change.value,
|
|
483
|
+
revision,
|
|
484
|
+
deleted: change.deleted === true,
|
|
485
|
+
updatedBy: event.actor,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
this.#seen[scope] = Math.max(this.#seen[scope], revision);
|
|
489
|
+
} else if (event.type === "submission.created") {
|
|
490
|
+
const payload = event.payload as { submissionId: string; viewerId?: string };
|
|
491
|
+
const own = payload.viewerId !== undefined && payload.viewerId === this.#viewer?.id;
|
|
492
|
+
if (own && this.#submission?.id !== payload.submissionId) {
|
|
493
|
+
// The viewer's own submission, made in another tab or by the shell's trusted Submit:
|
|
494
|
+
// the snapshot carries it whole, with its deliveries.
|
|
495
|
+
resync = true;
|
|
496
|
+
} else if (!own && this.view?.policy.policy === "once") {
|
|
497
|
+
// Under `once` for the whole artifact anyone's submission closes the draft (D106).
|
|
498
|
+
if (this.view.policy.scope !== "per-viewer") {
|
|
499
|
+
this.#closed = true;
|
|
500
|
+
this.#submitState = "closed";
|
|
501
|
+
for (const key of this.#allKeys()) touched.add(key);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
} else if (event.type.startsWith("submission.")) {
|
|
505
|
+
this.#deliveryEvent(event);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
this.#resolveCatchUp();
|
|
509
|
+
if (resync) void this.#resync();
|
|
510
|
+
if (touched.size > 0) this.#changed(touched);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Loads a fresh snapshot, once at a time; a failure leaves it to the next event or submit. */
|
|
514
|
+
#resync(): Promise<void> {
|
|
515
|
+
this.#resyncing ??= (async () => {
|
|
516
|
+
try {
|
|
517
|
+
this.#applySnapshot(await this.#transport().snapshot());
|
|
518
|
+
} catch {
|
|
519
|
+
// The next event, reconnect or submit asks again.
|
|
520
|
+
} finally {
|
|
521
|
+
this.#resyncing = undefined;
|
|
522
|
+
}
|
|
523
|
+
})();
|
|
524
|
+
return this.#resyncing;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ── Reading ────────────────────────────────────────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
/** Whether the gateway holds a value for the field (deleted fields don't count). */
|
|
530
|
+
stored(key: FieldKey): boolean {
|
|
531
|
+
const server = this.#server.get(key);
|
|
532
|
+
return server !== undefined && !server.deleted;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** The field's value as the viewer sees it: their edit, else the gateway's, else `fallback`. */
|
|
536
|
+
value(key: FieldKey, fallback: unknown): unknown {
|
|
537
|
+
if (this.mode === "local") {
|
|
538
|
+
const stored = this.#localRead(key);
|
|
539
|
+
const pending = this.#pending.get(key);
|
|
540
|
+
if (pending) return pending.op.delete ? fallback : pending.op.value;
|
|
541
|
+
return stored === undefined ? fallback : stored;
|
|
542
|
+
}
|
|
543
|
+
const pending = this.#pending.get(key);
|
|
544
|
+
if (pending) return pending.op.delete ? fallback : pending.op.value;
|
|
545
|
+
const sending = this.#sending.get(key);
|
|
546
|
+
if (sending) return sending.value === undefined ? fallback : sending.value;
|
|
547
|
+
const server = this.#server.get(key);
|
|
548
|
+
if (server && !server.deleted) return server.value;
|
|
549
|
+
return fallback;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** The field's open conflict, if any. */
|
|
553
|
+
conflict(key: FieldKey): OpenConflict | undefined {
|
|
554
|
+
return this.#conflicts.get(key);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Who last wrote the field (artifact state, D63). */
|
|
558
|
+
updatedBy(key: FieldKey): Actor | undefined {
|
|
559
|
+
return this.#server.get(key)?.updatedBy;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Why the gateway refused the field's edit for good, if it did: its code and message. */
|
|
563
|
+
refusal(key: FieldKey): { readonly code: string; readonly message: string } | undefined {
|
|
564
|
+
return this.#refused.get(key);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Whether the field takes edits: not a closed draft, not a view-only link. */
|
|
568
|
+
writable(key: FieldKey): boolean {
|
|
569
|
+
if (this.mode === "local") return true;
|
|
570
|
+
const { scope } = keyParts(key);
|
|
571
|
+
if (!this.#canSave()) return false;
|
|
572
|
+
return scope === "artifact" || !this.#closed;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Whether the link may submit (D97): its `submit` scope. */
|
|
576
|
+
canSubmit(): boolean {
|
|
577
|
+
return this.#options.scopes === undefined || this.#options.scopes.includes("submit");
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
#canSave(): boolean {
|
|
581
|
+
return this.#options.scopes === undefined || this.#options.scopes.includes("draft");
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** The field's save state (INTERFACES §2.3). */
|
|
585
|
+
save(key: FieldKey): "saved" | "saving" | "offline" | "conflict" | "readonly" {
|
|
586
|
+
if (this.mode === "local") return "readonly";
|
|
587
|
+
if (this.#conflicts.has(key)) return "conflict";
|
|
588
|
+
if (!this.writable(key)) return "readonly";
|
|
589
|
+
if (this.#refused.has(key)) return "offline";
|
|
590
|
+
const unsent = this.#pending.has(key) || this.#sending.has(key);
|
|
591
|
+
if (!unsent) return "saved";
|
|
592
|
+
return this.#online && !this.#revoked ? "saving" : "offline";
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** The page's save state, for the shell's rail. */
|
|
596
|
+
pageSave(): PageSave {
|
|
597
|
+
if (this.mode === "local") return "saved";
|
|
598
|
+
if (this.#conflicts.size > 0) return "conflict";
|
|
599
|
+
const unsent = this.#pending.size > 0 || this.#inflight.size > 0 || this.#queue.length > 0;
|
|
600
|
+
if (!unsent && this.#refused.size === 0) return "saved";
|
|
601
|
+
return this.#online && !this.#revoked && this.#refused.size === 0 ? "saving" : "offline";
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Whether edits wait to be saved. */
|
|
605
|
+
unsaved(): boolean {
|
|
606
|
+
return (
|
|
607
|
+
this.#pending.size > 0 ||
|
|
608
|
+
this.#inflight.size > 0 ||
|
|
609
|
+
this.#queue.length > 0 ||
|
|
610
|
+
this.#refused.size > 0
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** The viewer the page acts as, once known. */
|
|
615
|
+
viewer(): Viewer | undefined {
|
|
616
|
+
return this.#viewer;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Every field ID of a scope the viewer sees, with its value (for `window.storage.list`). */
|
|
620
|
+
ids(scope: FieldScope): string[] {
|
|
621
|
+
const ids = new Set<string>();
|
|
622
|
+
if (this.mode === "local") {
|
|
623
|
+
for (const id of this.#localIds(scope)) ids.add(id);
|
|
624
|
+
}
|
|
625
|
+
for (const [key, server] of this.#server) {
|
|
626
|
+
if (!server.deleted && key.startsWith(`${scope}:`)) ids.add(keyParts(key).id);
|
|
627
|
+
}
|
|
628
|
+
for (const key of [...this.#pending.keys(), ...this.#sending.keys()]) {
|
|
629
|
+
if (key.startsWith(`${scope}:`)) ids.add(keyParts(key).id);
|
|
630
|
+
}
|
|
631
|
+
return [...ids].filter((id) => this.value(fieldKey(scope, id), undefined) !== undefined).sort();
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ── Mounted fields ─────────────────────────────────────────────────────────────────────
|
|
635
|
+
|
|
636
|
+
/** Registers a mounted field; returns the function that unregisters it. */
|
|
637
|
+
register(registration: Registration): () => void {
|
|
638
|
+
const key = fieldKey(registration.scope, registration.id);
|
|
639
|
+
const existing = this.#registrations.get(key);
|
|
640
|
+
if (existing) {
|
|
641
|
+
existing.count += 1;
|
|
642
|
+
existing.registration = { ...registration, initial: existing.registration.initial };
|
|
643
|
+
} else this.#registrations.set(key, { registration, count: 1 });
|
|
644
|
+
for (const observer of this.#mountObservers) observer(registration, true);
|
|
645
|
+
this.#changed([key]);
|
|
646
|
+
let released = false;
|
|
647
|
+
return () => {
|
|
648
|
+
const entry = this.#registrations.get(key);
|
|
649
|
+
if (!entry) return;
|
|
650
|
+
if (!released) {
|
|
651
|
+
released = true;
|
|
652
|
+
for (const observer of this.#mountObservers) observer(registration, false);
|
|
653
|
+
}
|
|
654
|
+
entry.count -= 1;
|
|
655
|
+
if (entry.count <= 0) this.#registrations.delete(key);
|
|
656
|
+
this.#changed([key]);
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Hears every field mount and unmount, with the registration as the hook gave it; returns the
|
|
662
|
+
* function that stops. `verify`'s field probe uses it (D138). @internal
|
|
663
|
+
*/
|
|
664
|
+
observeMounts(observer: (registration: Registration, mounted: boolean) => void): () => void {
|
|
665
|
+
this.#mountObservers.add(observer);
|
|
666
|
+
return () => this.#mountObservers.delete(observer);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Tells the mount observers about a field a hook reads and writes without registering it
|
|
671
|
+
* (`useChoice`'s choice field): `verify`'s field probe hears it, and nothing else changes, so
|
|
672
|
+
* no default is written for it (D110). Returns the function that announces its unmount.
|
|
673
|
+
*/
|
|
674
|
+
announceMount(registration: Registration): () => void {
|
|
675
|
+
if (this.#mountObservers.size === 0) return () => undefined;
|
|
676
|
+
for (const observer of this.#mountObservers) observer(registration, true);
|
|
677
|
+
let released = false;
|
|
678
|
+
return () => {
|
|
679
|
+
if (released) return;
|
|
680
|
+
released = true;
|
|
681
|
+
for (const observer of this.#mountObservers) observer(registration, false);
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Updates a mounted field's options or validation message. */
|
|
686
|
+
updateRegistration(registration: Registration): void {
|
|
687
|
+
const key = fieldKey(registration.scope, registration.id);
|
|
688
|
+
const entry = this.#registrations.get(key);
|
|
689
|
+
if (!entry) return;
|
|
690
|
+
const next = { ...registration, initial: entry.registration.initial };
|
|
691
|
+
if (
|
|
692
|
+
entry.registration.label === next.label &&
|
|
693
|
+
entry.registration.required === next.required &&
|
|
694
|
+
entry.registration.sensitive === next.sensitive &&
|
|
695
|
+
entry.registration.error === next.error
|
|
696
|
+
)
|
|
697
|
+
return;
|
|
698
|
+
entry.registration = next;
|
|
699
|
+
this.#changed([key]);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Every mounted field. */
|
|
703
|
+
registrations(): Registration[] {
|
|
704
|
+
return [...this.#registrations.values()].map((entry) => entry.registration);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** What blocks a submit now (INTERFACES §2.3): required, invalid and conflicting fields. */
|
|
708
|
+
blockers(): SubmitBlocker[] {
|
|
709
|
+
const blockers: SubmitBlocker[] = [];
|
|
710
|
+
for (const [key, { registration }] of this.#registrations) {
|
|
711
|
+
const label = registration.label !== undefined ? { label: registration.label } : {};
|
|
712
|
+
if (this.#conflicts.has(key)) {
|
|
713
|
+
blockers.push({ fieldId: registration.id, ...label, reason: "conflict" });
|
|
714
|
+
} else if (registration.error !== undefined) {
|
|
715
|
+
blockers.push({ fieldId: registration.id, ...label, reason: "invalid" });
|
|
716
|
+
} else if (registration.required && isEmpty(this.value(key, registration.initial))) {
|
|
717
|
+
blockers.push({ fieldId: registration.id, ...label, reason: "required" });
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return blockers.sort((a, b) => (a.fieldId < b.fieldId ? -1 : a.fieldId > b.fieldId ? 1 : 0));
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// ── Editing ────────────────────────────────────────────────────────────────────────────
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Sets a field: shown at once, journaled at once, sent after the debounce (`immediate` sends
|
|
727
|
+
* now). Refused with `MOSTRA_FIELD_ID_INVALID` for an ID that isn't one; a closed draft or a
|
|
728
|
+
* view-only link refuses with `MOSTRA_SUBMIT_ALREADY` or `MOSTRA_HYGIENE_SCOPE_DENIED`.
|
|
729
|
+
*/
|
|
730
|
+
set(
|
|
731
|
+
scope: FieldScope,
|
|
732
|
+
id: string,
|
|
733
|
+
value: unknown,
|
|
734
|
+
options: {
|
|
735
|
+
readonly label?: string;
|
|
736
|
+
readonly required?: boolean;
|
|
737
|
+
readonly sensitive?: boolean;
|
|
738
|
+
readonly immediate?: boolean;
|
|
739
|
+
readonly delete?: boolean;
|
|
740
|
+
} = {},
|
|
741
|
+
): void {
|
|
742
|
+
if (!isFieldId(id)) {
|
|
743
|
+
throw new MostraError("MOSTRA_FIELD_ID_INVALID", `${JSON.stringify(id)} isn't a field ID.`);
|
|
744
|
+
}
|
|
745
|
+
const key = fieldKey(scope, id);
|
|
746
|
+
if (!this.writable(key)) {
|
|
747
|
+
throw this.#closed && scope === "viewer"
|
|
748
|
+
? new MostraError("MOSTRA_SUBMIT_ALREADY", "This draft was submitted and takes no edits.")
|
|
749
|
+
: new MostraError("MOSTRA_HYGIENE_SCOPE_DENIED", "This link can't save answers.");
|
|
750
|
+
}
|
|
751
|
+
if (value !== undefined) {
|
|
752
|
+
try {
|
|
753
|
+
JSON.stringify(value);
|
|
754
|
+
} catch {
|
|
755
|
+
throw new MostraError("MOSTRA_HOST_INVALID_PARAMS", `The value of ${id} isn't JSON.`);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
// An edit keeps the field's label, `required` and `sensitive`: from this call, else the
|
|
759
|
+
// earlier unsent edit, else the mounted field.
|
|
760
|
+
const earlierOp = this.#pending.get(fieldKey(scope, id))?.op;
|
|
761
|
+
const registered = this.#registrations.get(fieldKey(scope, id))?.registration;
|
|
762
|
+
const label = options.label ?? earlierOp?.label ?? registered?.label;
|
|
763
|
+
const required = options.required ?? earlierOp?.required ?? registered?.required;
|
|
764
|
+
const sensitive = options.sensitive ?? earlierOp?.sensitive ?? registered?.sensitive;
|
|
765
|
+
const op: FieldOp = {
|
|
766
|
+
id,
|
|
767
|
+
...(options.delete || value === undefined ? { delete: true as const } : { value }),
|
|
768
|
+
...(label !== undefined ? { label } : {}),
|
|
769
|
+
...(required !== undefined ? { required } : {}),
|
|
770
|
+
...(sensitive !== undefined ? { sensitive } : {}),
|
|
771
|
+
};
|
|
772
|
+
if (this.mode === "local") {
|
|
773
|
+
this.#localWrite(key, op.delete ? undefined : op.value);
|
|
774
|
+
this.#changed([key]);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
const earlier = this.#pending.get(key);
|
|
778
|
+
const entry: PendingEntry = {
|
|
779
|
+
kind: "pending",
|
|
780
|
+
viewerId: this.#viewer?.id ?? "",
|
|
781
|
+
tab: this.#options.tab,
|
|
782
|
+
viewId: this.view?.viewId ?? "",
|
|
783
|
+
scope,
|
|
784
|
+
op,
|
|
785
|
+
base: earlier ? earlier.base : this.#baseFor(key),
|
|
786
|
+
at: Date.now(),
|
|
787
|
+
};
|
|
788
|
+
this.#refused.delete(key);
|
|
789
|
+
this.#pending.set(key, entry);
|
|
790
|
+
// A conflict stays open until the viewer resolves it; the edit waits with it.
|
|
791
|
+
if (entry.viewerId !== "") void this.#options.journal.putPending(entry).catch(() => undefined);
|
|
792
|
+
this.#changed([key]);
|
|
793
|
+
if (options.immediate) void this.flush();
|
|
794
|
+
else this.#schedule();
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** The base of a new edit: what the page has seen of the field (null while it is in flight). */
|
|
798
|
+
#baseFor(key: FieldKey): number | null {
|
|
799
|
+
if (this.#sending.has(key)) return null;
|
|
800
|
+
// A value the page hasn't got: based on what it last had, so the write conflicts.
|
|
801
|
+
if (this.#unknown.has(key)) return this.#server.get(key)?.revision ?? 0;
|
|
802
|
+
const { scope } = keyParts(key);
|
|
803
|
+
return Math.max(this.#server.get(key)?.revision ?? 0, this.#seen[scope]);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
#schedule(): void {
|
|
807
|
+
if (this.#debounce !== undefined) this.#timers.clearTimeout(this.#debounce);
|
|
808
|
+
this.#debounce = this.#timers.setTimeout(() => {
|
|
809
|
+
this.#debounce = undefined;
|
|
810
|
+
void this.flush();
|
|
811
|
+
}, SAVE_DEBOUNCE_MS);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** Keeps the viewer's value of a conflicting field: written over theirs (FAILURE_MODEL §5). */
|
|
815
|
+
keepMine(key: FieldKey): void {
|
|
816
|
+
const conflict = this.#conflicts.get(key);
|
|
817
|
+
const pending = this.#pending.get(key);
|
|
818
|
+
if (!conflict || !pending) return;
|
|
819
|
+
this.#conflicts.delete(key);
|
|
820
|
+
const entry = { ...pending, base: conflict.revision, at: Date.now() };
|
|
821
|
+
this.#pending.set(key, entry);
|
|
822
|
+
void this.#options.journal.putPending(entry).catch(() => undefined);
|
|
823
|
+
this.#changed([key]);
|
|
824
|
+
void this.flush();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** Takes the other writer's value of a conflicting field and drops the viewer's. */
|
|
828
|
+
takeTheirs(key: FieldKey): void {
|
|
829
|
+
const pending = this.#pending.get(key);
|
|
830
|
+
this.#conflicts.delete(key);
|
|
831
|
+
this.#refused.delete(key);
|
|
832
|
+
this.#pending.delete(key);
|
|
833
|
+
if (pending) void this.#options.journal.dropPending(pending).catch(() => undefined);
|
|
834
|
+
this.#changed([key]);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ── Saving ─────────────────────────────────────────────────────────────────────────────
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Sends every edit now and waits for the answers. `keepalive` is for `pagehide`: the requests
|
|
841
|
+
* outlive the page, and the journal keeps whatever they don't deliver (IN-02).
|
|
842
|
+
*/
|
|
843
|
+
async flush(options: { readonly keepalive?: boolean } = {}): Promise<FlushOutcome> {
|
|
844
|
+
// Nothing is sent before the first snapshot: an edit's base must be a revision the page saw.
|
|
845
|
+
// It is journaled meanwhile and goes out once the snapshot arrives.
|
|
846
|
+
if (this.mode === "local" || this.#viewer === undefined || !this.#loaded) {
|
|
847
|
+
return { revision: this.#seen.viewer, unsaved: this.unsaved() };
|
|
848
|
+
}
|
|
849
|
+
if (this.#debounce !== undefined) {
|
|
850
|
+
this.#timers.clearTimeout(this.#debounce);
|
|
851
|
+
this.#debounce = undefined;
|
|
852
|
+
}
|
|
853
|
+
// Edits whose base waits for an earlier save stay until it answers; conflicts until resolved.
|
|
854
|
+
const ready = [...this.#pending.entries()].filter(
|
|
855
|
+
([key, entry]) => entry.base !== null && !this.#conflicts.has(key) && !this.#refused.has(key),
|
|
856
|
+
);
|
|
857
|
+
const groups = new Map<string, PendingEntry[]>();
|
|
858
|
+
for (const [, entry] of ready) {
|
|
859
|
+
// Each edit goes out as the view it was made in (an adopted tab's may be another version's),
|
|
860
|
+
// so its version and causation name what showed it (Claude review finding 12).
|
|
861
|
+
const group = `${entry.scope}\u0000${entry.base}\u0000${entry.viewId}`;
|
|
862
|
+
const list = groups.get(group) ?? [];
|
|
863
|
+
list.push(entry);
|
|
864
|
+
groups.set(group, list);
|
|
865
|
+
}
|
|
866
|
+
const created: MutationEntry[] = [];
|
|
867
|
+
for (const entries of groups.values()) {
|
|
868
|
+
for (let start = 0; start < entries.length; start += OPS_PER_MUTATION) {
|
|
869
|
+
const chunk = entries.slice(start, start + OPS_PER_MUTATION);
|
|
870
|
+
const first = chunk[0] as PendingEntry;
|
|
871
|
+
const mutation: MutationEntry = {
|
|
872
|
+
kind: "mutation",
|
|
873
|
+
viewerId: this.#viewer.id,
|
|
874
|
+
tab: this.#options.tab,
|
|
875
|
+
viewId: first.viewId !== "" ? first.viewId : (this.view?.viewId ?? ""),
|
|
876
|
+
scope: first.scope,
|
|
877
|
+
mutationId: MutationId.create(),
|
|
878
|
+
baseRevision: first.base as number,
|
|
879
|
+
ops: chunk.map((entry) => entry.op),
|
|
880
|
+
seq: Date.now() * 1000 + (this.#seq++ % 1000),
|
|
881
|
+
};
|
|
882
|
+
for (const entry of chunk) {
|
|
883
|
+
const key = fieldKey(entry.scope, entry.op.id);
|
|
884
|
+
this.#pending.delete(key);
|
|
885
|
+
this.#sending.set(key, {
|
|
886
|
+
value: entry.op.delete ? undefined : entry.op.value,
|
|
887
|
+
mutationId: mutation.mutationId,
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
void this.#options.journal.promote(mutation, chunk).catch(() => undefined);
|
|
891
|
+
created.push(mutation);
|
|
892
|
+
this.#queue.push(mutation);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (created.length > 0)
|
|
896
|
+
this.#changed(created.flatMap((m) => m.ops.map((op) => fieldKey(m.scope, op.id))));
|
|
897
|
+
await this.#drain(options.keepalive === true);
|
|
898
|
+
return { revision: this.#ownRevision(), unsaved: this.unsaved() };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** The draft revision the page's own saves reached. */
|
|
902
|
+
#ownRevision(): number {
|
|
903
|
+
let revision = this.#seen.viewer;
|
|
904
|
+
for (const [key, server] of this.#server) {
|
|
905
|
+
if (key.startsWith("viewer:")) revision = Math.max(revision, server.revision);
|
|
906
|
+
}
|
|
907
|
+
return revision;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** Sends queued mutations one at a time, in order, until the queue is empty or the page is offline. */
|
|
911
|
+
async #drain(keepalive: boolean): Promise<void> {
|
|
912
|
+
if (keepalive) {
|
|
913
|
+
// The page is closing: every request goes out now, outliving it.
|
|
914
|
+
// A hidden page may come back: each answered request leaves the queue (GPT review finding 9).
|
|
915
|
+
for (const mutation of [...this.#queue]) {
|
|
916
|
+
void this.#send(mutation, true)
|
|
917
|
+
.then((outcome) => {
|
|
918
|
+
if (outcome === "done") this.#dequeue(mutation);
|
|
919
|
+
})
|
|
920
|
+
.catch(() => undefined);
|
|
921
|
+
}
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
if (this.#sendingQueue) {
|
|
925
|
+
await new Promise<void>((resolve) => {
|
|
926
|
+
const check = (): void => {
|
|
927
|
+
if (!this.#sendingQueue) resolve();
|
|
928
|
+
else this.#timers.setTimeout(check, 20);
|
|
929
|
+
};
|
|
930
|
+
check();
|
|
931
|
+
});
|
|
932
|
+
if (this.#queue.length === 0) return;
|
|
933
|
+
}
|
|
934
|
+
this.#sendingQueue = true;
|
|
935
|
+
try {
|
|
936
|
+
while (this.#queue.length > 0 && !this.#revoked) {
|
|
937
|
+
const head = this.#queue[0] as MutationEntry;
|
|
938
|
+
const outcome = await this.#send(head, false);
|
|
939
|
+
if (outcome === "retry") {
|
|
940
|
+
this.#scheduleRetry();
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
this.#dequeue(head);
|
|
944
|
+
}
|
|
945
|
+
this.#retryMs = 1_000;
|
|
946
|
+
} finally {
|
|
947
|
+
this.#sendingQueue = false;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** Removes an answered mutation from the queue; the page's save state changes only now. */
|
|
952
|
+
#dequeue(mutation: MutationEntry): void {
|
|
953
|
+
const index = this.#queue.indexOf(mutation);
|
|
954
|
+
if (index === -1) return;
|
|
955
|
+
this.#queue.splice(index, 1);
|
|
956
|
+
this.#changed(mutation.ops.map((op) => fieldKey(mutation.scope, op.id)));
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* The event stream just brought something, so the gateway is reachable: saves waiting out a
|
|
961
|
+
* backoff after a network failure try again soon instead of after up to 30 s (FAILURE_MODEL
|
|
962
|
+
* §5, "on reconnect the journal replays"). The backoff itself isn't reset, so a path that keeps
|
|
963
|
+
* failing while the stream works (a proxy refusing POSTs) is tried at most once per message.
|
|
964
|
+
*/
|
|
965
|
+
#gatewayHeard(): void {
|
|
966
|
+
if (this.#retry === undefined) return;
|
|
967
|
+
this.#timers.clearTimeout(this.#retry);
|
|
968
|
+
this.#retry = this.#timers.setTimeout(() => {
|
|
969
|
+
this.#retry = undefined;
|
|
970
|
+
void this.#drain(false);
|
|
971
|
+
}, RETRY_SOON_MS);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
#scheduleRetry(): void {
|
|
975
|
+
if (this.#retry !== undefined) return;
|
|
976
|
+
this.#retry = this.#timers.setTimeout(() => {
|
|
977
|
+
this.#retry = undefined;
|
|
978
|
+
void this.#drain(false);
|
|
979
|
+
}, this.#retryMs);
|
|
980
|
+
this.#retryMs = Math.min(this.#retryMs * 2, 30_000);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
#kickQueue(): void {
|
|
984
|
+
if (this.#retry !== undefined) {
|
|
985
|
+
this.#timers.clearTimeout(this.#retry);
|
|
986
|
+
this.#retry = undefined;
|
|
987
|
+
}
|
|
988
|
+
this.#retryMs = 1_000;
|
|
989
|
+
void this.flush();
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** Sends one mutation; `retry` when the network failed and it stays queued. */
|
|
993
|
+
async #send(mutation: MutationEntry, keepalive: boolean): Promise<"done" | "retry"> {
|
|
994
|
+
this.#inflight.set(mutation.mutationId, mutation);
|
|
995
|
+
let result: MutationResult;
|
|
996
|
+
try {
|
|
997
|
+
result = await this.#transport().mutate(
|
|
998
|
+
{
|
|
999
|
+
mutationId: mutation.mutationId,
|
|
1000
|
+
scope: mutation.scope,
|
|
1001
|
+
baseRevision: String(mutation.baseRevision),
|
|
1002
|
+
viewId: mutation.viewId,
|
|
1003
|
+
ops: mutation.ops,
|
|
1004
|
+
},
|
|
1005
|
+
{ keepalive },
|
|
1006
|
+
);
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
if (error instanceof NetworkError) {
|
|
1009
|
+
this.#online = false;
|
|
1010
|
+
this.#changed(mutation.ops.map((op) => fieldKey(mutation.scope, op.id)));
|
|
1011
|
+
return "retry";
|
|
1012
|
+
}
|
|
1013
|
+
return this.#refusedMutation(mutation, error);
|
|
1014
|
+
} finally {
|
|
1015
|
+
this.#inflight.delete(mutation.mutationId);
|
|
1016
|
+
}
|
|
1017
|
+
if (!this.#online) this.#online = true;
|
|
1018
|
+
this.#answered(mutation, result);
|
|
1019
|
+
return "done";
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** A mutation the gateway answered. */
|
|
1023
|
+
#answered(mutation: MutationEntry, result: MutationResult): void {
|
|
1024
|
+
const revision = Number(result.revision);
|
|
1025
|
+
const touched: FieldKey[] = [];
|
|
1026
|
+
const conflicts = new Map(result.conflicts.map((conflict) => [conflict.id, conflict]));
|
|
1027
|
+
const applied: { id: string; value: unknown }[] = [];
|
|
1028
|
+
for (const op of mutation.ops) {
|
|
1029
|
+
const key = fieldKey(mutation.scope, op.id);
|
|
1030
|
+
touched.push(key);
|
|
1031
|
+
const sending = this.#sending.get(key);
|
|
1032
|
+
const mine = op.delete ? undefined : op.value;
|
|
1033
|
+
if (sending?.mutationId === mutation.mutationId) this.#sending.delete(key);
|
|
1034
|
+
const conflict = conflicts.get(op.id);
|
|
1035
|
+
if (conflict) {
|
|
1036
|
+
this.#conflicted(key, op, conflict, mutation);
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
if (result.applied.includes(op.id)) {
|
|
1040
|
+
const current = this.#server.get(key);
|
|
1041
|
+
if (!current || current.revision < revision) {
|
|
1042
|
+
this.#server.set(key, {
|
|
1043
|
+
value: mine,
|
|
1044
|
+
revision,
|
|
1045
|
+
deleted: op.delete === true,
|
|
1046
|
+
updatedBy: { kind: "viewer", ...(this.#viewer ? { id: this.#viewer.id } : {}) },
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
applied.push({ id: op.id, value: mine });
|
|
1050
|
+
// A later edit of the field, made while this one was in flight, is based on it.
|
|
1051
|
+
const later = this.#pending.get(key);
|
|
1052
|
+
if (later && later.base === null) {
|
|
1053
|
+
const based = { ...later, base: revision };
|
|
1054
|
+
this.#pending.set(key, based);
|
|
1055
|
+
void this.#options.journal.putPending(based).catch(() => undefined);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
void this.#options.journal.remove(mutation.mutationId).catch(() => undefined);
|
|
1060
|
+
if (applied.length > 0) {
|
|
1061
|
+
this.#broadcast?.postMessage({
|
|
1062
|
+
kind: "applied",
|
|
1063
|
+
scope: mutation.scope,
|
|
1064
|
+
revision,
|
|
1065
|
+
changes: applied,
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
this.#resolveCatchUp();
|
|
1069
|
+
this.#changed(touched);
|
|
1070
|
+
// Edits that waited for this answer can go now.
|
|
1071
|
+
if ([...this.#pending.values()].some((entry) => entry.base !== null)) this.#schedule();
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
#conflicted(key: FieldKey, op: FieldOp, conflict: FieldConflict, mutation: MutationEntry): void {
|
|
1075
|
+
const theirs = conflict.deleted ? undefined : conflict.value;
|
|
1076
|
+
const mine = op.delete ? undefined : op.value;
|
|
1077
|
+
this.#unknown.delete(key);
|
|
1078
|
+
this.#server.set(key, {
|
|
1079
|
+
value: theirs,
|
|
1080
|
+
revision: Number(conflict.revision),
|
|
1081
|
+
deleted: conflict.deleted === true,
|
|
1082
|
+
...(conflict.updatedBy ? { updatedBy: conflict.updatedBy } : {}),
|
|
1083
|
+
});
|
|
1084
|
+
const later = this.#pending.get(key);
|
|
1085
|
+
// The same value written twice (a replay of an edit that already landed) is no conflict.
|
|
1086
|
+
if (same(theirs, later ? (later.op.delete ? undefined : later.op.value) : mine)) {
|
|
1087
|
+
if (later) {
|
|
1088
|
+
this.#pending.delete(key);
|
|
1089
|
+
void this.#options.journal.dropPending(later).catch(() => undefined);
|
|
1090
|
+
}
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
// The viewer's value stays as a held edit, shown as theirs to keep or drop. It keeps the
|
|
1094
|
+
// base it was made on, so a page that replays it from the journal meets the same conflict
|
|
1095
|
+
// instead of writing over theirs; only `keepMine()` moves it to their revision (GPT review
|
|
1096
|
+
// finding 1).
|
|
1097
|
+
const held: PendingEntry =
|
|
1098
|
+
later !== undefined
|
|
1099
|
+
? later.base === null
|
|
1100
|
+
? { ...later, base: mutation.baseRevision }
|
|
1101
|
+
: later
|
|
1102
|
+
: {
|
|
1103
|
+
kind: "pending",
|
|
1104
|
+
viewerId: mutation.viewerId,
|
|
1105
|
+
tab: this.#options.tab,
|
|
1106
|
+
viewId: mutation.viewId,
|
|
1107
|
+
scope: mutation.scope,
|
|
1108
|
+
op,
|
|
1109
|
+
base: mutation.baseRevision,
|
|
1110
|
+
at: Date.now(),
|
|
1111
|
+
};
|
|
1112
|
+
this.#pending.set(key, held);
|
|
1113
|
+
void this.#options.journal.putPending(held).catch(() => undefined);
|
|
1114
|
+
this.#conflicts.set(key, {
|
|
1115
|
+
theirs,
|
|
1116
|
+
deleted: conflict.deleted === true,
|
|
1117
|
+
revision: Number(conflict.revision),
|
|
1118
|
+
...(conflict.updatedBy ? { updatedBy: conflict.updatedBy } : {}),
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/** A mutation the gateway refused: dropped from the queue, its values handled per refusal. */
|
|
1123
|
+
async #refusedMutation(mutation: MutationEntry, error: unknown): Promise<"done" | "retry"> {
|
|
1124
|
+
const code = error instanceof MostraError ? error.code : "MOSTRA_HOST_INTERNAL_ERROR";
|
|
1125
|
+
const keys = mutation.ops.map((op) => fieldKey(mutation.scope, op.id));
|
|
1126
|
+
const forget = (): void => {
|
|
1127
|
+
for (const key of keys) {
|
|
1128
|
+
const sending = this.#sending.get(key);
|
|
1129
|
+
if (sending?.mutationId === mutation.mutationId) this.#sending.delete(key);
|
|
1130
|
+
}
|
|
1131
|
+
void this.#options.journal.remove(mutation.mutationId).catch(() => undefined);
|
|
1132
|
+
};
|
|
1133
|
+
switch (code) {
|
|
1134
|
+
case "MOSTRA_HYGIENE_TOKEN_REVOKED":
|
|
1135
|
+
// The link died: the journal keeps the edits for a working link (FAILURE_MODEL §8).
|
|
1136
|
+
this.#revoked = true;
|
|
1137
|
+
this.#changed(keys);
|
|
1138
|
+
return "retry";
|
|
1139
|
+
case "MOSTRA_SUBMIT_VIEW_INVALID":
|
|
1140
|
+
// A view another link opened (an adopted entry): replay it as this page's view.
|
|
1141
|
+
if (this.view && mutation.viewId !== this.view.viewId) {
|
|
1142
|
+
const moved = { ...mutation, viewId: this.view.viewId };
|
|
1143
|
+
this.#queue[0] = moved;
|
|
1144
|
+
void this.#options.journal.promote(moved, []).catch(() => undefined);
|
|
1145
|
+
return this.#send(moved, false);
|
|
1146
|
+
}
|
|
1147
|
+
break;
|
|
1148
|
+
case "MOSTRA_SUBMIT_ALREADY":
|
|
1149
|
+
// A `once` submission closed the draft; these edits can't land (D106).
|
|
1150
|
+
this.#closed = true;
|
|
1151
|
+
forget();
|
|
1152
|
+
this.#changed(this.#allKeys());
|
|
1153
|
+
return "done";
|
|
1154
|
+
case "MOSTRA_HOME_DISK_FULL":
|
|
1155
|
+
case "MOSTRA_HOME_GONE":
|
|
1156
|
+
case "MOSTRA_HOST_INTERNAL_ERROR":
|
|
1157
|
+
// The gateway's trouble, not the edit's: it waits and retries (FAILURE_MODEL §3).
|
|
1158
|
+
this.#online = false;
|
|
1159
|
+
this.#changed(keys);
|
|
1160
|
+
return "retry";
|
|
1161
|
+
default:
|
|
1162
|
+
break;
|
|
1163
|
+
}
|
|
1164
|
+
// Refused for good (too large, an invalid ID or value): the value stays on the page, not
|
|
1165
|
+
// truncated, with the reason, and isn't sent again until the viewer changes it.
|
|
1166
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1167
|
+
for (const op of mutation.ops) {
|
|
1168
|
+
const key = fieldKey(mutation.scope, op.id);
|
|
1169
|
+
if (!this.#pending.has(key)) {
|
|
1170
|
+
const held: PendingEntry = {
|
|
1171
|
+
kind: "pending",
|
|
1172
|
+
viewerId: mutation.viewerId,
|
|
1173
|
+
tab: this.#options.tab,
|
|
1174
|
+
viewId: mutation.viewId,
|
|
1175
|
+
scope: mutation.scope,
|
|
1176
|
+
op,
|
|
1177
|
+
base: mutation.baseRevision,
|
|
1178
|
+
at: Date.now(),
|
|
1179
|
+
};
|
|
1180
|
+
this.#pending.set(key, held);
|
|
1181
|
+
// Journaled before the mutation is forgotten: the next page still has the value (GPT
|
|
1182
|
+
// review finding 7).
|
|
1183
|
+
void this.#options.journal.putPending(held).catch(() => undefined);
|
|
1184
|
+
}
|
|
1185
|
+
this.#refused.set(key, { code, message });
|
|
1186
|
+
}
|
|
1187
|
+
forget();
|
|
1188
|
+
this.#changed(keys);
|
|
1189
|
+
return "done";
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// ── The journal ────────────────────────────────────────────────────────────────────────
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* Replays what earlier pages left: this tab's entries (none on a fresh load) and those of tabs
|
|
1196
|
+
* that are gone, mutations first in their order, then pending edits (IN-02, IN-04).
|
|
1197
|
+
*/
|
|
1198
|
+
#recoverJournal(): Promise<void> {
|
|
1199
|
+
const exclusive = this.#options.exclusive ?? (<T>(fn: () => Promise<T>) => fn());
|
|
1200
|
+
return exclusive(() => this.#adoptJournal()).catch(() => undefined);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
async #adoptJournal(): Promise<void> {
|
|
1204
|
+
const viewerId = this.#viewer?.id;
|
|
1205
|
+
if (!viewerId) return;
|
|
1206
|
+
let contents: Awaited<ReturnType<Journal["load"]>>;
|
|
1207
|
+
try {
|
|
1208
|
+
contents = await this.#options.journal.load(viewerId);
|
|
1209
|
+
} catch {
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
const tabs = [
|
|
1213
|
+
...new Set([...contents.pending, ...contents.mutations].map((entry) => entry.tab)),
|
|
1214
|
+
].filter((tab) => tab !== this.#options.tab);
|
|
1215
|
+
const gone = this.#options.goneTabs ? await this.#options.goneTabs(tabs) : new Set(tabs);
|
|
1216
|
+
const mutations = contents.mutations.filter((entry) => gone.has(entry.tab));
|
|
1217
|
+
// Edits of one field left by several closed tabs: the newest stays an edit, the older ones
|
|
1218
|
+
// become mutations of their own, sent first, so each is saved or meets the others as a
|
|
1219
|
+
// conflict; adopting them under this tab would otherwise keep only one (GPT review finding 6).
|
|
1220
|
+
const pending: PendingEntry[] = [];
|
|
1221
|
+
const newest = new Map<FieldKey, PendingEntry>();
|
|
1222
|
+
for (const entry of contents.pending
|
|
1223
|
+
.filter((candidate) => gone.has(candidate.tab))
|
|
1224
|
+
.sort((a, b) => a.at - b.at)) {
|
|
1225
|
+
const key = fieldKey(entry.scope, entry.op.id);
|
|
1226
|
+
const earlier = newest.get(key);
|
|
1227
|
+
if (earlier) {
|
|
1228
|
+
const promoted: MutationEntry = {
|
|
1229
|
+
kind: "mutation",
|
|
1230
|
+
viewerId: earlier.viewerId,
|
|
1231
|
+
tab: earlier.tab,
|
|
1232
|
+
viewId: earlier.viewId,
|
|
1233
|
+
scope: earlier.scope,
|
|
1234
|
+
mutationId: MutationId.create(),
|
|
1235
|
+
baseRevision:
|
|
1236
|
+
earlier.base ?? this.#server.get(key)?.revision ?? this.#seen[earlier.scope],
|
|
1237
|
+
ops: [earlier.op],
|
|
1238
|
+
seq: earlier.at * 1000,
|
|
1239
|
+
};
|
|
1240
|
+
try {
|
|
1241
|
+
await this.#options.journal.promote(promoted, [earlier]);
|
|
1242
|
+
} catch {
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
mutations.push(promoted);
|
|
1246
|
+
pending.splice(pending.indexOf(earlier), 1);
|
|
1247
|
+
}
|
|
1248
|
+
newest.set(key, entry);
|
|
1249
|
+
pending.push(entry);
|
|
1250
|
+
}
|
|
1251
|
+
const adopted: JournalEntry[] = [...mutations.sort((a, b) => a.seq - b.seq), ...pending];
|
|
1252
|
+
if (adopted.length === 0) return;
|
|
1253
|
+
try {
|
|
1254
|
+
await this.#options.journal.adopt(adopted, this.#options.tab);
|
|
1255
|
+
} catch {
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
const touched: FieldKey[] = [];
|
|
1259
|
+
for (const entry of adopted) {
|
|
1260
|
+
if (entry.kind !== "mutation") continue;
|
|
1261
|
+
const mutation = { ...entry, tab: this.#options.tab };
|
|
1262
|
+
for (const op of mutation.ops) {
|
|
1263
|
+
const key = fieldKey(mutation.scope, op.id);
|
|
1264
|
+
touched.push(key);
|
|
1265
|
+
this.#sending.set(key, {
|
|
1266
|
+
value: op.delete ? undefined : op.value,
|
|
1267
|
+
mutationId: mutation.mutationId,
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
this.#queue.push(mutation);
|
|
1271
|
+
}
|
|
1272
|
+
for (const entry of adopted) {
|
|
1273
|
+
if (entry.kind !== "pending") continue;
|
|
1274
|
+
const key = fieldKey(entry.scope, entry.op.id);
|
|
1275
|
+
touched.push(key);
|
|
1276
|
+
if (this.#pending.has(key)) continue;
|
|
1277
|
+
const base = entry.base ?? (this.#sending.has(key) ? null : this.#baseFor(key));
|
|
1278
|
+
this.#pending.set(key, { ...entry, tab: this.#options.tab, base });
|
|
1279
|
+
}
|
|
1280
|
+
this.#changed(touched);
|
|
1281
|
+
void this.flush();
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// ── Tabs ───────────────────────────────────────────────────────────────────────────────
|
|
1285
|
+
|
|
1286
|
+
#openChannel(): void {
|
|
1287
|
+
const viewerId = this.#viewer?.id;
|
|
1288
|
+
const artifactId = this.view?.artifactId;
|
|
1289
|
+
if (!viewerId || !artifactId || !this.#options.channel) return;
|
|
1290
|
+
this.#broadcast = this.#options.channel(`mostra:${artifactId}:${viewerId}`);
|
|
1291
|
+
if (!this.#broadcast) return;
|
|
1292
|
+
this.#broadcast.onmessage = (event: MessageEvent) => {
|
|
1293
|
+
const data = event.data as {
|
|
1294
|
+
kind?: string;
|
|
1295
|
+
scope?: FieldScope;
|
|
1296
|
+
revision?: number;
|
|
1297
|
+
changes?: { id: string; value: unknown }[];
|
|
1298
|
+
};
|
|
1299
|
+
if (data.kind !== "applied" || !data.scope || typeof data.revision !== "number") return;
|
|
1300
|
+
const touched: FieldKey[] = [];
|
|
1301
|
+
for (const change of data.changes ?? []) {
|
|
1302
|
+
const key = fieldKey(data.scope, change.id);
|
|
1303
|
+
const current = this.#server.get(key);
|
|
1304
|
+
// Only ever newer: the event stream stays the source of truth (D22).
|
|
1305
|
+
if (current && current.revision >= data.revision) continue;
|
|
1306
|
+
this.#server.set(key, {
|
|
1307
|
+
value: change.value,
|
|
1308
|
+
revision: data.revision,
|
|
1309
|
+
deleted: change.value === undefined,
|
|
1310
|
+
...(current?.updatedBy ? { updatedBy: current.updatedBy } : {}),
|
|
1311
|
+
});
|
|
1312
|
+
touched.push(key);
|
|
1313
|
+
}
|
|
1314
|
+
if (touched.length > 0) this.#changed(touched);
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// ── Submitting ─────────────────────────────────────────────────────────────────────────
|
|
1319
|
+
|
|
1320
|
+
/** The submission state (INTERFACES §2.3). */
|
|
1321
|
+
submitState(): SubmitState {
|
|
1322
|
+
return this.#submitState;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/** The viewer's submission, once made or known. */
|
|
1326
|
+
submission(): SubmissionSummary | undefined {
|
|
1327
|
+
return this.#submission;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/** Whether a submission closed the viewer's draft. */
|
|
1331
|
+
get closed(): boolean {
|
|
1332
|
+
return this.#closed;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/**
|
|
1336
|
+
* Gets the draft ready for a submit (D110, IN-02): writes the initial values of mounted viewer
|
|
1337
|
+
* fields the gateway never stored, saves everything, waits until the event stream reached the
|
|
1338
|
+
* page's own last save, and reports what still blocks.
|
|
1339
|
+
*/
|
|
1340
|
+
/**
|
|
1341
|
+
* Adds a check that runs before every submit, either channel's: an adapter whose library
|
|
1342
|
+
* validates on its own schedule (react-hook-form validates on submit by default) runs its
|
|
1343
|
+
* validation here and updates its registrations, so the blockers are current. Returns the
|
|
1344
|
+
* function that removes it.
|
|
1345
|
+
*/
|
|
1346
|
+
beforeSubmit(check: () => Promise<void>): () => void {
|
|
1347
|
+
this.#beforeSubmit.add(check);
|
|
1348
|
+
return () => this.#beforeSubmit.delete(check);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
async prepareSubmit(): Promise<{
|
|
1352
|
+
readonly revision: number;
|
|
1353
|
+
readonly unsaved: boolean;
|
|
1354
|
+
readonly blockers: SubmitBlocker[];
|
|
1355
|
+
}> {
|
|
1356
|
+
if (this.mode === "local") throw targetUnavailable("Submitting");
|
|
1357
|
+
await this.whenReady(CATCH_UP_MS);
|
|
1358
|
+
// A check that fails can't stop the viewer's submit: it is the adapter's, and its own
|
|
1359
|
+
// errors stay visible on its fields.
|
|
1360
|
+
await Promise.allSettled([...this.#beforeSubmit].map((check) => check()));
|
|
1361
|
+
for (const [key, { registration }] of this.#registrations) {
|
|
1362
|
+
if (registration.scope !== "viewer" || !this.writable(key)) continue;
|
|
1363
|
+
if (this.stored(key) || this.#pending.has(key) || this.#sending.has(key)) continue;
|
|
1364
|
+
if (registration.initial === undefined) continue;
|
|
1365
|
+
this.set("viewer", registration.id, registration.initial, {
|
|
1366
|
+
...(registration.label !== undefined ? { label: registration.label } : {}),
|
|
1367
|
+
...(registration.required !== undefined ? { required: registration.required } : {}),
|
|
1368
|
+
...(registration.sensitive !== undefined ? { sensitive: registration.sensitive } : {}),
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
const flushed = await this.flush();
|
|
1372
|
+
await this.#caughtUp(flushed.revision);
|
|
1373
|
+
// A value another writer changed that the page hasn't got: the page doesn't show what would
|
|
1374
|
+
// be submitted until a snapshot brings it.
|
|
1375
|
+
if (this.#unknown.size > 0) await this.#resync();
|
|
1376
|
+
if (this.#seen.viewer < flushed.revision) {
|
|
1377
|
+
// The page's own saves landed, but its stream hasn't shown everything up to them: another
|
|
1378
|
+
// writer may have changed a field in between. It names only what it has shown, which the
|
|
1379
|
+
// gateway refuses as moved, and fetches the rest, so the viewer sees it and submits again
|
|
1380
|
+
// (FAILURE_MODEL §4; Claude review finding 3).
|
|
1381
|
+
void this.#resync();
|
|
1382
|
+
return { revision: this.#seen.viewer, unsaved: this.unsaved(), blockers: this.blockers() };
|
|
1383
|
+
}
|
|
1384
|
+
return {
|
|
1385
|
+
revision: Math.max(this.#seen.viewer, flushed.revision),
|
|
1386
|
+
unsaved: this.unsaved() || this.#unknown.size > 0,
|
|
1387
|
+
blockers: this.blockers(),
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/** Waits until the event stream reached `revision` of the draft, or a while. */
|
|
1392
|
+
#caughtUp(revision: number): Promise<void> {
|
|
1393
|
+
if (this.#seen.viewer >= revision) return Promise.resolve();
|
|
1394
|
+
return new Promise((resolve) => {
|
|
1395
|
+
const check = (): void => {
|
|
1396
|
+
if (this.#seen.viewer >= revision) resolve();
|
|
1397
|
+
else this.#catchUp.push(check);
|
|
1398
|
+
};
|
|
1399
|
+
this.#catchUp.push(check);
|
|
1400
|
+
this.#timers.setTimeout(resolve, CATCH_UP_MS);
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
#resolveCatchUp(): void {
|
|
1405
|
+
for (const check of this.#catchUp.splice(0)) check();
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/**
|
|
1409
|
+
* Submits through the artifact channel (D40), where the view allows it: prepares the draft,
|
|
1410
|
+
* refuses with `MOSTRA_SUBMIT_BLOCKED` while a field blocks, and submits the revision the
|
|
1411
|
+
* viewer saw.
|
|
1412
|
+
*/
|
|
1413
|
+
async submit(): Promise<SubmissionSummary> {
|
|
1414
|
+
if (this.mode === "local") throw targetUnavailable("Submitting");
|
|
1415
|
+
const view = this.view;
|
|
1416
|
+
if (view && !view.submitChannels.includes("artifact")) {
|
|
1417
|
+
throw new MostraError(
|
|
1418
|
+
"MOSTRA_SUBMIT_CHANNEL_DENIED",
|
|
1419
|
+
"This host takes submissions from the shell's trusted Submit only.",
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
if (view?.policy.policy === "none") {
|
|
1423
|
+
throw new MostraError(
|
|
1424
|
+
"MOSTRA_SUBMIT_POLICY_NONE",
|
|
1425
|
+
"This artifact's submission policy is none: it takes no submissions.",
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
if (!this.canSubmit()) {
|
|
1429
|
+
throw new MostraError(
|
|
1430
|
+
"MOSTRA_HYGIENE_SCOPE_DENIED",
|
|
1431
|
+
"This link doesn't carry the submit scope.",
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
const previous = this.#submitState;
|
|
1435
|
+
this.#submitState = "submitting";
|
|
1436
|
+
this.#changed();
|
|
1437
|
+
try {
|
|
1438
|
+
const prepared = await this.prepareSubmit();
|
|
1439
|
+
if (prepared.blockers.length > 0) {
|
|
1440
|
+
throw new MostraError(
|
|
1441
|
+
"MOSTRA_SUBMIT_BLOCKED",
|
|
1442
|
+
`Not submitted: ${prepared.blockers.map((b) => b.label ?? b.fieldId).join(", ")} ${prepared.blockers.length === 1 ? "needs" : "need"} attention.`,
|
|
1443
|
+
{ body: { blockers: prepared.blockers } },
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
1446
|
+
if (prepared.unsaved) {
|
|
1447
|
+
throw new MostraError(
|
|
1448
|
+
"MOSTRA_SUBMIT_BLOCKED",
|
|
1449
|
+
"Not submitted: some answers aren't saved yet. Try again when the page is back online.",
|
|
1450
|
+
{ body: { blockers: [] } },
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
const submission = await this.#transport().submit({
|
|
1454
|
+
viewId: view?.viewId ?? "",
|
|
1455
|
+
expectedRevision: String(prepared.revision),
|
|
1456
|
+
channel: "artifact",
|
|
1457
|
+
});
|
|
1458
|
+
this.#adoptSubmission(submission, false);
|
|
1459
|
+
// The page sees the submission, and a closed draft, at once (IN-13).
|
|
1460
|
+
this.#changed(this.#allKeys());
|
|
1461
|
+
return submission;
|
|
1462
|
+
} catch (error) {
|
|
1463
|
+
if (error instanceof MostraError && error.code === "MOSTRA_SUBMIT_ALREADY") {
|
|
1464
|
+
const existing = error.body?.["submission"] as SubmissionSummary | undefined;
|
|
1465
|
+
if (existing) this.#adoptSubmission(existing, false);
|
|
1466
|
+
else this.#submitState = "closed";
|
|
1467
|
+
} else {
|
|
1468
|
+
this.#submitState = previous === "submitting" ? "open" : previous;
|
|
1469
|
+
if (error instanceof MostraError && error.code === "MOSTRA_SUBMIT_REVISION_MOVED") {
|
|
1470
|
+
// The draft changed elsewhere: the page shows it before the viewer submits again.
|
|
1471
|
+
await this.#resync();
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
this.#changed();
|
|
1475
|
+
throw error;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/** Raises a signal for the host's attention (IN-09). */
|
|
1480
|
+
async signal(name: string, payload?: unknown): Promise<void> {
|
|
1481
|
+
if (this.mode === "local") throw targetUnavailable("Sending a signal");
|
|
1482
|
+
if (!SIGNAL_NAME_PATTERN.test(name) || name === "submit") {
|
|
1483
|
+
throw new MostraError(
|
|
1484
|
+
"MOSTRA_SUBMIT_SIGNAL_INVALID",
|
|
1485
|
+
`${JSON.stringify(name)} isn't a signal name: use letters, digits, ".", "_" and "-"; "submit" is the shell's.`,
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
await this.#transport().signal({
|
|
1489
|
+
viewId: this.view?.viewId ?? "",
|
|
1490
|
+
name,
|
|
1491
|
+
...(payload !== undefined ? { payload } : {}),
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/** Takes a submission the page made, or learned of, as the viewer's. */
|
|
1496
|
+
adoptSubmission(submission: SubmissionSummary): void {
|
|
1497
|
+
this.#adoptSubmission(submission, false);
|
|
1498
|
+
this.#changed();
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
#adoptSubmission(incoming: SubmissionSummary, fromSnapshot: boolean): void {
|
|
1502
|
+
const submission = mergedSubmission(this.#submission, incoming);
|
|
1503
|
+
this.#submission = submission;
|
|
1504
|
+
// Delivery events the stream brought before this submission was known (Claude review
|
|
1505
|
+
// finding 8): applied now, so the state never goes back.
|
|
1506
|
+
const early = this.#earlyDeliveries.filter(
|
|
1507
|
+
(event) => (event.payload as { submissionId?: string }).submissionId === submission.id,
|
|
1508
|
+
);
|
|
1509
|
+
this.#earlyDeliveries = this.#earlyDeliveries.filter((event) => !early.includes(event));
|
|
1510
|
+
const policy = this.view?.policy;
|
|
1511
|
+
// Under `epochs` the draft goes on to the next round; under `once` it is closed.
|
|
1512
|
+
if (policy?.policy !== "epochs" || submission.epoch === this.#epoch) {
|
|
1513
|
+
if (policy?.policy === "once" || policy === undefined) this.#closed = true;
|
|
1514
|
+
}
|
|
1515
|
+
if (policy?.policy === "epochs" && fromSnapshot && submission.epoch < this.#epoch) {
|
|
1516
|
+
this.#submitState = "open";
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
this.#submitState = deliveryState(submission);
|
|
1520
|
+
for (const event of early) this.#deliveryEvent(event);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
#deliveryEvent(event: EventRecord): void {
|
|
1524
|
+
const submission = this.#submission;
|
|
1525
|
+
const payload = event.payload as {
|
|
1526
|
+
submissionId?: string;
|
|
1527
|
+
deliveryId?: string;
|
|
1528
|
+
owner?: string;
|
|
1529
|
+
reason?: string;
|
|
1530
|
+
};
|
|
1531
|
+
if (!submission || payload.submissionId !== submission.id) {
|
|
1532
|
+
this.#earlyDeliveries = [...this.#earlyDeliveries, event].slice(-EARLY_DELIVERIES_MAX);
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
const kind = event.type.slice("submission.".length);
|
|
1536
|
+
const state =
|
|
1537
|
+
kind === "delivered"
|
|
1538
|
+
? "delivered"
|
|
1539
|
+
: kind === "rejected"
|
|
1540
|
+
? "rejected"
|
|
1541
|
+
: kind === "undeliverable"
|
|
1542
|
+
? "undeliverable"
|
|
1543
|
+
: "pending";
|
|
1544
|
+
const deliveries = (submission.deliveries ?? []).map((delivery) =>
|
|
1545
|
+
delivery.deliveryId === payload.deliveryId
|
|
1546
|
+
? {
|
|
1547
|
+
...delivery,
|
|
1548
|
+
state,
|
|
1549
|
+
...(kind === "retrying" ? { waiting: true as const } : {}),
|
|
1550
|
+
...(payload.reason !== undefined && kind !== "retrying"
|
|
1551
|
+
? { reason: payload.reason }
|
|
1552
|
+
: {}),
|
|
1553
|
+
updatedAt: event.at,
|
|
1554
|
+
}
|
|
1555
|
+
: delivery,
|
|
1556
|
+
) as NonNullable<SubmissionSummary["deliveries"]>;
|
|
1557
|
+
this.#submission = { ...submission, deliveries, delivery: worst(deliveries) };
|
|
1558
|
+
this.#submitState = deliveryState(this.#submission);
|
|
1559
|
+
this.#changed();
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
// ── Local mode ─────────────────────────────────────────────────────────────────────────
|
|
1563
|
+
|
|
1564
|
+
#localStorage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> | undefined {
|
|
1565
|
+
if (this.#options.local) return this.#options.local;
|
|
1566
|
+
try {
|
|
1567
|
+
return typeof localStorage === "undefined" ? undefined : localStorage;
|
|
1568
|
+
} catch {
|
|
1569
|
+
return undefined;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
#localRead(key: FieldKey): unknown {
|
|
1574
|
+
const text = this.#localStorage()?.getItem(`mostra.field.${key}`);
|
|
1575
|
+
if (text === null || text === undefined) return undefined;
|
|
1576
|
+
try {
|
|
1577
|
+
return JSON.parse(text);
|
|
1578
|
+
} catch {
|
|
1579
|
+
return undefined;
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
#localWrite(key: FieldKey, value: unknown): void {
|
|
1584
|
+
const storage = this.#localStorage();
|
|
1585
|
+
try {
|
|
1586
|
+
if (value === undefined) storage?.removeItem(`mostra.field.${key}`);
|
|
1587
|
+
else storage?.setItem(`mostra.field.${key}`, JSON.stringify(value));
|
|
1588
|
+
} catch {
|
|
1589
|
+
// A full or refused localStorage keeps the value for this page only.
|
|
1590
|
+
this.#pending.set(key, {
|
|
1591
|
+
kind: "pending",
|
|
1592
|
+
viewerId: "",
|
|
1593
|
+
tab: this.#options.tab,
|
|
1594
|
+
viewId: "",
|
|
1595
|
+
scope: keyParts(key).scope,
|
|
1596
|
+
op:
|
|
1597
|
+
value === undefined
|
|
1598
|
+
? { id: keyParts(key).id, delete: true }
|
|
1599
|
+
: { id: keyParts(key).id, value },
|
|
1600
|
+
base: 0,
|
|
1601
|
+
at: Date.now(),
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
#localIds(scope: FieldScope): string[] {
|
|
1607
|
+
const storage = this.#localStorage() as Storage | undefined;
|
|
1608
|
+
if (!storage || typeof storage.length !== "number") return [];
|
|
1609
|
+
const prefix = `mostra.field.${scope}:`;
|
|
1610
|
+
const ids: string[] = [];
|
|
1611
|
+
for (let index = 0; index < storage.length; index += 1) {
|
|
1612
|
+
const name = storage.key(index);
|
|
1613
|
+
if (name?.startsWith(prefix)) ids.push(name.slice(prefix.length));
|
|
1614
|
+
}
|
|
1615
|
+
return ids;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
const SEVERITY = { delivered: 0, pending: 1, rejected: 2, undeliverable: 3 } as const;
|
|
1620
|
+
|
|
1621
|
+
function worst(
|
|
1622
|
+
deliveries: NonNullable<SubmissionSummary["deliveries"]>,
|
|
1623
|
+
): SubmissionSummary["delivery"] {
|
|
1624
|
+
let result: SubmissionSummary["delivery"] = "delivered";
|
|
1625
|
+
for (const delivery of deliveries) {
|
|
1626
|
+
if (SEVERITY[delivery.state] > SEVERITY[result]) result = delivery.state;
|
|
1627
|
+
}
|
|
1628
|
+
return result;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
/** The submission's state as the viewer sees it (IN-13, D47). */
|
|
1632
|
+
export function deliveryState(submission: SubmissionSummary): SubmitState {
|
|
1633
|
+
const deliveries = submission.deliveries ?? [];
|
|
1634
|
+
if (submission.delivery === "undeliverable" || submission.delivery === "rejected") {
|
|
1635
|
+
return "undeliverable";
|
|
1636
|
+
}
|
|
1637
|
+
if (submission.delivery === "delivered") return "delivered";
|
|
1638
|
+
return deliveries.some((delivery) => delivery.state === "pending" && delivery.waiting)
|
|
1639
|
+
? "pending-delivery"
|
|
1640
|
+
: "submitted";
|
|
1641
|
+
}
|