@helipod/client 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/dist/chunk-DW55SNHW.js +286 -0
- package/dist/chunk-DW55SNHW.js.map +1 -0
- package/dist/chunk-I7ZJFW4E.js +2232 -0
- package/dist/chunk-I7ZJFW4E.js.map +1 -0
- package/dist/client-mJZjEkhK.d.ts +1144 -0
- package/dist/index.d.ts +268 -0
- package/dist/index.js +642 -0
- package/dist/index.js.map +1 -0
- package/dist/outbox-fs.d.ts +102 -0
- package/dist/outbox-fs.js +361 -0
- package/dist/outbox-fs.js.map +1 -0
- package/dist/outbox-storage-C6VHkXs9.d.ts +191 -0
- package/dist/react.d.ts +114 -0
- package/dist/react.js +139 -0
- package/dist/react.js.map +1 -0
- package/package.json +72 -0
|
@@ -0,0 +1,2232 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_OUTBOX_MAX_QUEUE_SIZE,
|
|
3
|
+
OUTBOX_VERSION,
|
|
4
|
+
OfflineClientResetError,
|
|
5
|
+
OutboxOverflowError,
|
|
6
|
+
defaultMintClientId,
|
|
7
|
+
mintIdentity
|
|
8
|
+
} from "./chunk-DW55SNHW.js";
|
|
9
|
+
|
|
10
|
+
// src/api.ts
|
|
11
|
+
function getFunctionPath(ref) {
|
|
12
|
+
return typeof ref === "string" ? ref : ref.__path;
|
|
13
|
+
}
|
|
14
|
+
function makeProxy(segments) {
|
|
15
|
+
return new Proxy(
|
|
16
|
+
{},
|
|
17
|
+
{
|
|
18
|
+
get(_target, prop) {
|
|
19
|
+
if (typeof prop !== "string") return void 0;
|
|
20
|
+
if (prop === "__path") {
|
|
21
|
+
if (segments.length < 2) return segments.join(":");
|
|
22
|
+
return `${segments.slice(0, -1).join("/")}:${segments[segments.length - 1]}`;
|
|
23
|
+
}
|
|
24
|
+
return makeProxy([...segments, prop]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
var anyApi = makeProxy([]);
|
|
30
|
+
|
|
31
|
+
// src/delivery-policy.ts
|
|
32
|
+
var MutationUndeliveredError = class extends Error {
|
|
33
|
+
constructor(message = "mutation outcome unknown: the connection closed before a response arrived") {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "MutationUndeliveredError";
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
function closeDisposition(entries, opts = {}) {
|
|
39
|
+
const armed = opts.armed ?? false;
|
|
40
|
+
const reject = [];
|
|
41
|
+
const drop = [];
|
|
42
|
+
const retain = [];
|
|
43
|
+
const park = [];
|
|
44
|
+
for (const e of entries) {
|
|
45
|
+
switch (e.status.type) {
|
|
46
|
+
case "unsent":
|
|
47
|
+
retain.push(e.requestId);
|
|
48
|
+
break;
|
|
49
|
+
case "parked":
|
|
50
|
+
retain.push(e.requestId);
|
|
51
|
+
break;
|
|
52
|
+
case "inflight":
|
|
53
|
+
if (armed && e.durable) {
|
|
54
|
+
park.push(e.requestId);
|
|
55
|
+
drop.push(e.requestId);
|
|
56
|
+
} else {
|
|
57
|
+
reject.push(e.requestId);
|
|
58
|
+
drop.push(e.requestId);
|
|
59
|
+
}
|
|
60
|
+
break;
|
|
61
|
+
case "completed":
|
|
62
|
+
drop.push(e.requestId);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { reject, drop, retain, park };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/optimistic-store.ts
|
|
70
|
+
function isDevMode() {
|
|
71
|
+
const env = globalThis.process?.env;
|
|
72
|
+
return env?.NODE_ENV !== "production";
|
|
73
|
+
}
|
|
74
|
+
function freezeDev(value) {
|
|
75
|
+
if (isDevMode() && value !== null && typeof value === "object") Object.freeze(value);
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
function createOptimisticLocalStore(view, seed) {
|
|
79
|
+
const ordinals = /* @__PURE__ */ new Map();
|
|
80
|
+
return {
|
|
81
|
+
getQuery: (ref, args) => freezeDev(view.getQuery(ref, args)),
|
|
82
|
+
setQuery: (ref, args, value) => view.setQuery(ref, args, value),
|
|
83
|
+
getAllQueries: (ref) => view.getAllQueries(ref).map(({ args, value }) => ({
|
|
84
|
+
args,
|
|
85
|
+
value: freezeDev(value)
|
|
86
|
+
})),
|
|
87
|
+
placeholderId: (table) => {
|
|
88
|
+
const n = ordinals.get(table) ?? 0;
|
|
89
|
+
ordinals.set(table, n + 1);
|
|
90
|
+
return `${seed.entropy}:${table}:${n}`;
|
|
91
|
+
},
|
|
92
|
+
now: () => seed.now
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/outbox-drain.ts
|
|
97
|
+
import { jsonToConvex } from "@helipod/values";
|
|
98
|
+
var DRAIN_INITIAL_BACKOFF_MS = 250;
|
|
99
|
+
var DRAIN_BACKOFF_BASE = 2;
|
|
100
|
+
var DRAIN_MAX_BACKOFF_MS = 3e4;
|
|
101
|
+
function computeDrainBackoff(attempts, rng = Math.random) {
|
|
102
|
+
const raw = DRAIN_INITIAL_BACKOFF_MS * DRAIN_BACKOFF_BASE ** (attempts + 1);
|
|
103
|
+
const jittered = Math.round(raw * (0.5 + 0.5 * rng()));
|
|
104
|
+
return Math.min(jittered, DRAIN_MAX_BACKOFF_MS);
|
|
105
|
+
}
|
|
106
|
+
var DEFAULT_DRAIN_CHUNK_SIZE = 50;
|
|
107
|
+
var DEFAULT_DRAIN_INTERVAL_MS = 5e3;
|
|
108
|
+
function probeLockManager() {
|
|
109
|
+
const nav = globalThis.navigator;
|
|
110
|
+
const locks = nav?.locks;
|
|
111
|
+
if (locks && typeof locks.request === "function") {
|
|
112
|
+
return {
|
|
113
|
+
request: (name, options, callback) => locks.request(name, options, callback)
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return void 0;
|
|
117
|
+
}
|
|
118
|
+
var OFFLINE_IDENTITY_CHANGED = "OFFLINE_IDENTITY_CHANGED";
|
|
119
|
+
var AUTH_REFRESH_UDF_PATH = "auth:refresh";
|
|
120
|
+
var NON_REPLAYABLE_MUTATION_DROPPED = "NON_REPLAYABLE_MUTATION_DROPPED";
|
|
121
|
+
function dropIfNonReplayable(outbox, entry) {
|
|
122
|
+
if (entry.udfPath !== AUTH_REFRESH_UDF_PATH) return false;
|
|
123
|
+
console.error(
|
|
124
|
+
`[helipod] outbox: dropping a queued "${entry.udfPath}" entry at hydrate \u2014 replaying an auth refresh is never safe (a stale rotation would trip reuse-detection and force-sign-out an honest user); this entry predates the client's transient-mutation fix`
|
|
125
|
+
);
|
|
126
|
+
void outbox.updateStatus(entry.clientId, entry.seq, "failed", {
|
|
127
|
+
message: `mutation "${entry.udfPath}" dropped at hydrate: durable auth-refresh replay is never safe`,
|
|
128
|
+
code: NON_REPLAYABLE_MUTATION_DROPPED
|
|
129
|
+
}).catch(() => {
|
|
130
|
+
});
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
var OutboxDrain = class {
|
|
134
|
+
host;
|
|
135
|
+
lockName;
|
|
136
|
+
locksOption;
|
|
137
|
+
poisonPolicy;
|
|
138
|
+
chunkSize;
|
|
139
|
+
intervalMs;
|
|
140
|
+
backoffMs;
|
|
141
|
+
onPause;
|
|
142
|
+
started = false;
|
|
143
|
+
leader = false;
|
|
144
|
+
stopped = false;
|
|
145
|
+
paused = false;
|
|
146
|
+
hydrated = false;
|
|
147
|
+
/** The in-flight chunk: requestId → in-log entry. Non-null iff one unacked chunk is outstanding. */
|
|
148
|
+
active = null;
|
|
149
|
+
/** Re-entrancy guard around `maybeDrainNext`'s `await whenBaselineAdopted()` (one flush at a time). */
|
|
150
|
+
flushScheduling = false;
|
|
151
|
+
/** Consecutive transient-stop count — the backoff attempt number (reset on any forward progress). */
|
|
152
|
+
transientAttempts = 0;
|
|
153
|
+
abort = new AbortController();
|
|
154
|
+
releaseLeadership;
|
|
155
|
+
intervalTimer;
|
|
156
|
+
backoffTimer;
|
|
157
|
+
constructor(host, opts) {
|
|
158
|
+
this.host = host;
|
|
159
|
+
this.lockName = opts.lockName;
|
|
160
|
+
this.locksOption = opts.locks;
|
|
161
|
+
this.poisonPolicy = opts.poisonPolicy ?? "skip";
|
|
162
|
+
this.chunkSize = opts.chunkSize ?? DEFAULT_DRAIN_CHUNK_SIZE;
|
|
163
|
+
this.intervalMs = opts.intervalMs ?? DEFAULT_DRAIN_INTERVAL_MS;
|
|
164
|
+
this.backoffMs = opts.backoffMs ?? ((attempts) => computeDrainBackoff(attempts));
|
|
165
|
+
this.onPause = opts.onPause;
|
|
166
|
+
}
|
|
167
|
+
/** Acquire leadership (Web Locks, or single-tab fallback) and, once leader, hydrate + drain. Safe
|
|
168
|
+
* to call once; a no-op afterward. */
|
|
169
|
+
start() {
|
|
170
|
+
if (this.started) return;
|
|
171
|
+
this.started = true;
|
|
172
|
+
const locks = this.locksOption === void 0 ? probeLockManager() : this.locksOption ?? void 0;
|
|
173
|
+
if (!locks) {
|
|
174
|
+
queueMicrotask(() => void this.becomeLeader());
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
void locks.request(this.lockName, { signal: this.abort.signal }, async () => {
|
|
178
|
+
if (this.stopped) return;
|
|
179
|
+
await this.becomeLeader();
|
|
180
|
+
}).catch(() => {
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/** Stop cleanly (client close, or mid-drain lock loss). Releases leadership, cancels a pending
|
|
184
|
+
* lock request, and clears every timer — the durable records make a successor leader safe. */
|
|
185
|
+
stop() {
|
|
186
|
+
if (this.stopped) return;
|
|
187
|
+
this.stopped = true;
|
|
188
|
+
this.leader = false;
|
|
189
|
+
this.active = null;
|
|
190
|
+
if (this.intervalTimer !== void 0) clearInterval(this.intervalTimer);
|
|
191
|
+
if (this.backoffTimer !== void 0) clearTimeout(this.backoffTimer);
|
|
192
|
+
this.intervalTimer = void 0;
|
|
193
|
+
this.backoffTimer = void 0;
|
|
194
|
+
this.abort.abort();
|
|
195
|
+
this.releaseLeadership?.();
|
|
196
|
+
}
|
|
197
|
+
/** Wake the drain (enqueue / reconnect-after-baseline / an explicit nudge). Deferred to a
|
|
198
|
+
* microtask so a synchronous settling frame (e.g. a `ConnectAck` emitted right after reopen)
|
|
199
|
+
* always settles its entries BEFORE the drain re-reads the queue. */
|
|
200
|
+
nudge() {
|
|
201
|
+
if (this.stopped || !this.leader) return;
|
|
202
|
+
queueMicrotask(() => void this.maybeDrainNext());
|
|
203
|
+
}
|
|
204
|
+
/** True iff `requestId` belongs to the drain's in-flight chunk — `client.ts` routes that unit's
|
|
205
|
+
* `MutationResponse` here instead of down the direct-send path. */
|
|
206
|
+
handles(requestId) {
|
|
207
|
+
return this.active?.has(requestId) ?? false;
|
|
208
|
+
}
|
|
209
|
+
/** The transport dropped (a reconnect-class close, NOT the client's `close()` — leadership and
|
|
210
|
+
* the interval survive). The in-flight chunk's unresponded units will never get a response on
|
|
211
|
+
* the new server session, so revert them to re-sendable and clear the chunk NOW — otherwise
|
|
212
|
+
* `canDrain()`'s one-unacked invariant (`active === null`) would wedge the drain for the rest of
|
|
213
|
+
* this tab session (no chunk would ever flush again, and every new mutation would queue behind
|
|
214
|
+
* the stuck backlog until overflow). Called by `client.ts#onTransportClosed` BEFORE the
|
|
215
|
+
* reconciler's S4 close rules run, so the reverted (`unsent`) units are simply retained by the
|
|
216
|
+
* close disposition, promises pending, ready for the reconnect handshake + re-drain. A pending
|
|
217
|
+
* transient-backoff timer is also cleared — the reconnect handshake re-drives the drain, and
|
|
218
|
+
* `canDrain()` would no-op a stale timer against a closed transport anyway. */
|
|
219
|
+
onTransportClosed() {
|
|
220
|
+
this.revertActive();
|
|
221
|
+
this.transientAttempts = 0;
|
|
222
|
+
if (this.backoffTimer !== void 0) {
|
|
223
|
+
clearTimeout(this.backoffTimer);
|
|
224
|
+
this.backoffTimer = void 0;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** True while an unacked chunk is in flight — `client.ts#hasOutboxBacklog` counts it so a new
|
|
228
|
+
* `mutation()` enqueues BEHIND the chunk instead of direct-sending ahead of it (the FIFO rule:
|
|
229
|
+
* "while the queue is non-empty, new mutations enqueue behind it"). A chunk's units are
|
|
230
|
+
* `inflight`, which the unsent/parked backlog scan alone would miss when the chunk consumed the
|
|
231
|
+
* entire backlog. */
|
|
232
|
+
get hasActiveChunk() {
|
|
233
|
+
return this.active !== null;
|
|
234
|
+
}
|
|
235
|
+
/** @internal test/debug — the drain halted on a coded failure under `poisonPolicy: "pause"`. */
|
|
236
|
+
get isPaused() {
|
|
237
|
+
return this.paused;
|
|
238
|
+
}
|
|
239
|
+
/** @internal test/debug — the drain currently holds leadership. */
|
|
240
|
+
get isLeader() {
|
|
241
|
+
return this.leader;
|
|
242
|
+
}
|
|
243
|
+
/** Resume a `pause`d drain (T5 owns the app-facing retry surface; this is the mechanism). */
|
|
244
|
+
resume() {
|
|
245
|
+
if (!this.paused) return;
|
|
246
|
+
this.paused = false;
|
|
247
|
+
this.transientAttempts = 0;
|
|
248
|
+
this.nudge();
|
|
249
|
+
}
|
|
250
|
+
async becomeLeader() {
|
|
251
|
+
if (this.stopped) return;
|
|
252
|
+
this.leader = true;
|
|
253
|
+
await this.hydrateOnce();
|
|
254
|
+
if (this.stopped) return;
|
|
255
|
+
if (this.host.transportOpen() && this.host.drainable().length > 0) this.host.ensureInitialHandshake();
|
|
256
|
+
this.startInterval();
|
|
257
|
+
void this.maybeDrainNext();
|
|
258
|
+
await new Promise((resolve) => {
|
|
259
|
+
this.releaseLeadership = resolve;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
startInterval() {
|
|
263
|
+
if (this.intervalTimer !== void 0 || this.intervalMs <= 0) return;
|
|
264
|
+
this.intervalTimer = setInterval(() => void this.maybeDrainNext(), this.intervalMs);
|
|
265
|
+
this.intervalTimer.unref?.();
|
|
266
|
+
}
|
|
267
|
+
/** Load the durable queue into the log (once), under recorded ids, then prune dead meta rows.
|
|
268
|
+
* Only STILL-ACTIVE entries (`unsent`/`inflight`/`parked`) are hydrated into the log — a `failed`
|
|
269
|
+
* entry left behind by a prior session is a terminal, accessor-only record (surfaced via
|
|
270
|
+
* `pendingMutations()`/the constructor's `refireDurableFailures` R9 scan) and must never be
|
|
271
|
+
* resurrected here as a fresh `unsent` log entry: `host.addHydrated` unconditionally stamps
|
|
272
|
+
* `status: "unsent"` regardless of the persisted status, so hydrating a `failed` row verbatim
|
|
273
|
+
* would render a phantom optimistic row for an already-dead mutation AND make it drainable again
|
|
274
|
+
* (a resend of something the server/R9 already settled), on top of double-firing R9 alongside the
|
|
275
|
+
* constructor's unconditional resume scan. Mirrors the same filter `mirrorFromStore`'s cross-tab
|
|
276
|
+
* backstop applies (`client.ts`). */
|
|
277
|
+
async hydrateOnce() {
|
|
278
|
+
if (this.hydrated) return;
|
|
279
|
+
this.hydrated = true;
|
|
280
|
+
const { entries } = await this.host.outbox.loadAll();
|
|
281
|
+
for (const e of entries) {
|
|
282
|
+
if (e.status !== "unsent" && e.status !== "inflight" && e.status !== "parked") continue;
|
|
283
|
+
if (dropIfNonReplayable(this.host.outbox, e)) continue;
|
|
284
|
+
this.host.addHydrated(e);
|
|
285
|
+
}
|
|
286
|
+
await this.pruneDeadMeta();
|
|
287
|
+
}
|
|
288
|
+
/** Delete meta rows for clientIds with no live entries and that aren't the current session's — the
|
|
289
|
+
* T1-flagged unbounded-tiny-rows gap (one dead row accrues per prior tab-session + every reset).
|
|
290
|
+
* Capability-gated: a minimal `OutboxStorage` (a bare test double) may omit the two optional
|
|
291
|
+
* methods, in which case pruning is simply skipped. */
|
|
292
|
+
async pruneDeadMeta() {
|
|
293
|
+
const list = this.host.outbox.listMetaClientIds;
|
|
294
|
+
const del = this.host.outbox.deleteMeta;
|
|
295
|
+
if (!list || !del) return;
|
|
296
|
+
let ids;
|
|
297
|
+
try {
|
|
298
|
+
ids = await list.call(this.host.outbox);
|
|
299
|
+
} catch {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const current = this.host.currentClientId();
|
|
303
|
+
const live = /* @__PURE__ */ new Set();
|
|
304
|
+
for (const e of this.host.drainable()) if (e.clientId !== void 0) live.add(e.clientId);
|
|
305
|
+
for (const id of ids) {
|
|
306
|
+
if (id === current || live.has(id)) continue;
|
|
307
|
+
try {
|
|
308
|
+
await del.call(this.host.outbox, id);
|
|
309
|
+
} catch {
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
canDrain() {
|
|
314
|
+
return this.leader && !this.stopped && !this.paused && this.active === null && this.host.transportOpen() && this.host.isArmed() && this.host.drainable().length > 0;
|
|
315
|
+
}
|
|
316
|
+
/** Flush the next chunk when eligible. The `flushScheduling` guard + the synchronous `this.active`
|
|
317
|
+
* set (in `flushChunk`) keep exactly one chunk in flight across the `await` yield. */
|
|
318
|
+
async maybeDrainNext() {
|
|
319
|
+
if (this.flushScheduling || !this.canDrain()) return;
|
|
320
|
+
this.flushScheduling = true;
|
|
321
|
+
try {
|
|
322
|
+
await this.host.whenBaselineAdopted();
|
|
323
|
+
if (!this.canDrain()) return;
|
|
324
|
+
this.flushChunk();
|
|
325
|
+
} finally {
|
|
326
|
+
this.flushScheduling = false;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
flushChunk() {
|
|
330
|
+
const all = this.host.drainable();
|
|
331
|
+
const currentFingerprint = this.host.currentFingerprint();
|
|
332
|
+
const chunk = [];
|
|
333
|
+
const map = /* @__PURE__ */ new Map();
|
|
334
|
+
let settledIdentityFailure = false;
|
|
335
|
+
for (const entry of all) {
|
|
336
|
+
if (chunk.length >= this.chunkSize) break;
|
|
337
|
+
if (entry.identityFingerprint !== void 0 && entry.identityFingerprint !== currentFingerprint) {
|
|
338
|
+
console.error(
|
|
339
|
+
`[helipod] outbox: dropping mutation "${entry.udfPath}" \u2014 it was queued under a different identity than the current session (${OFFLINE_IDENTITY_CHANGED})`
|
|
340
|
+
);
|
|
341
|
+
this.host.settleTerminal(
|
|
342
|
+
entry.requestId,
|
|
343
|
+
OFFLINE_IDENTITY_CHANGED,
|
|
344
|
+
`mutation "${entry.udfPath}" dropped: the session identity changed since it was queued`
|
|
345
|
+
);
|
|
346
|
+
settledIdentityFailure = true;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
this.host.setStatus(entry, "inflight");
|
|
350
|
+
map.set(entry.requestId, entry);
|
|
351
|
+
chunk.push(this.host.batchEntry(entry));
|
|
352
|
+
}
|
|
353
|
+
if (chunk.length === 0) {
|
|
354
|
+
if (settledIdentityFailure) this.nudge();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
this.active = map;
|
|
358
|
+
this.host.sendBatch(chunk);
|
|
359
|
+
}
|
|
360
|
+
/** Route one unit's `MutationResponse` (only ever called for a requestId in the active chunk). */
|
|
361
|
+
onResponse(msg) {
|
|
362
|
+
const active = this.active;
|
|
363
|
+
const entry = active?.get(msg.requestId);
|
|
364
|
+
if (!active || !entry) return;
|
|
365
|
+
if (msg.success) {
|
|
366
|
+
active.delete(msg.requestId);
|
|
367
|
+
const value = this.resolveResponseValue(msg);
|
|
368
|
+
this.host.settleApplied(msg.requestId, value, msg.replayed === true, msg.ts);
|
|
369
|
+
this.onForwardProgress();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (msg.code !== void 0) {
|
|
373
|
+
if (this.poisonPolicy === "pause") {
|
|
374
|
+
this.paused = true;
|
|
375
|
+
console.error(
|
|
376
|
+
`[helipod] outbox drain PAUSED on a coded failure of "${entry.udfPath}" (${msg.code}); poisonPolicy="pause" \u2014 the queue is halted until resumed`
|
|
377
|
+
);
|
|
378
|
+
this.onPause?.({ requestId: msg.requestId, udfPath: entry.udfPath, code: msg.code });
|
|
379
|
+
this.revertActive();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
active.delete(msg.requestId);
|
|
383
|
+
this.host.settleTerminal(msg.requestId, msg.code, `mutation "${entry.udfPath}" failed`);
|
|
384
|
+
this.onForwardProgress();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
this.transientAttempts++;
|
|
388
|
+
this.revertActive();
|
|
389
|
+
const delay = this.backoffMs(this.transientAttempts);
|
|
390
|
+
this.backoffTimer = setTimeout(() => {
|
|
391
|
+
this.backoffTimer = void 0;
|
|
392
|
+
void this.maybeDrainNext();
|
|
393
|
+
}, delay);
|
|
394
|
+
this.backoffTimer.unref?.();
|
|
395
|
+
}
|
|
396
|
+
resolveResponseValue(msg) {
|
|
397
|
+
if (msg.valueMissing) return null;
|
|
398
|
+
return msg.value !== void 0 ? jsonToConvex(msg.value) : null;
|
|
399
|
+
}
|
|
400
|
+
/** A unit settled (applied or coded-terminal): if the chunk is now empty, advance to the next. */
|
|
401
|
+
onForwardProgress() {
|
|
402
|
+
this.transientAttempts = 0;
|
|
403
|
+
if (this.active && this.active.size === 0) {
|
|
404
|
+
this.active = null;
|
|
405
|
+
this.nudge();
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/** Revert every still-in-flight entry of the active chunk to `unsent` (re-sendable) and clear the
|
|
409
|
+
* chunk — used by the transient-stop and pause paths. The units that got no response are exactly
|
|
410
|
+
* those still in `active`. */
|
|
411
|
+
revertActive() {
|
|
412
|
+
if (!this.active) return;
|
|
413
|
+
for (const entry of this.active.values()) this.host.setStatus(entry, "unsent");
|
|
414
|
+
this.active = null;
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// src/connect-handshake.ts
|
|
419
|
+
var HELD_STATUSES = /* @__PURE__ */ new Set(["unsent", "inflight", "parked"]);
|
|
420
|
+
function outboxHeldFromLog(entries) {
|
|
421
|
+
const refs = [];
|
|
422
|
+
for (const e of entries) {
|
|
423
|
+
if (e.clientId !== void 0 && e.seq !== void 0 && HELD_STATUSES.has(e.status.type)) {
|
|
424
|
+
refs.push({ clientId: e.clientId, seq: e.seq });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return refs;
|
|
428
|
+
}
|
|
429
|
+
function outboxHeldFromStore(entries) {
|
|
430
|
+
const refs = [];
|
|
431
|
+
for (const e of entries) {
|
|
432
|
+
if (HELD_STATUSES.has(e.status)) refs.push({ clientId: e.clientId, seq: e.seq });
|
|
433
|
+
}
|
|
434
|
+
return refs;
|
|
435
|
+
}
|
|
436
|
+
function outboxAckedThrough(held) {
|
|
437
|
+
const lowestHeld = /* @__PURE__ */ new Map();
|
|
438
|
+
for (const ref of held) {
|
|
439
|
+
const cur = lowestHeld.get(ref.clientId);
|
|
440
|
+
if (cur === void 0 || ref.seq < cur) lowestHeld.set(ref.clientId, ref.seq);
|
|
441
|
+
}
|
|
442
|
+
const acked = [];
|
|
443
|
+
for (const [clientId, minSeq] of lowestHeld) {
|
|
444
|
+
if (minSeq > 0) acked.push({ clientId, seq: minSeq - 1 });
|
|
445
|
+
}
|
|
446
|
+
return acked;
|
|
447
|
+
}
|
|
448
|
+
function buildConnectMessage(sessionId, clientId, held) {
|
|
449
|
+
return {
|
|
450
|
+
type: "Connect",
|
|
451
|
+
sessionId,
|
|
452
|
+
...clientId !== void 0 ? { clientId } : {},
|
|
453
|
+
held,
|
|
454
|
+
ackedThrough: outboxAckedThrough(held),
|
|
455
|
+
// DLR Stage 2a: advertise by-id diff support. The server records this on any `Connect` (even a
|
|
456
|
+
// resume-handshake one) and sends `QueryDiff` only to a session that advertised it.
|
|
457
|
+
supportsQueryDiff: true
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// src/identity-fingerprint.ts
|
|
462
|
+
async function sha256Hex(input) {
|
|
463
|
+
const bytes = new TextEncoder().encode(input);
|
|
464
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
465
|
+
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
466
|
+
}
|
|
467
|
+
function sessionFingerprintKey(sessionId) {
|
|
468
|
+
return `session:${sessionId}`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// src/client.ts
|
|
472
|
+
import {
|
|
473
|
+
versionsEqual,
|
|
474
|
+
INITIAL_VERSION
|
|
475
|
+
} from "@helipod/sync";
|
|
476
|
+
import { convexToJson as convexToJson2, jsonToConvex as jsonToConvex4 } from "@helipod/values";
|
|
477
|
+
|
|
478
|
+
// src/layered-store.ts
|
|
479
|
+
import { convexToJson, jsonToConvex as jsonToConvex2 } from "@helipod/values";
|
|
480
|
+
import { applyChanges, driftChecksum } from "@helipod/sync";
|
|
481
|
+
import { base64ToBytes, compareKeyBytes } from "@helipod/index-key-codec";
|
|
482
|
+
function queryHash(path, argsJson) {
|
|
483
|
+
return `${path}:${JSON.stringify(argsJson)}`;
|
|
484
|
+
}
|
|
485
|
+
function renderByIdValue(rows) {
|
|
486
|
+
for (const rv of rows.values()) return jsonToConvex2(rv.row);
|
|
487
|
+
return void 0;
|
|
488
|
+
}
|
|
489
|
+
function renderRangeValue(rows, orderDir) {
|
|
490
|
+
const entries = [...rows.values()].sort(
|
|
491
|
+
(a, b) => compareKeyBytes(base64ToBytes(a.orderKey ?? ""), base64ToBytes(b.orderKey ?? ""))
|
|
492
|
+
);
|
|
493
|
+
if (orderDir === "desc") entries.reverse();
|
|
494
|
+
return entries.map((e) => jsonToConvex2(e.row));
|
|
495
|
+
}
|
|
496
|
+
function renderPageValue(rows, orderDir, meta) {
|
|
497
|
+
return {
|
|
498
|
+
page: renderRangeValue(rows, orderDir),
|
|
499
|
+
nextCursor: meta.nextCursor,
|
|
500
|
+
hasMore: meta.hasMore,
|
|
501
|
+
scanCapped: meta.scanCapped
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
var LayeredQueryStore = class {
|
|
505
|
+
byHash = /* @__PURE__ */ new Map();
|
|
506
|
+
byId = /* @__PURE__ */ new Map();
|
|
507
|
+
create(queryId, path, args, hash) {
|
|
508
|
+
const sub = {
|
|
509
|
+
queryId,
|
|
510
|
+
path,
|
|
511
|
+
args,
|
|
512
|
+
hash,
|
|
513
|
+
serverValue: void 0,
|
|
514
|
+
composedValue: void 0,
|
|
515
|
+
answered: false,
|
|
516
|
+
lastHash: void 0,
|
|
517
|
+
listeners: /* @__PURE__ */ new Set()
|
|
518
|
+
};
|
|
519
|
+
this.byHash.set(hash, sub);
|
|
520
|
+
this.byId.set(queryId, sub);
|
|
521
|
+
return sub;
|
|
522
|
+
}
|
|
523
|
+
remove(hash) {
|
|
524
|
+
const sub = this.byHash.get(hash);
|
|
525
|
+
if (!sub) return;
|
|
526
|
+
this.byHash.delete(hash);
|
|
527
|
+
this.byId.delete(sub.queryId);
|
|
528
|
+
}
|
|
529
|
+
/** Set a subscription's authoritative base (from a `QueryUpdated` modification). Does NOT fire —
|
|
530
|
+
* `recompose` owns all listener firing so the base+drop+rebuild happen as one atomic frame.
|
|
531
|
+
* `hash` is the server-minted result fingerprint carried on the same modification — stored
|
|
532
|
+
* verbatim (undefined clears `lastHash`, e.g. an old server that never sends `hash`).
|
|
533
|
+
*
|
|
534
|
+
* DLR Stage 2b (Finding 2): a `QueryUpdated` is the ONLY way a DIFFABLE (range/by-id) sub ever
|
|
535
|
+
* reaches this method — the server sends a full RERUN answer instead of a `QueryDiff` only on a
|
|
536
|
+
* RERUN-fallback (a `SetAuth` identity switch drops the server-side row-map; a fleet-forwarded
|
|
537
|
+
* RERUN does the same), never in steady state (steady state for a diffable sub is `QueryDiff`).
|
|
538
|
+
* So revert the sub to a plain RERUN-rendered sub here: drop the PRIOR identity's `diffRows` and
|
|
539
|
+
* `renderMode`. Otherwise a subsequent incremental `QueryDiff` would merge onto the stale previous-
|
|
540
|
+
* identity row-map and `renderRangeValue` the prior user's rows for ~1 RTT until drift-resync heals
|
|
541
|
+
* — a transient cross-identity leak on an auth-scoped range query. Cleared → a later incremental
|
|
542
|
+
* diff instead hits `reconcile.ts`'s uninitialized-render-mode guard (→ resync), and a later
|
|
543
|
+
* `QueryDiff` reset re-establishes `renderMode`/`diffRows` cleanly. The immediate frame renders
|
|
544
|
+
* `value` directly (recompose reads `serverValue`, never `diffRows`), so no stale row is shown. */
|
|
545
|
+
setServerValue(sub, value, hash) {
|
|
546
|
+
sub.serverValue = value;
|
|
547
|
+
sub.lastHash = hash;
|
|
548
|
+
sub.answered = true;
|
|
549
|
+
sub.diffRows = void 0;
|
|
550
|
+
sub.renderMode = void 0;
|
|
551
|
+
sub.orderDir = void 0;
|
|
552
|
+
sub.pageMeta = void 0;
|
|
553
|
+
}
|
|
554
|
+
/** Mark a subscription as having received its first reply WITHOUT a base value (from a
|
|
555
|
+
* `QueryFailed` modification — there is no server row to render, only an error). See the
|
|
556
|
+
* `answered` field doc for why this is distinct from `setServerValue`. */
|
|
557
|
+
markAnswered(sub) {
|
|
558
|
+
sub.answered = true;
|
|
559
|
+
}
|
|
560
|
+
/** Ingest a `QueryUnchanged` modification (subscription resume, design 2025-11-28): the fresh
|
|
561
|
+
* server-side re-run's hash matched what this session echoed, so there is no new value on the
|
|
562
|
+
* wire — `serverValue`/`lastHash`/`composedValue` all stay exactly as they were. It still counts
|
|
563
|
+
* as a delivered reply (`answered = true`, same as `setServerValue`/`markAnswered`) — see the
|
|
564
|
+
* `answered` field doc. The caller (`reconcile.ts`) is responsible for passing this sub's `hash`
|
|
565
|
+
* into `recompose`'s `forceNotify` set — see that method's doc for why a `QueryUnchanged` must
|
|
566
|
+
* still fire listeners to introduce no new observable difference for app code. */
|
|
567
|
+
markUnchanged(sub) {
|
|
568
|
+
sub.answered = true;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* DLR Stage 2a/2b — apply a `QueryDiff`'s changes to a DIFFABLE query's keyed row-map, re-derive
|
|
572
|
+
* the rendered `serverValue` as a FRESH reference (so `recompose`'s reference-inequality check
|
|
573
|
+
* fires listeners), and report whether the client-recomputed drift checksum diverged from the
|
|
574
|
+
* server's. The caller (the reconciler) triggers a scoped resync on `drift === true`.
|
|
575
|
+
*
|
|
576
|
+
* `reset` (2b — widened from a bare `true`) governs where the diff applies FROM and how the
|
|
577
|
+
* result renders:
|
|
578
|
+
* - `undefined` (an incremental diff): merges `changes` onto the RUNNING `sub.diffRows` map —
|
|
579
|
+
* `sub.renderMode`/`orderDir` are left exactly as they were from the last reset.
|
|
580
|
+
* - `true` (a by-id reset): the classic 2a reset — `sub.renderMode = "byid"`.
|
|
581
|
+
* - `{ mode, orderDir }` (a range reset, or a future non-byid mode): `sub.renderMode = mode`,
|
|
582
|
+
* `sub.orderDir = orderDir`.
|
|
583
|
+
* Both reset forms rebuild from an EMPTY map, never merge onto `sub.diffRows` — a reset is a full
|
|
584
|
+
* re-baseline (the initial subscribe answer, or a range resync after a drift/table-invalidation),
|
|
585
|
+
* so a row that's no longer in the result set must disappear, not linger from the prior baseline.
|
|
586
|
+
* `applyChanges` is copy-on-write, so `diffRows` becomes a new Map each apply — the client never
|
|
587
|
+
* mutates a map a listener may still hold.
|
|
588
|
+
*
|
|
589
|
+
* `lastHash` OWNERSHIP (DLR 2b Task 10): this method does NOT touch `sub.lastHash` — the caller
|
|
590
|
+
* (`reconcile.ts#ingestTransition`'s `QueryDiff` arm) owns it, since only the caller knows whether
|
|
591
|
+
* this call was a RESET (carries the server's resume fingerprint, `mod.hash`, to store) or an
|
|
592
|
+
* INCREMENTAL diff (no fingerprint — the caller clears it instead). Leaving it here would mean
|
|
593
|
+
* either always clearing it (breaking resume for every diffable sub, the Task 10 regression this
|
|
594
|
+
* fixes) or reaching into wire-message fields (`mod.hash`) this store-level method doesn't receive.
|
|
595
|
+
*
|
|
596
|
+
* NOTE ON AN UNINITIALIZED `renderMode` (DLR 2b Task 10 residual): this method does NOT itself
|
|
597
|
+
* guard against an INCREMENTAL diff (`reset === undefined`) arriving while `renderMode` has never
|
|
598
|
+
* been established — it can't distinguish that from the ordinary "first-ever call, reset omitted"
|
|
599
|
+
* shorthand several of this file's own unit tests use to mean "build from an empty baseline" (a
|
|
600
|
+
* pattern that predates `reset`'s widening and has no way here to tell "legitimately fresh" apart
|
|
601
|
+
* from "already answered by something else, never diff-initialized"). The caller
|
|
602
|
+
* (`reconcile.ts#ingestTransition`) owns that distinction instead — it has `sub.answered`'s
|
|
603
|
+
* PRE-CALL value on hand, which this method's own post-call mutation of `sub.answered` would
|
|
604
|
+
* otherwise erase, and doing it there also means the store-level surface tested here stays exactly
|
|
605
|
+
* as it always was.
|
|
606
|
+
*/
|
|
607
|
+
applyDiff(sub, changes, checksum, reset) {
|
|
608
|
+
if (reset === true) {
|
|
609
|
+
sub.renderMode = "byid";
|
|
610
|
+
} else if (reset) {
|
|
611
|
+
sub.renderMode = reset.mode;
|
|
612
|
+
sub.orderDir = reset.orderDir;
|
|
613
|
+
if (reset.mode === "page") {
|
|
614
|
+
sub.pageMeta = { nextCursor: reset.nextCursor, hasMore: reset.hasMore, scanCapped: reset.scanCapped };
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const base = reset !== void 0 ? /* @__PURE__ */ new Map() : sub.diffRows ?? /* @__PURE__ */ new Map();
|
|
618
|
+
const next = applyChanges(base, changes);
|
|
619
|
+
sub.diffRows = next;
|
|
620
|
+
sub.serverValue = sub.renderMode === "page" ? renderPageValue(next, sub.orderDir ?? "asc", sub.pageMeta) : sub.renderMode === "range" ? renderRangeValue(next, sub.orderDir ?? "asc") : renderByIdValue(next);
|
|
621
|
+
sub.answered = true;
|
|
622
|
+
return { drift: driftChecksum(next) !== checksum };
|
|
623
|
+
}
|
|
624
|
+
serverValueOf(hash) {
|
|
625
|
+
return this.byHash.get(hash)?.serverValue;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Rebuild every subscription's `composedValue` = replay the surviving updates (in `requestId`
|
|
629
|
+
* order) over the current `serverValue`s, then fire listeners wherever the composed value's
|
|
630
|
+
* reference changed. Transactional per-updater: a throwing updater is buffered-and-discarded and
|
|
631
|
+
* returned as a `ReplayDrop` (the caller removes it from the log); the rebuild always completes.
|
|
632
|
+
*
|
|
633
|
+
* @param entries surviving pending mutations, in replay order
|
|
634
|
+
* @param invokeUpdate runs one entry's updater against the view (kept in the reconciler so the
|
|
635
|
+
* entry `seed` can feed `placeholderId()`/`now()` when T5 wires them)
|
|
636
|
+
* @param forceNotify subscription `hash`es (subscription resume) to fire listeners for even when
|
|
637
|
+
* the composed reference didn't change — a `QueryUnchanged` ingest has no new
|
|
638
|
+
* value to swap in (`serverValue` is retained as-is), yet this store has NEVER
|
|
639
|
+
* done content-based dedup for a plain `QueryUpdated` either: `setServerValue`
|
|
640
|
+
* unconditionally overwrites `serverValue`, and — because `jsonToConvex` mints
|
|
641
|
+
* a fresh object on every decode — the reference-inequality check below ALWAYS
|
|
642
|
+
* fires for a content-identical `QueryUpdated` today (verified in
|
|
643
|
+
* `test/resume-client.test.ts`'s Step-1 comment). `forceNotify` reproduces
|
|
644
|
+
* that same always-fires behavior for `QueryUnchanged`, so it introduces no new
|
|
645
|
+
* observable difference for app code. One nuance worth flagging: a
|
|
646
|
+
* `QueryUnchanged` notify hands listeners the RETAINED `serverValue` reference
|
|
647
|
+
* (nothing new was decoded), whereas a value-equal `QueryUpdated` today mints a
|
|
648
|
+
* fresh object every time — so a reference-equality consumer (e.g. React's
|
|
649
|
+
* `useState`, which bails out of a re-render when the new state === the old
|
|
650
|
+
* state) may legitimately skip one redundant re-render on this path that it
|
|
651
|
+
* wouldn't skip on an equivalent full send. Content identity is hash-proven
|
|
652
|
+
* either way; only the object identity differs.
|
|
653
|
+
*/
|
|
654
|
+
recompose(entries, invokeUpdate, forceNotify) {
|
|
655
|
+
const overlay = /* @__PURE__ */ new Map();
|
|
656
|
+
const dropped = [];
|
|
657
|
+
for (const entry of entries) {
|
|
658
|
+
if (!entry.update) continue;
|
|
659
|
+
const local = /* @__PURE__ */ new Map();
|
|
660
|
+
const touched = /* @__PURE__ */ new Set();
|
|
661
|
+
const read = (hash) => local.has(hash) ? local.get(hash) : overlay.has(hash) ? overlay.get(hash) : this.serverValueOf(hash);
|
|
662
|
+
const view = {
|
|
663
|
+
getQuery: (ref, args = {}) => read(queryHash(getFunctionPath(ref), convexToJson(args))),
|
|
664
|
+
setQuery: (ref, args, value) => {
|
|
665
|
+
const h = queryHash(getFunctionPath(ref), convexToJson(args));
|
|
666
|
+
local.set(h, value);
|
|
667
|
+
touched.add(h);
|
|
668
|
+
},
|
|
669
|
+
getAllQueries: (ref) => {
|
|
670
|
+
const path = getFunctionPath(ref);
|
|
671
|
+
const out = [];
|
|
672
|
+
for (const s of this.byHash.values()) {
|
|
673
|
+
if (s.path === path) out.push({ args: jsonToConvex2(s.args), value: read(s.hash) });
|
|
674
|
+
}
|
|
675
|
+
return out;
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
try {
|
|
679
|
+
invokeUpdate(entry, view);
|
|
680
|
+
} catch (error) {
|
|
681
|
+
dropped.push({ requestId: entry.requestId, error });
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
for (const h of touched) overlay.set(h, local.get(h));
|
|
685
|
+
entry.touched = touched;
|
|
686
|
+
}
|
|
687
|
+
for (const sub of this.byHash.values()) {
|
|
688
|
+
const next = overlay.has(sub.hash) ? overlay.get(sub.hash) : sub.serverValue;
|
|
689
|
+
if (next !== sub.composedValue || forceNotify?.has(sub.hash)) {
|
|
690
|
+
sub.composedValue = next;
|
|
691
|
+
if (next !== void 0) {
|
|
692
|
+
for (const l of sub.listeners) l.onUpdate(next);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return dropped;
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
// src/reconcile.ts
|
|
701
|
+
import { jsonToConvex as jsonToConvex3 } from "@helipod/values";
|
|
702
|
+
|
|
703
|
+
// src/mutation-log.ts
|
|
704
|
+
var MutationLog = class {
|
|
705
|
+
entries = /* @__PURE__ */ new Map();
|
|
706
|
+
add(entry) {
|
|
707
|
+
this.entries.set(entry.requestId, entry);
|
|
708
|
+
}
|
|
709
|
+
get(requestId) {
|
|
710
|
+
return this.entries.get(requestId);
|
|
711
|
+
}
|
|
712
|
+
delete(requestId) {
|
|
713
|
+
return this.entries.delete(requestId);
|
|
714
|
+
}
|
|
715
|
+
/** All entries in requestId (insertion) order — the replay order. */
|
|
716
|
+
entriesInOrder() {
|
|
717
|
+
return [...this.entries.values()];
|
|
718
|
+
}
|
|
719
|
+
get size() {
|
|
720
|
+
return this.entries.size;
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
// src/reconcile.ts
|
|
725
|
+
function versionCoversCommit(maxObservedTs, commitTs) {
|
|
726
|
+
return commitTs <= maxObservedTs && commitTs > 0;
|
|
727
|
+
}
|
|
728
|
+
var DEFAULT_GATE_TIMEOUT_MS = 1e4;
|
|
729
|
+
var Reconciler = class {
|
|
730
|
+
constructor(store, opts = {}) {
|
|
731
|
+
this.store = store;
|
|
732
|
+
this.gateTimeoutMs = opts.gateTimeoutMs ?? DEFAULT_GATE_TIMEOUT_MS;
|
|
733
|
+
this.onDrift = opts.onDrift;
|
|
734
|
+
}
|
|
735
|
+
store;
|
|
736
|
+
log = new MutationLog();
|
|
737
|
+
observedTs = 0;
|
|
738
|
+
gateTimeoutMs;
|
|
739
|
+
timers = /* @__PURE__ */ new Map();
|
|
740
|
+
/** DLR Stage 2a — invoked when a `QueryDiff`'s client-recomputed drift checksum diverges from the
|
|
741
|
+
* server's. The client wires this to a scoped resync (`client.ts` passes `() => this.resync()`).
|
|
742
|
+
* For by-id the diff is trivially correct so this should never fire in normal operation; it is the
|
|
743
|
+
* safety net that 2b's harder differ inherits already proven. */
|
|
744
|
+
onDrift;
|
|
745
|
+
/** Max `endVersion.ts` observed this session (reset on close). Exposed for tests. */
|
|
746
|
+
get maxObservedTs() {
|
|
747
|
+
return this.observedTs;
|
|
748
|
+
}
|
|
749
|
+
entries() {
|
|
750
|
+
return this.log.entriesInOrder();
|
|
751
|
+
}
|
|
752
|
+
/** T6: `unsent` entries in FIFO (requestId/insertion) order — flushed on transport reopen. */
|
|
753
|
+
unsentInOrder() {
|
|
754
|
+
return this.log.entriesInOrder().filter((e) => e.status.type === "unsent");
|
|
755
|
+
}
|
|
756
|
+
/** T3: the log entry for `requestId` (or `undefined`). Lets `client.ts` read an outbox entry's
|
|
757
|
+
* recorded `(clientId, seq)` for dequeue-on-success BEFORE a settling event removes it from the
|
|
758
|
+
* log. Read-only — all mutation still flows through the events below. */
|
|
759
|
+
getEntry(requestId) {
|
|
760
|
+
return this.log.get(requestId);
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* The drop-on-verdict-after-baseline rule (T3, verdict §(d) "Reload and rendering — the fork,
|
|
764
|
+
* decided"). A CROSS-SESSION entry whose recorded verdict is `applied` (learned via the `Connect`
|
|
765
|
+
* handshake's `ConnectAck` or a drain replay-ack) drops its optimistic layer once the reconnect
|
|
766
|
+
* baseline Transition has been ADOPTED. Sound because the entry's commit necessarily predates
|
|
767
|
+
* this session's `Connect`, hence predates the baseline's read snapshot — so the baseline already
|
|
768
|
+
* renders the effect; removing the (registry-rebuilt, T5) layer in the SAME one-pass `rebuild()`
|
|
769
|
+
* is flicker-free (the authoritative rows are already in the base, so the composed view never
|
|
770
|
+
* blinks the row away). The rule is deliberately **layer-agnostic**: the entry may hold no layer
|
|
771
|
+
* at all — a parked entry (its `update` was cleared at close), a plain non-optimistic mutation,
|
|
772
|
+
* or (until T5 ships the registry) any hydrated entry — in which case this is a clean removal.
|
|
773
|
+
*
|
|
774
|
+
* The CALLER (`client.ts`) is responsible for gating the call on baseline adoption; this method
|
|
775
|
+
* assumes that gate has already passed. `versionCoversCommit` (the same-session gate predicate)
|
|
776
|
+
* is intentionally untouched — same-session entries still drop on observed inclusion, never here.
|
|
777
|
+
*/
|
|
778
|
+
onVerdictAfterBaseline(requestId) {
|
|
779
|
+
if (!this.log.get(requestId)) return;
|
|
780
|
+
this.log.delete(requestId);
|
|
781
|
+
this.clearTimer(requestId);
|
|
782
|
+
this.rebuild();
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* T5: add a HYDRATED (cross-reload) durable entry to the log — `client.ts#addHydratedEntry`'s
|
|
786
|
+
* counterpart to `initiate()` for a live call-site mutation. `entry.update` may already be set
|
|
787
|
+
* (a registry hit — `client.ts` looks it up BEFORE calling this). Unlike `initiate()`, where the
|
|
788
|
+
* OWN entry's throw is rethrown synchronously to the `mutation()` caller, a REGISTERED updater
|
|
789
|
+
* that throws here is ordinary replay-drop collateral — warned and dropped via the normal
|
|
790
|
+
* `rebuild()` path, never rethrown (there is no synchronous caller on the hydrate path to
|
|
791
|
+
* propagate to; the entry still drains fine, only its rendering is lost). An entry with no
|
|
792
|
+
* `update` (no registry hit, or the registry simply wasn't configured) is added without a
|
|
793
|
+
* recompose pass at all — a plain layerless entry, exactly T4's pre-registry behavior.
|
|
794
|
+
*/
|
|
795
|
+
addHydrated(entry) {
|
|
796
|
+
this.log.add(entry);
|
|
797
|
+
if (entry.update) this.rebuild();
|
|
798
|
+
}
|
|
799
|
+
invokeUpdate = (entry, view) => {
|
|
800
|
+
const store = createOptimisticLocalStore(view, entry.seed);
|
|
801
|
+
entry.update(store, jsonToConvex3(entry.args));
|
|
802
|
+
};
|
|
803
|
+
/** Rebuild composed values; drop + warn any entry whose updater threw during replay.
|
|
804
|
+
* `forceNotify` (subscription resume) forwards straight to `store.recompose` — see its doc. */
|
|
805
|
+
rebuild(forceNotify) {
|
|
806
|
+
const dropped = this.store.recompose(this.log.entriesInOrder(), this.invokeUpdate, forceNotify);
|
|
807
|
+
for (const d of dropped) {
|
|
808
|
+
const path = this.log.get(d.requestId)?.udfPath ?? d.requestId;
|
|
809
|
+
this.log.delete(d.requestId);
|
|
810
|
+
this.clearTimer(d.requestId);
|
|
811
|
+
console.warn(`[helipod] optimistic update for "${path}" threw during replay; dropping its pending layer`, d.error);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Event 1 — mutation initiation. Add the entry, replay all surviving updates over the current
|
|
816
|
+
* base. If THIS entry's updater throws, it is removed and the error rethrown **synchronously** so
|
|
817
|
+
* the caller sends nothing (a prior entry throwing is contained + warned, not rethrown).
|
|
818
|
+
*/
|
|
819
|
+
initiate(entry) {
|
|
820
|
+
this.log.add(entry);
|
|
821
|
+
if (!entry.update) return;
|
|
822
|
+
const dropped = this.store.recompose(this.log.entriesInOrder(), this.invokeUpdate);
|
|
823
|
+
let ownError;
|
|
824
|
+
for (const d of dropped) {
|
|
825
|
+
this.log.delete(d.requestId);
|
|
826
|
+
this.clearTimer(d.requestId);
|
|
827
|
+
if (d.requestId === entry.requestId) {
|
|
828
|
+
ownError = { error: d.error };
|
|
829
|
+
} else {
|
|
830
|
+
const path = this.log.get(d.requestId)?.udfPath ?? d.requestId;
|
|
831
|
+
console.warn(`[helipod] optimistic update for "${path}" threw during replay; dropping its pending layer`, d.error);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
if (ownError) throw ownError.error;
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Event 2 — a contiguous (or resync-adopted) Transition, applied as ONE synchronous pass:
|
|
838
|
+
* advance the frontier, apply modifications to the base, drop every gated `completed` layer, then
|
|
839
|
+
* rebuild composed. The frame where a layer disappears is the same frame its authoritative rows
|
|
840
|
+
* appear — the no-flicker guarantee. An empty (`modifications: []`) ts-advancing Transition (T2)
|
|
841
|
+
* flows through here with zero special-casing: the loop simply does no base writes.
|
|
842
|
+
*/
|
|
843
|
+
ingestTransition(modifications, endTs) {
|
|
844
|
+
this.observedTs = Math.max(this.observedTs, endTs);
|
|
845
|
+
let unchanged;
|
|
846
|
+
for (const mod of modifications) {
|
|
847
|
+
if (mod.type === "QueryUpdated") {
|
|
848
|
+
const sub = this.store.byId.get(mod.queryId);
|
|
849
|
+
if (sub) this.store.setServerValue(sub, jsonToConvex3(mod.value), mod.hash);
|
|
850
|
+
} else if (mod.type === "QueryFailed") {
|
|
851
|
+
const sub = this.store.byId.get(mod.queryId);
|
|
852
|
+
if (sub) {
|
|
853
|
+
this.store.markAnswered(sub);
|
|
854
|
+
console.error(`[helipod] query "${sub.path}" failed: ${mod.error}`);
|
|
855
|
+
for (const l of sub.listeners) l.onError?.(mod.error);
|
|
856
|
+
}
|
|
857
|
+
} else if (mod.type === "QueryUnchanged") {
|
|
858
|
+
const sub = this.store.byId.get(mod.queryId);
|
|
859
|
+
if (sub) {
|
|
860
|
+
this.store.markUnchanged(sub);
|
|
861
|
+
(unchanged ??= /* @__PURE__ */ new Set()).add(sub.hash);
|
|
862
|
+
}
|
|
863
|
+
} else if (mod.type === "QueryDiff") {
|
|
864
|
+
const sub = this.store.byId.get(mod.queryId);
|
|
865
|
+
if (sub) {
|
|
866
|
+
if (mod.reset === void 0 && sub.renderMode === void 0 && sub.answered) {
|
|
867
|
+
console.error(`[helipod] query "${sub.path}" incremental diff arrived with no established render mode; resyncing`);
|
|
868
|
+
sub.lastHash = void 0;
|
|
869
|
+
this.onDrift?.(mod.queryId);
|
|
870
|
+
} else {
|
|
871
|
+
const { drift } = this.store.applyDiff(sub, mod.changes, mod.checksum, mod.reset);
|
|
872
|
+
sub.lastHash = mod.reset !== void 0 ? mod.hash : void 0;
|
|
873
|
+
if (drift) {
|
|
874
|
+
console.error(`[helipod] query "${sub.path}" diff checksum mismatch; resyncing`);
|
|
875
|
+
this.onDrift?.(mod.queryId);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
for (const entry of this.log.entriesInOrder()) {
|
|
882
|
+
if (entry.status.type === "completed" && versionCoversCommit(this.observedTs, entry.status.commitTs)) {
|
|
883
|
+
this.log.delete(entry.requestId);
|
|
884
|
+
this.clearTimer(entry.requestId);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
this.rebuild(unchanged);
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Event 3 — `MutationResponse` success carrying `ts` (W1). The client already resolved the
|
|
891
|
+
* promise (D3). Here: drop now if there is nothing to protect (no updater / nothing touched), or
|
|
892
|
+
* the gate is already covered, or `ts` is missing/≤0 (accept one-frame flicker over a wedge —
|
|
893
|
+
* the server-side `commitTs > 0` assertion makes this unreachable); otherwise hold the layer as
|
|
894
|
+
* `completed` and arm the gate timer.
|
|
895
|
+
*/
|
|
896
|
+
onMutationSuccess(requestId, ts) {
|
|
897
|
+
const entry = this.log.get(requestId);
|
|
898
|
+
if (!entry) return;
|
|
899
|
+
if (!entry.update || entry.touched.size === 0) {
|
|
900
|
+
this.log.delete(requestId);
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
if (ts === void 0 || ts <= 0) {
|
|
904
|
+
console.warn(`[helipod] mutation "${entry.udfPath}" acked with no usable commitTs (ts=${ts}); dropping its layer now`);
|
|
905
|
+
this.log.delete(requestId);
|
|
906
|
+
this.rebuild();
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (versionCoversCommit(this.observedTs, ts)) {
|
|
910
|
+
this.log.delete(requestId);
|
|
911
|
+
this.rebuild();
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
entry.status = { type: "completed", commitTs: ts, completedAt: Date.now() };
|
|
915
|
+
this.armGateTimer(requestId);
|
|
916
|
+
}
|
|
917
|
+
/** Event 4 — `MutationResponse` failure. The client rejected the promise; drop the layer + rebuild. */
|
|
918
|
+
onMutationFailure(requestId) {
|
|
919
|
+
if (!this.log.get(requestId)) return;
|
|
920
|
+
this.log.delete(requestId);
|
|
921
|
+
this.clearTimer(requestId);
|
|
922
|
+
this.rebuild();
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Event 6 — transport close (S4). `unsent` retained; `inflight`/`completed` layers drop; the
|
|
926
|
+
* frontier resets; composed rebuilds over the retained set. Returns the `inflight` ids the client
|
|
927
|
+
* must reject with `MutationUndeliveredError`. NO layer crosses a session.
|
|
928
|
+
*
|
|
929
|
+
* Task 2 extends this with the S4 park swap: when `armed` (a `ConnectAck` has proven server-side
|
|
930
|
+
* dedup — T3 sets it via `client.ts#setOutboxArmed`) AND an `inflight` entry's durable append has
|
|
931
|
+
* already committed (`entry.durable`), it PARKS instead of rejecting — its `status` flips to
|
|
932
|
+
* `"parked"` and its `update` closure is cleared (the layer still drops, via the normal
|
|
933
|
+
* `rebuild()` below: `recompose` skips any entry with no `update`, the same mechanism a plain
|
|
934
|
+
* non-optimistic mutation already relies on) but the entry itself STAYS in the log, ready for a
|
|
935
|
+
* future drain (T4) to resend under its recorded `(clientId, seq)`. Every other `drop`ped id
|
|
936
|
+
* (rejected-inflight, completed) is still fully removed from the log, exactly as before.
|
|
937
|
+
*/
|
|
938
|
+
closeSession(armed = false) {
|
|
939
|
+
const disp = closeDisposition(this.log.entriesInOrder(), { armed });
|
|
940
|
+
const parkedIds = new Set(disp.park);
|
|
941
|
+
for (const rid of disp.park) {
|
|
942
|
+
const entry = this.log.get(rid);
|
|
943
|
+
if (entry) {
|
|
944
|
+
entry.status = { type: "parked" };
|
|
945
|
+
entry.update = void 0;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
for (const rid of disp.drop) {
|
|
949
|
+
if (parkedIds.has(rid)) continue;
|
|
950
|
+
this.log.delete(rid);
|
|
951
|
+
this.clearTimer(rid);
|
|
952
|
+
}
|
|
953
|
+
this.observedTs = 0;
|
|
954
|
+
this.rebuild();
|
|
955
|
+
return { rejectedInflight: disp.reject, parked: disp.park };
|
|
956
|
+
}
|
|
957
|
+
armGateTimer(requestId) {
|
|
958
|
+
this.clearTimer(requestId);
|
|
959
|
+
const timer = setTimeout(() => {
|
|
960
|
+
this.timers.delete(requestId);
|
|
961
|
+
const entry = this.log.get(requestId);
|
|
962
|
+
if (!entry || entry.status.type !== "completed") return;
|
|
963
|
+
console.warn(`[helipod] mutation "${entry.udfPath}" layer not confirmed within ${this.gateTimeoutMs}ms; dropping it`);
|
|
964
|
+
this.log.delete(requestId);
|
|
965
|
+
this.rebuild();
|
|
966
|
+
}, this.gateTimeoutMs);
|
|
967
|
+
timer.unref?.();
|
|
968
|
+
this.timers.set(requestId, timer);
|
|
969
|
+
}
|
|
970
|
+
clearTimer(requestId) {
|
|
971
|
+
const timer = this.timers.get(requestId);
|
|
972
|
+
if (timer !== void 0) {
|
|
973
|
+
clearTimeout(timer);
|
|
974
|
+
this.timers.delete(requestId);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
// src/client.ts
|
|
980
|
+
function isOutboxBroadcastMessage(data) {
|
|
981
|
+
if (typeof data !== "object" || data === null || !("kind" in data)) return false;
|
|
982
|
+
const kind = data.kind;
|
|
983
|
+
return kind === "enqueued" || kind === "settled" || kind === "failed";
|
|
984
|
+
}
|
|
985
|
+
function probeBroadcastChannel(name) {
|
|
986
|
+
if (typeof BroadcastChannel === "undefined") return void 0;
|
|
987
|
+
const channel = new BroadcastChannel(name);
|
|
988
|
+
let closed = false;
|
|
989
|
+
const wrapper = {
|
|
990
|
+
// A `postMessage` racing an in-flight write-behind `.then()` against `close()` (e.g. a client
|
|
991
|
+
// torn down mid-test, or an app unmounting while an append is still resolving) must NEVER throw
|
|
992
|
+
// out from under `notifyOutboxChange()` — `closed` makes this a harmless no-op instead of the
|
|
993
|
+
// DOM's `InvalidStateError`.
|
|
994
|
+
postMessage: (message) => {
|
|
995
|
+
if (!closed) channel.postMessage(message);
|
|
996
|
+
},
|
|
997
|
+
onmessage: null,
|
|
998
|
+
close: () => {
|
|
999
|
+
closed = true;
|
|
1000
|
+
channel.close();
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
channel.onmessage = (ev) => wrapper.onmessage?.({ data: ev.data });
|
|
1004
|
+
return wrapper;
|
|
1005
|
+
}
|
|
1006
|
+
var entropyCounter = 0;
|
|
1007
|
+
function makeEntropy() {
|
|
1008
|
+
return `${Date.now().toString(36)}-${(entropyCounter++).toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
1009
|
+
}
|
|
1010
|
+
var HelipodClient = class {
|
|
1011
|
+
transport;
|
|
1012
|
+
version = { ...INITIAL_VERSION };
|
|
1013
|
+
resyncing = false;
|
|
1014
|
+
closed = false;
|
|
1015
|
+
// DLR Stage 3: the observed-ts frontier as of the moment BEFORE the current close. `closeSession`
|
|
1016
|
+
// resets `reconciler.maxObservedTs` to 0 on every transport close (reconcile.ts ~line 320, "the
|
|
1017
|
+
// ts-gate is only sound over one monotone feed") — so on a genuine reconnect, `maxObservedTs` is
|
|
1018
|
+
// already 0 by the time `resync()` runs and echoing it alone would defeat the resume watermark.
|
|
1019
|
+
// Captured in `onTransportClosed()` BEFORE `closeSession()` runs; `resync()` echoes
|
|
1020
|
+
// `Math.max(resumeSinceTs, reconciler.maxObservedTs)` so a real reconnect (observedTs reset to 0)
|
|
1021
|
+
// uses this snapshot, while a same-session drift resync (observedTs intact, no close in between)
|
|
1022
|
+
// uses the live value instead.
|
|
1023
|
+
resumeSinceTs = 0;
|
|
1024
|
+
store = new LayeredQueryStore();
|
|
1025
|
+
reconciler;
|
|
1026
|
+
/** Mutation promise callbacks, keyed by requestId — resolved/rejected here; layers live in the log. */
|
|
1027
|
+
pendingMutationCallbacks = /* @__PURE__ */ new Map();
|
|
1028
|
+
pendingActions = /* @__PURE__ */ new Map();
|
|
1029
|
+
broadcastListeners = /* @__PURE__ */ new Set();
|
|
1030
|
+
disposeTransport;
|
|
1031
|
+
disposeClose;
|
|
1032
|
+
disposeReopen;
|
|
1033
|
+
nextQueryId = 1;
|
|
1034
|
+
nextRequestId = 1;
|
|
1035
|
+
/** The last token passed to `setAuth` (T6: replayed on reconnect). Unset until `setAuth` is
|
|
1036
|
+
* first called — a transport that never had auth set never sends a spurious `SetAuth` on reopen. */
|
|
1037
|
+
hasSetAuth = false;
|
|
1038
|
+
lastAuthToken = null;
|
|
1039
|
+
/** Absent unless `opts.outbox` is configured — a client constructed without it behaves exactly
|
|
1040
|
+
* as before this seam existed (`outbox-storage.ts`'s file doc: "never touches this file's
|
|
1041
|
+
* runtime branches that matter"). */
|
|
1042
|
+
outbox;
|
|
1043
|
+
/** Resolves once this tab-session's clientId is durably persisted (`mintIdentity`,
|
|
1044
|
+
* `outbox-storage.ts`) — ALWAYS a fresh clientId, never one reused from a prior session. Public
|
|
1045
|
+
* contract for tests/direct inspection; `mutation()` itself never awaits this (see
|
|
1046
|
+
* `outboxClientId`/`outboxNextSeq` below — the synchronous counterparts it actually reads). */
|
|
1047
|
+
outboxIdentity;
|
|
1048
|
+
/** This tab-session's clientId, minted SYNCHRONOUSLY at construction (Task 2) — `mutation()` must
|
|
1049
|
+
* stay fully synchronous (T1's open concern), so it cannot await `outboxIdentity`'s async
|
|
1050
|
+
* `getMeta`/`setMeta` round-trip. Fed into `mintIdentity` via `opts.mintClientId` below so the
|
|
1051
|
+
* durable meta row names this SAME id. Set once, iff `opts.outbox` is configured; never reused
|
|
1052
|
+
* across a reload (a fresh `HelipodClient` always mints again). */
|
|
1053
|
+
outboxClientId;
|
|
1054
|
+
/** In-memory serial `seq` counter for `outboxClientId` (verdict §(d): "seqs minted serially
|
|
1055
|
+
* in-memory per tab"). Starts at 0 synchronously; `outboxIdentity`'s resolution only ever
|
|
1056
|
+
* reconciles it UPWARD (never re-hands-out a seq already allocated locally) for the
|
|
1057
|
+
* astronomically-unlikely colliding-clientId case `mintIdentity` itself guards against. */
|
|
1058
|
+
outboxNextSeq = 0;
|
|
1059
|
+
/** Monotonic per-tab counter for `OutboxEntry.order` — the drain's (T4) FIFO key across the
|
|
1060
|
+
* WHOLE shared queue (every clientId/tab). Seeded from wall-clock time so multiple tabs sharing
|
|
1061
|
+
* one outbox interleave in roughly chronological order; strictly increasing per call within
|
|
1062
|
+
* this tab regardless of clock resolution. Cross-tab total ordering is a best-effort aid to the
|
|
1063
|
+
* drain's efficiency, NOT a correctness requirement — "locks are efficiency; correctness is the
|
|
1064
|
+
* records" (verdict §(d) "Drain"). */
|
|
1065
|
+
outboxOrderCounter = 0;
|
|
1066
|
+
/** Cache of `identityFingerprint` (SHA-256 hex of the last `SetAuth` token, or `"anon"` for
|
|
1067
|
+
* none/empty) — see `setAuth()` below and spec §(k)7. Stamped synchronously onto every entry;
|
|
1068
|
+
* computed asynchronously (SubtleCrypto) whenever `setAuth` is called with a real token. */
|
|
1069
|
+
outboxFingerprint = "anon";
|
|
1070
|
+
/** When true, a managed `createAuthClient` owns the outbox fingerprint (derived from the stable
|
|
1071
|
+
* `sessionId`, not the rotating token) — `setAuth`'s token-hash recompute is suppressed so
|
|
1072
|
+
* rotation never orphans queued offline mutations mid-drain (spec decision 9). The raw
|
|
1073
|
+
* `setAuth(token)` path (no `createAuthClient`) leaves this false and keeps token-hash
|
|
1074
|
+
* fingerprinting byte-for-byte unchanged. */
|
|
1075
|
+
sessionFingerprintActive = false;
|
|
1076
|
+
/** The `session:<sessionId>` key whose SHA-256 is the active managed fingerprint — guards a stale
|
|
1077
|
+
* async digest from overwriting a newer session's fingerprint. */
|
|
1078
|
+
sessionFingerprintKey = null;
|
|
1079
|
+
/** The S4 swap's capability flag (verdict §(d) "S4 swap, feature-detected") — flipped by
|
|
1080
|
+
* `setOutboxArmed()`, which T3's Connect handshake calls once a `ConnectAck` proves server-side
|
|
1081
|
+
* receipt dedup exists for this session. Defaults `false`: today's fail-fast, byte-for-byte,
|
|
1082
|
+
* whether or not an outbox is configured. */
|
|
1083
|
+
outboxArmed = false;
|
|
1084
|
+
outboxMaxQueueSize;
|
|
1085
|
+
/** The last `ConnectAck.deploymentId` seen — the same-timeline proof stamp (verdict §(g) hazard
|
|
1086
|
+
* 15's client half). Surfaced via `getOutboxDeploymentId()`; also written into the durable meta
|
|
1087
|
+
* row so a future reload can compare timelines. Undefined until the first `ConnectAck`. */
|
|
1088
|
+
outboxDeploymentId;
|
|
1089
|
+
/** App callback fired once whenever a `ConnectAck{known: false}` resets this client's identity
|
|
1090
|
+
* (verdict §(d) Retention). Optional constructor config. */
|
|
1091
|
+
onClientResetCallback;
|
|
1092
|
+
/** True while the Connect handshake is waiting for the first post-`Connect` baseline Transition
|
|
1093
|
+
* to be ADOPTED through S3 (verdict §(d) / spec decision 5 — "a NEW await"). While true, the
|
|
1094
|
+
* drop rule for `applied` cross-session entries is DEFERRED (queued in `outboxPendingDrops`) and
|
|
1095
|
+
* `whenBaselineAdopted()` (T4's drain gate) stays pending. */
|
|
1096
|
+
outboxAwaitingBaseline = false;
|
|
1097
|
+
/** requestIds whose `applied`-verdict layer drop is deferred until the baseline is adopted (so the
|
|
1098
|
+
* drop is flicker-free — the baseline already renders the effect). Drained by `markBaselineAdopted`. */
|
|
1099
|
+
outboxPendingDrops = [];
|
|
1100
|
+
/** Resolvers for in-flight `whenBaselineAdopted()` promises — settled together when the baseline
|
|
1101
|
+
* Transition adopts (or immediately, when a reopen had no live subscriptions to re-baseline). */
|
|
1102
|
+
outboxBaselineResolvers = [];
|
|
1103
|
+
/** Whether a `Connect` handshake has already gone out on the CURRENT connection (reset at close).
|
|
1104
|
+
* Guards against a double-handshake when both the reopen path and the drain's first-connect path
|
|
1105
|
+
* could fire — the drain's `ensureInitialHandshake()` is a no-op once a reopen already sent one. */
|
|
1106
|
+
outboxConnectSent = false;
|
|
1107
|
+
/** DLR Stage 2a — whether this connection has advertised `supportsQueryDiff` (reset at close). A
|
|
1108
|
+
* non-outbox client has no resume `Connect` to piggyback the capability on (an outbox client's
|
|
1109
|
+
* `buildConnectMessage` carries it), so it sends a minimal capability-only `Connect` before its
|
|
1110
|
+
* first subscribe. Without this the server never records the capability and every by-id sub falls
|
|
1111
|
+
* back to RERUN — correct, but the diff path never engages. */
|
|
1112
|
+
diffCapabilitySent = false;
|
|
1113
|
+
/** The drain (Task 4) — the Web Locks leader that turns the durable queue into exactly-once server
|
|
1114
|
+
* effects. Present iff `opts.outbox` is configured; started at construction. */
|
|
1115
|
+
outboxDrain;
|
|
1116
|
+
/* -------------------------------------------------------------------------------------------
|
|
1117
|
+
* T5 — the `optimisticUpdates` registry, R9 observability.
|
|
1118
|
+
* ------------------------------------------------------------------------------------------- */
|
|
1119
|
+
/** T5: the registry `mutation()` NEVER consults — only `addHydratedEntry` does, at hydrate time
|
|
1120
|
+
* (verdict §(d): "call-site closure wins for the live call; the registry is consulted only at
|
|
1121
|
+
* hydrate"). Plain string-keyed: a generated `UdfPathOf<Api>` union (`@helipod/codegen`) narrows
|
|
1122
|
+
* the caller's OWN object-literal keys; this package never imports that generated type. */
|
|
1123
|
+
optimisticUpdates;
|
|
1124
|
+
/** udfPaths already warned for a registry miss at hydrate — "one warn per udfPath" (spec §(k)6),
|
|
1125
|
+
* not once per missed ENTRY (a stale backlog of the same unregistered udfPath warns exactly once). */
|
|
1126
|
+
optimisticUpdateMissWarned = /* @__PURE__ */ new Set();
|
|
1127
|
+
/** T5 (R9): fired for a terminal durable failure with no live promise awaiter THIS session (a
|
|
1128
|
+
* hydrated/retried entry, or one discovered already-failed at construction — "resume"). Never
|
|
1129
|
+
* fired for a failure a live `mutation()` caller's own rejected promise already delivered
|
|
1130
|
+
* (Lunora's `hadAwaiter` — no double notification for one failure). */
|
|
1131
|
+
onMutationFailedCallback;
|
|
1132
|
+
/** T5 (R9): same-instance listeners for "the durable outbox changed" — `usePendingMutations()`'s
|
|
1133
|
+
* re-read trigger. Fired locally on every outbox-mutating op AND on an incoming cross-tab
|
|
1134
|
+
* `outboxBroadcast` message (unified into one path — a listener never needs to know which). */
|
|
1135
|
+
outboxChangeListeners = /* @__PURE__ */ new Set();
|
|
1136
|
+
/** T5 (R9): the cross-tab nudge — `undefined` when no outbox is configured or the probe/injected
|
|
1137
|
+
* option resolved to nothing (single-tab observability still works via `outboxChangeListeners`). */
|
|
1138
|
+
outboxBroadcast;
|
|
1139
|
+
/** T-crosstab: serializes `mirrorFromStore()` — a second call arriving while one is already
|
|
1140
|
+
* in-flight (a rapid burst of `enqueued` broadcasts) sets this bit instead of racing a second
|
|
1141
|
+
* `loadAll()`; the in-flight run loops once more on completion so the caller's freshest read is
|
|
1142
|
+
* never dropped. */
|
|
1143
|
+
mirrorInFlight = false;
|
|
1144
|
+
mirrorRerun = false;
|
|
1145
|
+
constructor(transport, opts = {}) {
|
|
1146
|
+
this.transport = transport;
|
|
1147
|
+
this.reconciler = new Reconciler(this.store, { gateTimeoutMs: opts.gateTimeoutMs, onDrift: () => this.resync() });
|
|
1148
|
+
this.outbox = opts.outbox;
|
|
1149
|
+
this.onClientResetCallback = opts.onClientReset;
|
|
1150
|
+
this.outboxMaxQueueSize = opts.outboxMaxQueueSize ?? DEFAULT_OUTBOX_MAX_QUEUE_SIZE;
|
|
1151
|
+
this.optimisticUpdates = opts.optimisticUpdates ?? {};
|
|
1152
|
+
this.onMutationFailedCallback = opts.onMutationFailed;
|
|
1153
|
+
if (opts.outbox) {
|
|
1154
|
+
this.outboxClientId = defaultMintClientId();
|
|
1155
|
+
this.outboxIdentity = mintIdentity(opts.outbox, { mintClientId: () => this.outboxClientId }).then((id) => {
|
|
1156
|
+
this.outboxNextSeq = Math.max(this.outboxNextSeq, id.nextSeq);
|
|
1157
|
+
return id;
|
|
1158
|
+
});
|
|
1159
|
+
this.outboxIdentity.catch((err) => this.handleOutboxWriteError("mintIdentity", this.outboxClientId, void 0, void 0, err));
|
|
1160
|
+
this.outboxDrain = new OutboxDrain(this.makeDrainHost(), {
|
|
1161
|
+
lockName: `helipod:outbox:${this.originTag()}:${opts.outboxDeployment ?? "default"}`,
|
|
1162
|
+
locks: opts.outboxLocks,
|
|
1163
|
+
poisonPolicy: opts.poisonPolicy,
|
|
1164
|
+
chunkSize: opts.outboxChunkSize,
|
|
1165
|
+
intervalMs: opts.outboxDrainIntervalMs,
|
|
1166
|
+
backoffMs: opts.outboxBackoffMs,
|
|
1167
|
+
onPause: opts.onOutboxPause
|
|
1168
|
+
});
|
|
1169
|
+
this.outboxBroadcast = opts.outboxBroadcast === null ? void 0 : opts.outboxBroadcast ?? probeBroadcastChannel(`helipod:outbox:${this.originTag()}:${opts.outboxDeployment ?? "default"}:pending`);
|
|
1170
|
+
if (this.outboxBroadcast) {
|
|
1171
|
+
this.outboxBroadcast.onmessage = (event) => {
|
|
1172
|
+
for (const l of this.outboxChangeListeners) l();
|
|
1173
|
+
try {
|
|
1174
|
+
this.handleOutboxBroadcastMessage(event.data);
|
|
1175
|
+
} catch (err) {
|
|
1176
|
+
if (isDevMode()) console.error("[helipod] outbox: error handling a cross-tab broadcast message", err);
|
|
1177
|
+
}
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
void this.refireDurableFailures();
|
|
1181
|
+
void this.mirrorFromStore().catch((err) => this.handleOutboxWriteError("mirrorFromStore", void 0, void 0, void 0, err));
|
|
1182
|
+
}
|
|
1183
|
+
this.disposeTransport = transport.onMessage((msg) => this.onServerMessage(msg));
|
|
1184
|
+
this.disposeClose = transport.onClose(() => this.onTransportClosed());
|
|
1185
|
+
this.disposeReopen = transport.onReopen?.(() => this.onTransportReopened());
|
|
1186
|
+
this.outboxDrain?.start();
|
|
1187
|
+
}
|
|
1188
|
+
/** The origin component of the drain's Web Locks name — `location.origin` in a browser, a stable
|
|
1189
|
+
* fallback elsewhere (Node/SSR share one origin; correctness is the records, not the lock). */
|
|
1190
|
+
originTag() {
|
|
1191
|
+
const loc = globalThis.location;
|
|
1192
|
+
return loc?.origin ?? "app";
|
|
1193
|
+
}
|
|
1194
|
+
/** @internal This tab-session's durable outbox identity, or `undefined` when no `outbox` was
|
|
1195
|
+
* configured. Exposed for direct testing of the identity-mint behavior; `mutation()` itself
|
|
1196
|
+
* reads the synchronous `outboxClientId`/`outboxNextSeq` counterparts, never this promise
|
|
1197
|
+
* (see the field doc above `outboxClientId`). */
|
|
1198
|
+
getOutboxIdentity() {
|
|
1199
|
+
return this.outboxIdentity;
|
|
1200
|
+
}
|
|
1201
|
+
/** @internal T3's Connect handshake calls this once a `ConnectAck` proves server-side receipt
|
|
1202
|
+
* dedup exists for this session — see verdict §(d) "S4 swap, feature-detected". Before that (no
|
|
1203
|
+
* outbox configured, a fresh/pre-handshake session, or an old server that never sends
|
|
1204
|
+
* `ConnectAck`), `close()` behaves exactly as it always has: today's fail-fast, byte-for-byte. */
|
|
1205
|
+
setOutboxArmed(armed) {
|
|
1206
|
+
this.outboxArmed = armed;
|
|
1207
|
+
}
|
|
1208
|
+
subscribe(ref, args = {}, onUpdate, onError) {
|
|
1209
|
+
const path = getFunctionPath(ref);
|
|
1210
|
+
const argsJson = convexToJson2(args);
|
|
1211
|
+
const hash = queryHash(path, argsJson);
|
|
1212
|
+
let sub = this.store.byHash.get(hash);
|
|
1213
|
+
if (!sub) {
|
|
1214
|
+
const queryId = this.nextQueryId++;
|
|
1215
|
+
sub = this.store.create(queryId, path, argsJson, hash);
|
|
1216
|
+
this.maybeSendDiffCapability();
|
|
1217
|
+
this.transport.send({ type: "ModifyQuerySet", add: [{ queryId, udfPath: path, args: argsJson }], remove: [] });
|
|
1218
|
+
}
|
|
1219
|
+
const listener = { onUpdate, onError };
|
|
1220
|
+
sub.listeners.add(listener);
|
|
1221
|
+
if (sub.composedValue !== void 0) onUpdate(sub.composedValue);
|
|
1222
|
+
return () => {
|
|
1223
|
+
const s = this.store.byHash.get(hash);
|
|
1224
|
+
if (!s) return;
|
|
1225
|
+
s.listeners.delete(listener);
|
|
1226
|
+
if (s.listeners.size === 0) {
|
|
1227
|
+
this.transport.send({ type: "ModifyQuerySet", add: [], remove: [s.queryId] });
|
|
1228
|
+
this.store.remove(hash);
|
|
1229
|
+
}
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
query(ref, args = {}) {
|
|
1233
|
+
return new Promise((resolve, reject) => {
|
|
1234
|
+
const unsubscribe = this.subscribe(
|
|
1235
|
+
ref,
|
|
1236
|
+
args,
|
|
1237
|
+
(value) => {
|
|
1238
|
+
resolve(value);
|
|
1239
|
+
queueMicrotask(() => unsubscribe());
|
|
1240
|
+
},
|
|
1241
|
+
(error) => {
|
|
1242
|
+
reject(new Error(error));
|
|
1243
|
+
queueMicrotask(() => unsubscribe());
|
|
1244
|
+
}
|
|
1245
|
+
);
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
mutation(ref, args = {}, opts = {}) {
|
|
1249
|
+
const path = getFunctionPath(ref);
|
|
1250
|
+
const argsJson = convexToJson2(args);
|
|
1251
|
+
const useOutbox = this.outbox !== void 0 && opts.transient !== true;
|
|
1252
|
+
if (useOutbox && this.outboxQueueDepth() >= this.outboxMaxQueueSize) {
|
|
1253
|
+
return Promise.reject(new OutboxOverflowError());
|
|
1254
|
+
}
|
|
1255
|
+
const requestId = String(this.nextRequestId++);
|
|
1256
|
+
const entry = {
|
|
1257
|
+
requestId,
|
|
1258
|
+
udfPath: path,
|
|
1259
|
+
args: argsJson,
|
|
1260
|
+
update: opts.optimisticUpdate,
|
|
1261
|
+
seed: { entropy: makeEntropy(), now: Date.now() },
|
|
1262
|
+
touched: /* @__PURE__ */ new Set(),
|
|
1263
|
+
status: { type: "unsent" }
|
|
1264
|
+
};
|
|
1265
|
+
if (useOutbox) {
|
|
1266
|
+
entry.clientId = this.outboxClientId;
|
|
1267
|
+
entry.seq = this.outboxNextSeq++;
|
|
1268
|
+
entry.order = this.nextOutboxOrder();
|
|
1269
|
+
entry.identityFingerprint = this.outboxFingerprint;
|
|
1270
|
+
entry.enqueuedAt = Date.now();
|
|
1271
|
+
}
|
|
1272
|
+
const queueBusy = useOutbox && this.hasOutboxBacklog();
|
|
1273
|
+
this.reconciler.initiate(entry);
|
|
1274
|
+
return new Promise((resolve, reject) => {
|
|
1275
|
+
this.pendingMutationCallbacks.set(requestId, { resolve, reject });
|
|
1276
|
+
if (this.closed || queueBusy) {
|
|
1277
|
+
entry.status = { type: "unsent" };
|
|
1278
|
+
} else {
|
|
1279
|
+
entry.status = { type: "inflight" };
|
|
1280
|
+
this.transport.send(this.mutationMessage(entry));
|
|
1281
|
+
}
|
|
1282
|
+
if (useOutbox) {
|
|
1283
|
+
void this.outbox.append(this.toOutboxEntry(entry)).then(() => {
|
|
1284
|
+
entry.durable = true;
|
|
1285
|
+
this.outboxDrain?.nudge();
|
|
1286
|
+
this.notifyOutboxChange();
|
|
1287
|
+
}).catch((err) => this.handleOutboxWriteError("append", entry.clientId, entry.seq, entry.udfPath, err));
|
|
1288
|
+
}
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
/** The wire `Mutation` message for `entry` — carries `(clientId, seq)` whenever an outbox is
|
|
1292
|
+
* configured (park-safety, verdict §(d)), and OMITS the fields entirely (not merely `undefined`)
|
|
1293
|
+
* when it isn't, so a client with no `outbox` sends exactly today's shape, byte-for-byte. */
|
|
1294
|
+
mutationMessage(entry) {
|
|
1295
|
+
return {
|
|
1296
|
+
type: "Mutation",
|
|
1297
|
+
requestId: entry.requestId,
|
|
1298
|
+
udfPath: entry.udfPath,
|
|
1299
|
+
args: entry.args,
|
|
1300
|
+
...entry.clientId !== void 0 ? { clientId: entry.clientId, seq: entry.seq } : {}
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
/** The persisted `OutboxStorage` twin of `entry` — only ever called when `this.outbox` (and thus
|
|
1304
|
+
* `entry.clientId`/`seq`/`order`/`enqueuedAt`) is set. */
|
|
1305
|
+
toOutboxEntry(entry) {
|
|
1306
|
+
return {
|
|
1307
|
+
clientId: entry.clientId,
|
|
1308
|
+
seq: entry.seq,
|
|
1309
|
+
requestId: entry.requestId,
|
|
1310
|
+
udfPath: entry.udfPath,
|
|
1311
|
+
args: entry.args,
|
|
1312
|
+
seed: entry.seed,
|
|
1313
|
+
order: entry.order,
|
|
1314
|
+
status: entry.status.type === "unsent" ? "unsent" : "inflight",
|
|
1315
|
+
identityFingerprint: entry.identityFingerprint,
|
|
1316
|
+
outboxVersion: OUTBOX_VERSION,
|
|
1317
|
+
enqueuedAt: entry.enqueuedAt
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
/** True while any OTHER entry is `unsent` (queued for a flush) or `parked` (queued for a future
|
|
1321
|
+
* drain) — the FIFO-preserving gate a new mutation enqueues behind (verdict §(d) "Enqueue").
|
|
1322
|
+
* A drain chunk in flight also counts (its units are `inflight`, which the scan alone would miss
|
|
1323
|
+
* when the chunk consumed the whole backlog) — otherwise a mutation issued mid-chunk would
|
|
1324
|
+
* direct-send AHEAD of a still-unsettled older unit, breaking the FIFO promise if the chunk
|
|
1325
|
+
* transient-stops and re-sends. Plain live in-flight direct-sends deliberately do NOT count:
|
|
1326
|
+
* "when empty, live sends go direct and concurrent" (T2's scoping, unchanged). */
|
|
1327
|
+
hasOutboxBacklog() {
|
|
1328
|
+
if (this.outboxDrain?.hasActiveChunk) return true;
|
|
1329
|
+
for (const e of this.reconciler.entries()) {
|
|
1330
|
+
if (e.status.type === "unsent" || e.status.type === "parked") return true;
|
|
1331
|
+
}
|
|
1332
|
+
return false;
|
|
1333
|
+
}
|
|
1334
|
+
/** Count of outbox-tracked entries not yet fully settled (excludes `completed` — already acked,
|
|
1335
|
+
* held only for the ts-gate) — the overflow cap's occupancy (verdict §(d) "Enqueue": "bounded,
|
|
1336
|
+
* default 1000"). */
|
|
1337
|
+
outboxQueueDepth() {
|
|
1338
|
+
let n = 0;
|
|
1339
|
+
for (const e of this.reconciler.entries()) {
|
|
1340
|
+
if (e.clientId !== void 0 && e.status.type !== "completed") n++;
|
|
1341
|
+
}
|
|
1342
|
+
return n;
|
|
1343
|
+
}
|
|
1344
|
+
/** Monotonic `OutboxEntry.order` allocator — see the `outboxOrderCounter` field doc. */
|
|
1345
|
+
nextOutboxOrder() {
|
|
1346
|
+
const now = Date.now();
|
|
1347
|
+
this.outboxOrderCounter = this.outboxOrderCounter >= now ? this.outboxOrderCounter + 1 : now;
|
|
1348
|
+
return this.outboxOrderCounter;
|
|
1349
|
+
}
|
|
1350
|
+
action(ref, args = {}) {
|
|
1351
|
+
const requestId = String(this.nextRequestId++);
|
|
1352
|
+
return new Promise((resolve, reject) => {
|
|
1353
|
+
this.pendingActions.set(requestId, { resolve, reject });
|
|
1354
|
+
this.transport.send({ type: "Action", requestId, udfPath: getFunctionPath(ref), args: convexToJson2(args) });
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
/** Set (or clear) the session identity for this connection; the server re-runs subscriptions under it. */
|
|
1358
|
+
setAuth(token) {
|
|
1359
|
+
this.hasSetAuth = true;
|
|
1360
|
+
this.lastAuthToken = token;
|
|
1361
|
+
this.transport.send({ type: "SetAuth", token });
|
|
1362
|
+
if (this.outbox && !this.sessionFingerprintActive) {
|
|
1363
|
+
if (!token) {
|
|
1364
|
+
this.outboxFingerprint = "anon";
|
|
1365
|
+
} else {
|
|
1366
|
+
const forToken = token;
|
|
1367
|
+
void sha256Hex(forToken).then((hex) => {
|
|
1368
|
+
if (this.lastAuthToken === forToken) this.outboxFingerprint = hex;
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
/** Managed-session fingerprinting (spec decision 9): a `createAuthClient` calls this so the durable
|
|
1374
|
+
* outbox's `identityFingerprint` derives from the STABLE `sessionId`, not the rotating access
|
|
1375
|
+
* token — otherwise a rotation mid-drain would orphan queued offline mutations under a new
|
|
1376
|
+
* fingerprint. Pass `null` to hand the fingerprint back to the raw `setAuth` token-hash path
|
|
1377
|
+
* (e.g. on sign-out). No-op when no outbox is configured. */
|
|
1378
|
+
setSessionFingerprint(sessionId) {
|
|
1379
|
+
this.sessionFingerprintActive = sessionId !== null;
|
|
1380
|
+
if (sessionId === null) {
|
|
1381
|
+
this.sessionFingerprintKey = null;
|
|
1382
|
+
if (this.outbox) {
|
|
1383
|
+
const token = this.lastAuthToken;
|
|
1384
|
+
if (!token) this.outboxFingerprint = "anon";
|
|
1385
|
+
else void sha256Hex(token).then((hex) => {
|
|
1386
|
+
if (this.lastAuthToken === token && !this.sessionFingerprintActive) this.outboxFingerprint = hex;
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
if (!this.outbox) return;
|
|
1392
|
+
const key = sessionFingerprintKey(sessionId);
|
|
1393
|
+
this.sessionFingerprintKey = key;
|
|
1394
|
+
void sha256Hex(key).then((hex) => {
|
|
1395
|
+
if (this.sessionFingerprintKey === key) this.outboxFingerprint = hex;
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
/** Publish an ephemeral event (presence/typing) — bypasses the engine. */
|
|
1399
|
+
publishEphemeral(topic, event) {
|
|
1400
|
+
this.transport.send({ type: "EphemeralPublish", topic, event: convexToJson2(event) });
|
|
1401
|
+
}
|
|
1402
|
+
/** Listen for ephemeral broadcasts (presence/typing) from other clients. */
|
|
1403
|
+
onBroadcast(listener) {
|
|
1404
|
+
this.broadcastListeners.add(listener);
|
|
1405
|
+
return () => this.broadcastListeners.delete(listener);
|
|
1406
|
+
}
|
|
1407
|
+
close() {
|
|
1408
|
+
this.outboxDrain?.stop();
|
|
1409
|
+
this.outboxBroadcast?.close();
|
|
1410
|
+
this.disposeTransport();
|
|
1411
|
+
this.disposeClose();
|
|
1412
|
+
this.disposeReopen?.();
|
|
1413
|
+
this.transport.close();
|
|
1414
|
+
this.onTransportClosed();
|
|
1415
|
+
}
|
|
1416
|
+
/** @internal test/debug only — the observed-inclusion frontier (resets to 0 at close). */
|
|
1417
|
+
get __maxObservedTs() {
|
|
1418
|
+
return this.reconciler.maxObservedTs;
|
|
1419
|
+
}
|
|
1420
|
+
/** @internal test/debug only — the live pending-mutation log, in requestId order. */
|
|
1421
|
+
get __pending() {
|
|
1422
|
+
return this.reconciler.entries();
|
|
1423
|
+
}
|
|
1424
|
+
/** @internal test/debug only — the current `identityFingerprint` cache (see `setAuth`); polling
|
|
1425
|
+
* this (rather than calling `mutation()` repeatedly, which consumes seqs) is how a test waits
|
|
1426
|
+
* out the async SHA-256 digest without depending on a fixed tick count. */
|
|
1427
|
+
get __outboxFingerprint() {
|
|
1428
|
+
return this.outboxFingerprint;
|
|
1429
|
+
}
|
|
1430
|
+
onServerMessage(msg) {
|
|
1431
|
+
switch (msg.type) {
|
|
1432
|
+
case "Transition": {
|
|
1433
|
+
if (this.resyncing) {
|
|
1434
|
+
this.reconciler.ingestTransition(msg.modifications, msg.endVersion.ts);
|
|
1435
|
+
this.version = msg.endVersion;
|
|
1436
|
+
this.resyncing = false;
|
|
1437
|
+
if (this.outboxAwaitingBaseline) this.markBaselineAdopted();
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
if (!versionsEqual(msg.startVersion, this.version)) {
|
|
1441
|
+
this.resync();
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
this.reconciler.ingestTransition(msg.modifications, msg.endVersion.ts);
|
|
1445
|
+
this.version = msg.endVersion;
|
|
1446
|
+
if (this.outboxAwaitingBaseline) this.markBaselineAdopted();
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
case "MutationResponse": {
|
|
1450
|
+
if (this.outboxDrain?.handles(msg.requestId)) {
|
|
1451
|
+
this.outboxDrain.onResponse(msg);
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
const entry = this.reconciler.getEntry(msg.requestId);
|
|
1455
|
+
const pending = this.pendingMutationCallbacks.get(msg.requestId);
|
|
1456
|
+
const hadAwaiter = pending !== void 0;
|
|
1457
|
+
this.pendingMutationCallbacks.delete(msg.requestId);
|
|
1458
|
+
if (msg.success) {
|
|
1459
|
+
pending?.resolve(jsonToConvex4(msg.value ?? null));
|
|
1460
|
+
this.reconciler.onMutationSuccess(msg.requestId, msg.ts);
|
|
1461
|
+
this.dequeueOutboxEntry(entry);
|
|
1462
|
+
} else {
|
|
1463
|
+
pending?.reject(this.mutationError(msg.error, msg.code));
|
|
1464
|
+
this.reconciler.onMutationFailure(msg.requestId);
|
|
1465
|
+
if (entry?.clientId !== void 0 && entry.seq !== void 0) {
|
|
1466
|
+
this.outboxMarkFailed(entry.clientId, entry.seq, msg.error, msg.code);
|
|
1467
|
+
}
|
|
1468
|
+
if (!hadAwaiter) this.notifyMutationFailed(entry?.clientId, entry?.seq, entry?.udfPath ?? "unknown", { message: msg.error, code: msg.code });
|
|
1469
|
+
}
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
case "ConnectAck":
|
|
1473
|
+
this.handleConnectAck(msg);
|
|
1474
|
+
return;
|
|
1475
|
+
case "ActionResponse": {
|
|
1476
|
+
const pending = this.pendingActions.get(msg.requestId);
|
|
1477
|
+
if (pending) {
|
|
1478
|
+
this.pendingActions.delete(msg.requestId);
|
|
1479
|
+
if (msg.success) pending.resolve(jsonToConvex4(msg.value));
|
|
1480
|
+
else pending.reject(new Error(msg.error));
|
|
1481
|
+
}
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
case "Broadcast": {
|
|
1485
|
+
const event = jsonToConvex4(msg.event);
|
|
1486
|
+
for (const listener of this.broadcastListeners) listener(msg.topic, event);
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
default:
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
/** A frame was missed: reset and re-subscribe all live queries; adopt the server's next state. */
|
|
1494
|
+
resync() {
|
|
1495
|
+
this.resyncing = true;
|
|
1496
|
+
this.version = { ...INITIAL_VERSION };
|
|
1497
|
+
const subs = [...this.store.byId.values()];
|
|
1498
|
+
if (subs.length === 0) {
|
|
1499
|
+
this.resyncing = false;
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
this.transport.send({
|
|
1503
|
+
type: "ModifyQuerySet",
|
|
1504
|
+
// `resultHash` (subscription resume): echoed ONLY for a sub that was actually delivered a
|
|
1505
|
+
// base value and still has its fingerprint on hand — a failed sub (`serverValue` never set),
|
|
1506
|
+
// a never-answered sub, or one whose last `QueryUpdated` had no `hash` (old server) echoes
|
|
1507
|
+
// nothing, falling through to today's full-send byte-for-byte. Note this condition doesn't
|
|
1508
|
+
// check "currently failed" — a sub that failed AFTER a prior success still has a retained
|
|
1509
|
+
// `serverValue`/`lastHash` from that success and still echoes it here. That's sound: the
|
|
1510
|
+
// server only replies `QueryUnchanged` if the FRESH re-run (against live, current state)
|
|
1511
|
+
// hashes equal to the echoed base — i.e. the query has recovered back to that exact value —
|
|
1512
|
+
// in which case `QueryUnchanged` renders exactly what a full `QueryUpdated` send would have
|
|
1513
|
+
// delivered. Any other outcome (still failing, or recovered to a different value) arrives as
|
|
1514
|
+
// a normal `QueryFailed`/`QueryUpdated` modification, not `QueryUnchanged`.
|
|
1515
|
+
add: subs.map((s) => ({
|
|
1516
|
+
queryId: s.queryId,
|
|
1517
|
+
udfPath: s.path,
|
|
1518
|
+
args: s.args,
|
|
1519
|
+
...s.answered && s.serverValue !== void 0 && s.lastHash !== void 0 ? { resultHash: s.lastHash } : {},
|
|
1520
|
+
// DLR Stage 3: echo the client's observed-inclusion frontier so the server can skip the
|
|
1521
|
+
// re-run entirely when nothing touched this query's read-set since. Fresh `subscribe()`
|
|
1522
|
+
// never sets this — only a resume resubscribe has a meaningful watermark to echo. `Math.max`
|
|
1523
|
+
// with `resumeSinceTs` (the pre-close snapshot) covers both resume paths: a real transport
|
|
1524
|
+
// close resets `reconciler.maxObservedTs` to 0 (see `resumeSinceTs`'s own comment), so the
|
|
1525
|
+
// snapshot wins there; a same-session drift resync never closed, so `maxObservedTs` is still
|
|
1526
|
+
// live and wins instead.
|
|
1527
|
+
sinceTs: Math.max(this.resumeSinceTs, this.reconciler.maxObservedTs)
|
|
1528
|
+
})),
|
|
1529
|
+
remove: []
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
onTransportClosed() {
|
|
1533
|
+
this.closed = true;
|
|
1534
|
+
this.outboxConnectSent = false;
|
|
1535
|
+
this.diffCapabilitySent = false;
|
|
1536
|
+
this.outboxDrain?.onTransportClosed();
|
|
1537
|
+
this.resumeSinceTs = this.reconciler.maxObservedTs;
|
|
1538
|
+
const { rejectedInflight, parked } = this.reconciler.closeSession(this.outboxArmed);
|
|
1539
|
+
for (const rid of rejectedInflight) {
|
|
1540
|
+
const pending = this.pendingMutationCallbacks.get(rid);
|
|
1541
|
+
this.pendingMutationCallbacks.delete(rid);
|
|
1542
|
+
pending?.reject(new MutationUndeliveredError());
|
|
1543
|
+
}
|
|
1544
|
+
for (const rid of parked) {
|
|
1545
|
+
const entry = this.reconciler.getEntry(rid);
|
|
1546
|
+
if (entry?.clientId !== void 0 && entry.seq !== void 0) this.outboxUpdateStatus(entry.clientId, entry.seq, "parked");
|
|
1547
|
+
}
|
|
1548
|
+
for (const [, pending] of this.pendingActions) pending.reject(new Error("connection closed"));
|
|
1549
|
+
this.pendingActions.clear();
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* T6: the transport reconnected (a fresh session — the server has no state for it). Order is
|
|
1553
|
+
* load-bearing (verdict §(c) event 6): `SetAuth` replay first (the server re-runs subscriptions
|
|
1554
|
+
* under the right identity), THEN resubscribe every live query (the existing resync path — it
|
|
1555
|
+
* adopts the reply as a fresh baseline regardless of its start version), THEN flush every
|
|
1556
|
+
* `unsent` mutation FIFO — each transitions `unsent` -> `inflight` reusing its ORIGINAL
|
|
1557
|
+
* `requestId` (never re-minted), so the promise created at `mutation()` call time stays the one
|
|
1558
|
+
* that resolves when the new session's `MutationResponse` arrives.
|
|
1559
|
+
*/
|
|
1560
|
+
onTransportReopened() {
|
|
1561
|
+
this.closed = false;
|
|
1562
|
+
if (this.hasSetAuth) this.transport.send({ type: "SetAuth", token: this.lastAuthToken });
|
|
1563
|
+
this.maybeSendDiffCapability();
|
|
1564
|
+
this.resync();
|
|
1565
|
+
if (this.outbox) {
|
|
1566
|
+
this.initiateHandshake(this.resyncing);
|
|
1567
|
+
this.outboxDrain?.nudge();
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
for (const entry of this.reconciler.unsentInOrder()) {
|
|
1571
|
+
entry.status = { type: "inflight" };
|
|
1572
|
+
this.transport.send(this.mutationMessage(entry));
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
/** Send the `Connect` resume handshake once per connection (idempotent via `outboxConnectSent`),
|
|
1576
|
+
* arming the baseline await. Shared by the reopen path and the drain's first-connect path (Task 4
|
|
1577
|
+
* / T3 handoff #1: a fresh-client-first-connect after reload has no reopen event, so the drain
|
|
1578
|
+
* triggers the same handshake on becoming leader with a durable backlog). */
|
|
1579
|
+
initiateHandshake(expectTransition) {
|
|
1580
|
+
if (this.outboxConnectSent || this.closed || !this.outbox) return;
|
|
1581
|
+
this.outboxConnectSent = true;
|
|
1582
|
+
this.beginBaselineAwait(expectTransition);
|
|
1583
|
+
this.sendConnect();
|
|
1584
|
+
}
|
|
1585
|
+
/** True iff at least one live subscription has NOT yet received its first server reply
|
|
1586
|
+
* (`!sub.answered` — set by `ingestTransition` on EITHER outcome, `QueryUpdated` or `QueryFailed`).
|
|
1587
|
+
* The drain's first-connect `ensureInitialHandshake` gate (T4 bug fix, later widened to cover the
|
|
1588
|
+
* failed-query shape too — re-review FIX 2): a subscription created (and answered) BEFORE the
|
|
1589
|
+
* drain's async hydrate finishes has already consumed its one-shot Transition by the time the
|
|
1590
|
+
* handshake arms — waiting for ANOTHER one that will never come on a quiet deployment would starve
|
|
1591
|
+
* `whenBaselineAdopted()` (and so the drain) forever. Only a subscription still awaiting its first
|
|
1592
|
+
* reply guarantees a future Transition is actually coming — that's the one worth waiting for.
|
|
1593
|
+
* (Deliberately NOT `sub.serverValue === undefined`: a `QueryFailed` reply never sets `serverValue`
|
|
1594
|
+
* — there's no base to render — so that check misclassified an already-answered failed query as
|
|
1595
|
+
* still-undelivered and reproduced the same deadlock via the failed-query path.) */
|
|
1596
|
+
hasUndeliveredSubscription() {
|
|
1597
|
+
for (const sub of this.store.byId.values()) {
|
|
1598
|
+
if (!sub.answered) return true;
|
|
1599
|
+
}
|
|
1600
|
+
return false;
|
|
1601
|
+
}
|
|
1602
|
+
/* ---------------------------------------------------------------------------------------------
|
|
1603
|
+
* T3 — the Connect resume handshake, verdict settlement, the baseline-gated drop rule, and reset.
|
|
1604
|
+
* ------------------------------------------------------------------------------------------- */
|
|
1605
|
+
/** @internal T4's drain gate. Resolves once the first post-`Connect` baseline Transition has been
|
|
1606
|
+
* adopted through S3 (verdict §(d) / spec decision 5). Resolves immediately when no handshake is
|
|
1607
|
+
* in flight (nothing to await) or when a reopen had no live subscriptions to re-baseline. */
|
|
1608
|
+
whenBaselineAdopted() {
|
|
1609
|
+
if (!this.outboxAwaitingBaseline) return Promise.resolve();
|
|
1610
|
+
return new Promise((resolve) => this.outboxBaselineResolvers.push(resolve));
|
|
1611
|
+
}
|
|
1612
|
+
/** @internal test/debug — the last `ConnectAck.deploymentId` (the same-timeline proof stamp), or
|
|
1613
|
+
* `undefined` before any handshake completed. */
|
|
1614
|
+
getOutboxDeploymentId() {
|
|
1615
|
+
return this.outboxDeploymentId;
|
|
1616
|
+
}
|
|
1617
|
+
/** @internal test/debug — whether the S4 park swap is armed (a `ConnectAck` has proven dedup). */
|
|
1618
|
+
get __outboxArmed() {
|
|
1619
|
+
return this.outboxArmed;
|
|
1620
|
+
}
|
|
1621
|
+
/** Begin awaiting the post-`Connect` baseline. `expectTransition` is true iff a baseline Transition
|
|
1622
|
+
* is actually coming — for a reopen, `this.resyncing` (set iff `resync()` re-subscribed live
|
|
1623
|
+
* queries); for a first connect, whether any live subscription is still awaiting its first
|
|
1624
|
+
* delivery (`hasUndeliveredSubscription()` — NOT merely whether one exists: a subscription
|
|
1625
|
+
* created and already answered before the handshake armed has nothing left to wait for). With
|
|
1626
|
+
* nothing pending, there is no baseline frame coming and adoption is immediate. */
|
|
1627
|
+
beginBaselineAwait(expectTransition) {
|
|
1628
|
+
this.outboxAwaitingBaseline = expectTransition;
|
|
1629
|
+
if (!expectTransition) this.markBaselineAdopted();
|
|
1630
|
+
}
|
|
1631
|
+
/** The baseline Transition adopted (or there was none to await): fire every deferred `applied`
|
|
1632
|
+
* layer drop (each flicker-free now — the baseline renders the effect), release the drain gate,
|
|
1633
|
+
* and wake the drain (reconnect-after-baseline). */
|
|
1634
|
+
markBaselineAdopted() {
|
|
1635
|
+
this.outboxAwaitingBaseline = false;
|
|
1636
|
+
for (const rid of this.outboxPendingDrops.splice(0)) this.reconciler.onVerdictAfterBaseline(rid);
|
|
1637
|
+
const resolvers = this.outboxBaselineResolvers.splice(0);
|
|
1638
|
+
for (const resolve of resolvers) resolve();
|
|
1639
|
+
this.outboxDrain?.nudge();
|
|
1640
|
+
}
|
|
1641
|
+
/** Send the `Connect` resume handshake: this tab-session's clientId, the `held` durable entries
|
|
1642
|
+
* (every not-yet-settled `(clientId, seq)` in the log — the server classifies each into
|
|
1643
|
+
* `ConnectAck.results`), and `ackedThrough` (the contiguous settled-prefix per clientId, for
|
|
1644
|
+
* server-side retention pruning). Delegates the pure computation to `./connect-handshake` — the
|
|
1645
|
+
* SAME shared module the headless drain (`headless-drain.ts`) builds its own `Connect` from. */
|
|
1646
|
+
sendConnect() {
|
|
1647
|
+
const held = outboxHeldFromLog(this.reconciler.entries());
|
|
1648
|
+
this.transport.send(buildConnectMessage(makeEntropy(), this.outboxClientId, held));
|
|
1649
|
+
}
|
|
1650
|
+
/** DLR Stage 2a — advertise by-id diff support once per connection for a NON-outbox client (an
|
|
1651
|
+
* outbox client's resume `Connect` already carries the flag). A capability-only `Connect` (no
|
|
1652
|
+
* `clientId`/`held`/`ackedThrough`) is the reserved server no-op path — it records the capability
|
|
1653
|
+
* and sends no `ConnectAck` — so it never interferes with the outbox handshake or backpressure.
|
|
1654
|
+
* Sent BEFORE the first `ModifyQuerySet` (fresh connect) and before `resync()` (reopen) so the
|
|
1655
|
+
* server has the capability recorded when it decides a by-id sub's initial answer; the ordered
|
|
1656
|
+
* transport guarantees delivery order. Even if it somehow arrived late the diff path self-heals
|
|
1657
|
+
* (a pre-capability RERUN answer, then diffs resume), but sending it first keeps the common path
|
|
1658
|
+
* on the diff answer from the very first subscribe. */
|
|
1659
|
+
maybeSendDiffCapability() {
|
|
1660
|
+
if (this.outbox || this.diffCapabilitySent || this.closed) return;
|
|
1661
|
+
this.diffCapabilitySent = true;
|
|
1662
|
+
this.transport.send({ type: "Connect", sessionId: makeEntropy(), supportsQueryDiff: true });
|
|
1663
|
+
}
|
|
1664
|
+
/** Process a `ConnectAck` (verdict §(e)): the capability proof arms the S4 park swap; the
|
|
1665
|
+
* deploymentId is surfaced + persisted; `known: false` triggers `onClientReset`; otherwise each
|
|
1666
|
+
* classified `held` seq is settled (`applied`/`failed`/`stale` terminal; `unknown` left for the
|
|
1667
|
+
* drain). */
|
|
1668
|
+
handleConnectAck(msg) {
|
|
1669
|
+
this.outboxArmed = true;
|
|
1670
|
+
this.outboxDeploymentId = msg.deploymentId;
|
|
1671
|
+
if (this.outbox && this.outboxClientId !== void 0) {
|
|
1672
|
+
void this.outbox.setMeta(this.outboxClientId, { nextSeq: this.outboxNextSeq, deployment: msg.deploymentId }).catch((err) => this.handleOutboxWriteError("setMeta", this.outboxClientId, void 0, void 0, err));
|
|
1673
|
+
}
|
|
1674
|
+
if (!msg.known) {
|
|
1675
|
+
void this.onClientReset().then(() => this.outboxDrain?.nudge());
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
for (const v of msg.results) this.settleVerdict(v);
|
|
1679
|
+
this.outboxDrain?.nudge();
|
|
1680
|
+
}
|
|
1681
|
+
/** Settle one classified `held` seq from a `ConnectAck` (or, later, a drain replay-ack). */
|
|
1682
|
+
settleVerdict(v) {
|
|
1683
|
+
const entry = this.findOutboxEntry(v.clientId, v.seq);
|
|
1684
|
+
switch (v.verdict) {
|
|
1685
|
+
case "applied": {
|
|
1686
|
+
const value = v.valueMissing ? null : jsonToConvex4(v.value ?? null);
|
|
1687
|
+
if (entry) this.resolvePending(entry.requestId, value);
|
|
1688
|
+
this.outboxDequeue(v.clientId, v.seq);
|
|
1689
|
+
if (entry) this.dropAfterBaseline(entry.requestId);
|
|
1690
|
+
break;
|
|
1691
|
+
}
|
|
1692
|
+
case "failed": {
|
|
1693
|
+
const hadAwaiter = entry ? this.pendingMutationCallbacks.has(entry.requestId) : false;
|
|
1694
|
+
const message = `mutation "${entry?.udfPath ?? "unknown"}" failed`;
|
|
1695
|
+
if (entry) this.rejectPending(entry.requestId, this.mutationError(message, v.code));
|
|
1696
|
+
this.outboxMarkFailed(v.clientId, v.seq, message, v.code);
|
|
1697
|
+
if (entry) this.reconciler.onMutationFailure(entry.requestId);
|
|
1698
|
+
if (!hadAwaiter) this.notifyMutationFailed(v.clientId, v.seq, entry?.udfPath ?? "unknown", { message, code: v.code });
|
|
1699
|
+
break;
|
|
1700
|
+
}
|
|
1701
|
+
case "stale": {
|
|
1702
|
+
const hadAwaiter = entry ? this.pendingMutationCallbacks.has(entry.requestId) : false;
|
|
1703
|
+
const message = "mutation disowned (STALE_CLIENT)";
|
|
1704
|
+
const code = v.code ?? "STALE_CLIENT";
|
|
1705
|
+
if (entry) this.rejectPending(entry.requestId, this.mutationError(message, code));
|
|
1706
|
+
this.outboxMarkFailed(v.clientId, v.seq, message, code);
|
|
1707
|
+
if (entry) this.reconciler.onMutationFailure(entry.requestId);
|
|
1708
|
+
if (!hadAwaiter) this.notifyMutationFailed(v.clientId, v.seq, entry?.udfPath ?? "unknown", { message, code });
|
|
1709
|
+
break;
|
|
1710
|
+
}
|
|
1711
|
+
case "unknown":
|
|
1712
|
+
break;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
/** `known: false` — the server disowned this client's history (verdict §(d) Retention). Re-mint a
|
|
1716
|
+
* fresh clientId + meta; re-enqueue every `unsent` entry under the new clientId + NEW seqs (never
|
|
1717
|
+
* applied, so safe); reject every `parked` entry LOUDLY (in-flight-at-disconnect, no server dedup
|
|
1718
|
+
* → a blind resend could double-apply); fire the `onClientReset` callback. */
|
|
1719
|
+
async onClientReset() {
|
|
1720
|
+
const oldClientId = this.outboxClientId;
|
|
1721
|
+
const fresh = defaultMintClientId();
|
|
1722
|
+
this.outboxClientId = fresh;
|
|
1723
|
+
this.outboxNextSeq = 0;
|
|
1724
|
+
let parkedRejected = 0;
|
|
1725
|
+
let unsentReEnqueued = 0;
|
|
1726
|
+
for (const entry of [...this.reconciler.entries()]) {
|
|
1727
|
+
const recordedClientId = entry.clientId ?? oldClientId;
|
|
1728
|
+
if (entry.status.type === "parked") {
|
|
1729
|
+
const hadAwaiter = this.pendingMutationCallbacks.has(entry.requestId);
|
|
1730
|
+
const resetMessage = "the server disowned this client's mutation history (swept/foreign timeline)";
|
|
1731
|
+
if (recordedClientId !== void 0 && entry.seq !== void 0) {
|
|
1732
|
+
this.outboxMarkFailed(recordedClientId, entry.seq, resetMessage, "OFFLINE_CLIENT_RESET");
|
|
1733
|
+
}
|
|
1734
|
+
this.rejectPending(entry.requestId, new OfflineClientResetError());
|
|
1735
|
+
this.reconciler.onMutationFailure(entry.requestId);
|
|
1736
|
+
if (!hadAwaiter) this.notifyMutationFailed(recordedClientId, entry.seq, entry.udfPath, { message: resetMessage, code: "OFFLINE_CLIENT_RESET" });
|
|
1737
|
+
parkedRejected++;
|
|
1738
|
+
} else if (entry.status.type === "unsent") {
|
|
1739
|
+
if (recordedClientId !== void 0 && entry.seq !== void 0) this.outboxDequeue(recordedClientId, entry.seq);
|
|
1740
|
+
entry.clientId = fresh;
|
|
1741
|
+
entry.seq = this.outboxNextSeq++;
|
|
1742
|
+
entry.order = this.nextOutboxOrder();
|
|
1743
|
+
this.outboxAppend(entry);
|
|
1744
|
+
unsentReEnqueued++;
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
if (this.outbox) {
|
|
1748
|
+
await mintIdentity(this.outbox, { mintClientId: () => fresh, deployment: this.outboxDeploymentId });
|
|
1749
|
+
await this.outbox.setMeta(fresh, { nextSeq: this.outboxNextSeq, deployment: this.outboxDeploymentId });
|
|
1750
|
+
}
|
|
1751
|
+
this.onClientResetCallback?.({ oldClientId, newClientId: fresh, unsentReEnqueued, parkedRejected });
|
|
1752
|
+
}
|
|
1753
|
+
/** The in-memory log entry with this recorded `(clientId, seq)`, or `undefined`. */
|
|
1754
|
+
findOutboxEntry(clientId, seq) {
|
|
1755
|
+
for (const e of this.reconciler.entries()) {
|
|
1756
|
+
if (e.clientId === clientId && e.seq === seq) return e;
|
|
1757
|
+
}
|
|
1758
|
+
return void 0;
|
|
1759
|
+
}
|
|
1760
|
+
/** Resolve a pending mutation promise by requestId (no-op if it already settled / has no awaiter). */
|
|
1761
|
+
resolvePending(requestId, value) {
|
|
1762
|
+
const pending = this.pendingMutationCallbacks.get(requestId);
|
|
1763
|
+
this.pendingMutationCallbacks.delete(requestId);
|
|
1764
|
+
pending?.resolve(value);
|
|
1765
|
+
}
|
|
1766
|
+
/** Reject a pending mutation promise by requestId (no-op if it already settled / has no awaiter). */
|
|
1767
|
+
rejectPending(requestId, error) {
|
|
1768
|
+
const pending = this.pendingMutationCallbacks.get(requestId);
|
|
1769
|
+
this.pendingMutationCallbacks.delete(requestId);
|
|
1770
|
+
pending?.reject(error);
|
|
1771
|
+
}
|
|
1772
|
+
/** Drop an `applied` cross-session entry's layer — deferred until the baseline is adopted (so the
|
|
1773
|
+
* drop is flicker-free), or immediately if it already has. */
|
|
1774
|
+
dropAfterBaseline(requestId) {
|
|
1775
|
+
if (this.outboxAwaitingBaseline) this.outboxPendingDrops.push(requestId);
|
|
1776
|
+
else this.reconciler.onVerdictAfterBaseline(requestId);
|
|
1777
|
+
}
|
|
1778
|
+
/** Dequeue a settled durable entry from the outbox store (no-op without an outbox / clientId). */
|
|
1779
|
+
dequeueOutboxEntry(entry) {
|
|
1780
|
+
if (entry?.clientId !== void 0 && entry.seq !== void 0) this.outboxDequeue(entry.clientId, entry.seq);
|
|
1781
|
+
}
|
|
1782
|
+
/** An `Error` carrying the server's terminal verdict `code` (STALE_CLIENT, an app error code) so
|
|
1783
|
+
* the drain's coded-vs-codeless retry policy (T4) and apps can key off it. */
|
|
1784
|
+
mutationError(message, code) {
|
|
1785
|
+
const err = new Error(message);
|
|
1786
|
+
if (code !== void 0) err.code = code;
|
|
1787
|
+
return err;
|
|
1788
|
+
}
|
|
1789
|
+
/* ---------------------------------------------------------------------------------------------
|
|
1790
|
+
* Task 4 — the drain host. These bind the drain's `DrainHost` seam to the client's private state
|
|
1791
|
+
* so the T3 settlement primitives (`resolvePending`/`rejectPending`, `dequeue`, the drop rule) are
|
|
1792
|
+
* REUSED by the drain, not forked (verdict §(d) "Drain").
|
|
1793
|
+
* ------------------------------------------------------------------------------------------- */
|
|
1794
|
+
/** @internal test/debug — the live drain (Task 4), or `undefined` without an outbox. */
|
|
1795
|
+
get __outboxDrain() {
|
|
1796
|
+
return this.outboxDrain;
|
|
1797
|
+
}
|
|
1798
|
+
makeDrainHost() {
|
|
1799
|
+
return {
|
|
1800
|
+
outbox: this.outbox,
|
|
1801
|
+
currentClientId: () => this.outboxClientId,
|
|
1802
|
+
currentFingerprint: () => this.outboxFingerprint,
|
|
1803
|
+
transportOpen: () => !this.closed,
|
|
1804
|
+
isArmed: () => this.outboxArmed,
|
|
1805
|
+
drainable: () => this.drainableEntries(),
|
|
1806
|
+
addHydrated: (entry) => this.addHydratedEntry(entry),
|
|
1807
|
+
ensureInitialHandshake: () => this.initiateHandshake(this.hasUndeliveredSubscription()),
|
|
1808
|
+
setStatus: (entry, status) => {
|
|
1809
|
+
entry.status = { type: status };
|
|
1810
|
+
if (entry.clientId !== void 0 && entry.seq !== void 0) this.outboxUpdateStatus(entry.clientId, entry.seq, status);
|
|
1811
|
+
},
|
|
1812
|
+
batchEntry: (entry) => this.drainBatchEntry(entry),
|
|
1813
|
+
sendBatch: (entries) => this.transport.send({ type: "MutationBatch", entries }),
|
|
1814
|
+
settleApplied: (requestId, value, replayed, ts) => this.drainSettleApplied(requestId, value, replayed, ts),
|
|
1815
|
+
settleTerminal: (requestId, code, message) => this.drainSettleTerminal(requestId, code, message),
|
|
1816
|
+
whenBaselineAdopted: () => this.whenBaselineAdopted()
|
|
1817
|
+
};
|
|
1818
|
+
}
|
|
1819
|
+
/** Drain-eligible entries: durable, recorded `(clientId, seq)`, still `unsent`/`parked`, FIFO by
|
|
1820
|
+
* the persisted `order`. Excludes `inflight` (a live direct-send or an in-flight chunk unit) and
|
|
1821
|
+
* `completed`. */
|
|
1822
|
+
drainableEntries() {
|
|
1823
|
+
return this.reconciler.entries().filter(
|
|
1824
|
+
(e) => e.clientId !== void 0 && e.seq !== void 0 && e.durable === true && (e.status.type === "unsent" || e.status.type === "parked")
|
|
1825
|
+
).sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
1826
|
+
}
|
|
1827
|
+
/** Add a hydrated durable entry into the log under a FRESH requestId (the persisted requestId was
|
|
1828
|
+
* session-correlation only; a fresh one avoids colliding with this session's requestId counter).
|
|
1829
|
+
* Idempotent by `(clientId, seq)` — a direct-send this session already tracks is not re-added.
|
|
1830
|
+
* T5: `entry.update` is populated from the `optimisticUpdates` registry (hydrate-only lookup — a
|
|
1831
|
+
* live call-site closure is never in play here, there IS no live call site for a cross-reload
|
|
1832
|
+
* entry); a registry miss is layerless (no `update` at all), a clean drop under the baseline-gated
|
|
1833
|
+
* drop rule exactly as T4 shipped it. */
|
|
1834
|
+
addHydratedEntry(e) {
|
|
1835
|
+
if (dropIfNonReplayable(this.outbox, e)) return;
|
|
1836
|
+
for (const existing of this.reconciler.entries()) {
|
|
1837
|
+
if (existing.clientId === e.clientId && existing.seq === e.seq) return;
|
|
1838
|
+
}
|
|
1839
|
+
this.outboxOrderCounter = Math.max(this.outboxOrderCounter, e.order);
|
|
1840
|
+
const entry = {
|
|
1841
|
+
requestId: String(this.nextRequestId++),
|
|
1842
|
+
udfPath: e.udfPath,
|
|
1843
|
+
args: e.args,
|
|
1844
|
+
seed: e.seed,
|
|
1845
|
+
touched: /* @__PURE__ */ new Set(),
|
|
1846
|
+
status: { type: "unsent" },
|
|
1847
|
+
clientId: e.clientId,
|
|
1848
|
+
seq: e.seq,
|
|
1849
|
+
order: e.order,
|
|
1850
|
+
identityFingerprint: e.identityFingerprint,
|
|
1851
|
+
enqueuedAt: e.enqueuedAt,
|
|
1852
|
+
durable: true,
|
|
1853
|
+
update: this.lookupHydratedUpdate(e.udfPath)
|
|
1854
|
+
};
|
|
1855
|
+
this.reconciler.addHydrated(entry);
|
|
1856
|
+
}
|
|
1857
|
+
/** T5: the registry lookup `addHydratedEntry` makes — hydrate-time ONLY (verdict §(d): "the
|
|
1858
|
+
* registry is consulted at hydrate only"). A miss warns ONCE per udfPath (not per entry — a
|
|
1859
|
+
* backlog of many unregistered entries for the same udfPath warns once) and returns `undefined`:
|
|
1860
|
+
* the entry still drains fine, only its optimistic rendering is skipped (spec §(k)6). */
|
|
1861
|
+
lookupHydratedUpdate(udfPath) {
|
|
1862
|
+
const fn = this.optimisticUpdates[udfPath];
|
|
1863
|
+
if (fn) return fn;
|
|
1864
|
+
if (!this.optimisticUpdateMissWarned.has(udfPath)) {
|
|
1865
|
+
this.optimisticUpdateMissWarned.add(udfPath);
|
|
1866
|
+
console.warn(
|
|
1867
|
+
`[helipod] outbox: no optimisticUpdates registered for "${udfPath}" \u2014 a hydrated cross-reload mutation for it will drain without an optimistic layer (rendering only; the mutation itself is unaffected)`
|
|
1868
|
+
);
|
|
1869
|
+
}
|
|
1870
|
+
return void 0;
|
|
1871
|
+
}
|
|
1872
|
+
/* ---------------------------------------------------------------------------------------------
|
|
1873
|
+
* T-crosstab (browser-ux spec Part A) — live cross-tab rendering. Extends the hydrate machinery
|
|
1874
|
+
* above (`addHydratedEntry`/`lookupHydratedUpdate`) with live callers driven by the broadcast
|
|
1875
|
+
* channel, instead of its one construction-time caller (`OutboxDrain#hydrateOnce`).
|
|
1876
|
+
* ------------------------------------------------------------------------------------------- */
|
|
1877
|
+
/** The one predicate distinguishing a MIRRORED entry (another tab-session's durable append, or
|
|
1878
|
+
* this tab's own past-session hydrate — either way, no live promise) from an entry THIS instance
|
|
1879
|
+
* itself initiated live (hazard (a), "own-tab discrimination"): durable AND no
|
|
1880
|
+
* `pendingMutationCallbacks` registered for its `requestId`. A live `mutation()` caller has a
|
|
1881
|
+
* callback registered only UNTIL its own wire response settles it — resolved/rejected callbacks
|
|
1882
|
+
* are deleted immediately (`resolvePending`/`rejectPending`), well before a `completed` layer's
|
|
1883
|
+
* gate gets a chance to drop it. So a live caller's entry looks exactly like a true mirror
|
|
1884
|
+
* (`isMirroredEntry` returns `true`) for the whole post-ack, still-gated window — this predicate
|
|
1885
|
+
* alone does NOT distinguish "own tab, post-ack" from "another tab's mirror"; it is the
|
|
1886
|
+
* `status.type === "completed"` skip in `mirrorFromStore`'s backstop pass that closes that gap
|
|
1887
|
+
* (a `completed` layer is owned by its gate, never force-settled by this predicate or the store).
|
|
1888
|
+
* Used both by the dispatch methods below and the doc comment on `mirrorFromStore`'s backstop pass
|
|
1889
|
+
* — a single documented helper, never inlined twice (brief hazard (a)). */
|
|
1890
|
+
isMirroredEntry(entry) {
|
|
1891
|
+
return entry.durable === true && !this.pendingMutationCallbacks.has(entry.requestId);
|
|
1892
|
+
}
|
|
1893
|
+
/** Handle an incoming (already-fan-out'd) broadcast payload — the typed half of `onmessage`. A
|
|
1894
|
+
* payload that doesn't match `OutboxBroadcastMessage` (the legacy bare `1`, or anything else) is
|
|
1895
|
+
* simply not recognized: the accessor nudge already fired in the caller, nothing else happens. */
|
|
1896
|
+
handleOutboxBroadcastMessage(data) {
|
|
1897
|
+
if (!this.outbox || !isOutboxBroadcastMessage(data)) return;
|
|
1898
|
+
if (data.kind === "enqueued") {
|
|
1899
|
+
void this.mirrorFromStore().catch((err) => this.handleOutboxWriteError("mirrorFromStore", void 0, void 0, void 0, err));
|
|
1900
|
+
} else {
|
|
1901
|
+
this.onCrossTabSettle(data);
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
/** Re-read the durable store and reconcile this tab's mirrored set against it — the `enqueued`
|
|
1905
|
+
* broadcast's handler, and the missed-message backstop (spec Part A "Rules"): a mirrored entry
|
|
1906
|
+
* absent from the fresh snapshot (settled/failed elsewhere, whose OWN targeted broadcast this tab
|
|
1907
|
+
* never received) drops via the same `dropAfterBaseline` one-pass rule the verdict-after-baseline
|
|
1908
|
+
* path uses. Serialized on `mirrorInFlight` — a second call arriving mid-read sets `mirrorRerun`
|
|
1909
|
+
* and is folded into one more pass after the current read finishes, rather than racing a second
|
|
1910
|
+
* `loadAll()` against it.
|
|
1911
|
+
*
|
|
1912
|
+
* Only STILL-ACTIVE entries (`unsent`/`inflight`/`parked`) are (re-)hydrated — a `failed` (or a
|
|
1913
|
+
* stray `completed`) entry is a terminal, accessor-only record (`pendingMutations()` already
|
|
1914
|
+
* surfaces it) and must never be resurrected as a fresh `unsent` optimistic layer. Without this
|
|
1915
|
+
* guard, a tab that itself once owned a now-terminally-failed entry would keep reviving its OWN
|
|
1916
|
+
* dead record on every subsequent `enqueued` broadcast (this store never dequeues a `failed`
|
|
1917
|
+
* entry — R9 "persists until dismissed/retried") — and, being `durable` with no live callback,
|
|
1918
|
+
* that revived entry would then match `isMirroredEntry`, making the tab react to an UNRELATED
|
|
1919
|
+
* later `settled`/`failed` broadcast that merely happens to name the same `(clientId, seq)`.
|
|
1920
|
+
*
|
|
1921
|
+
* Two review-fixed hazards in the reconcile loop below (both stem from the SAME root cause: the
|
|
1922
|
+
* store's presence/absence is not always the authority for a mirrored layer's fate):
|
|
1923
|
+
* - a `completed` mirrored layer (this tab already got a targeted `settled` broadcast and is
|
|
1924
|
+
* holding it gated until ITS OWN feed observes `commitTs`, per `onCrossTabSettle`) is SKIPPED
|
|
1925
|
+
* entirely here, never force-dropped merely because the store record is absent. The leader's
|
|
1926
|
+
* own `drainSettleApplied` dequeues the record right after posting `settled`, and THAT
|
|
1927
|
+
* dequeue's `{kind:"enqueued"}` follow-up broadcast is exactly what drives this backstop pass —
|
|
1928
|
+
* so a `completed` entry being store-absent is the ordinary, expected case, not a missed
|
|
1929
|
+
* message. Force-dropping it here would race ahead of this tab's own gate (a flicker the gate
|
|
1930
|
+
* exists to prevent) — CRITICAL. The same skip also protects THIS tab's own just-acked live
|
|
1931
|
+
* mutation: its `pendingMutationCallbacks` entry is deleted the moment its wire response
|
|
1932
|
+
* settles it (see `isMirroredEntry`'s doc — the callback does NOT survive the whole gated
|
|
1933
|
+
* window, only until the response arrives), so during that gated window it is
|
|
1934
|
+
* indistinguishable from a true mirror to `isMirroredEntry` and would otherwise be dropped by a
|
|
1935
|
+
* totally unrelated tab's `enqueued` broadcast.
|
|
1936
|
+
* - the "is this mirror still active" check now reads from ACTIVE-status (`unsent`/`inflight`/
|
|
1937
|
+
* `parked`) entries only, not "any entry present in the store" — a mirror whose backing record
|
|
1938
|
+
* flipped to `failed` (R9 never dequeues a failure) is present-in-store but no longer active;
|
|
1939
|
+
* treating presence alone as "still live" would leave a permanent phantom optimistic row behind
|
|
1940
|
+
* a missed `failed` broadcast. Such an entry is instead settled failed right here (same effect
|
|
1941
|
+
* as `onCrossTabSettle`'s `failed` branch — mark failed + fire R9), using the terminal verdict
|
|
1942
|
+
* already recorded on the store row itself. */
|
|
1943
|
+
async mirrorFromStore() {
|
|
1944
|
+
if (!this.outbox) return;
|
|
1945
|
+
if (this.mirrorInFlight) {
|
|
1946
|
+
this.mirrorRerun = true;
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
this.mirrorInFlight = true;
|
|
1950
|
+
try {
|
|
1951
|
+
do {
|
|
1952
|
+
this.mirrorRerun = false;
|
|
1953
|
+
const { entries } = await this.outbox.loadAll();
|
|
1954
|
+
const activeLive = /* @__PURE__ */ new Set();
|
|
1955
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
1956
|
+
for (const e of entries) {
|
|
1957
|
+
const key = `${e.clientId}:${e.seq}`;
|
|
1958
|
+
byKey.set(key, e);
|
|
1959
|
+
if (e.status === "unsent" || e.status === "inflight" || e.status === "parked") {
|
|
1960
|
+
activeLive.add(key);
|
|
1961
|
+
this.addHydratedEntry(e);
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
for (const entry of this.reconciler.entries()) {
|
|
1965
|
+
if (!this.isMirroredEntry(entry)) continue;
|
|
1966
|
+
if (entry.clientId === void 0 || entry.seq === void 0) continue;
|
|
1967
|
+
if (entry.status.type === "completed") continue;
|
|
1968
|
+
const key = `${entry.clientId}:${entry.seq}`;
|
|
1969
|
+
if (activeLive.has(key)) continue;
|
|
1970
|
+
const stored = byKey.get(key);
|
|
1971
|
+
if (stored?.status === "failed") {
|
|
1972
|
+
this.reconciler.onMutationFailure(entry.requestId);
|
|
1973
|
+
this.notifyMutationFailed(entry.clientId, entry.seq, entry.udfPath, stored.error ?? { message: `mutation "${entry.udfPath}" failed` });
|
|
1974
|
+
} else {
|
|
1975
|
+
this.dropAfterBaseline(entry.requestId);
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
} while (this.mirrorRerun);
|
|
1979
|
+
} finally {
|
|
1980
|
+
this.mirrorInFlight = false;
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
/** Handle a targeted `settled`/`failed` broadcast — the leader's flicker-free fast path (Part A's
|
|
1984
|
+
* normal route; `mirrorFromStore`'s backstop above is the fallback for a missed message). Ignored
|
|
1985
|
+
* entirely for an entry this tab doesn't know about, or one it initiated live itself (hazard (a)).
|
|
1986
|
+
* Never touches the durable store — the leader (whichever tab settled it) already wrote that;
|
|
1987
|
+
* this only updates THIS tab's in-memory reconciler layer + R9 observability. */
|
|
1988
|
+
onCrossTabSettle(msg) {
|
|
1989
|
+
const entry = this.findOutboxEntry(msg.clientId, msg.seq);
|
|
1990
|
+
if (!entry || !this.isMirroredEntry(entry)) return;
|
|
1991
|
+
if (msg.kind === "settled") {
|
|
1992
|
+
this.reconciler.onMutationSuccess(entry.requestId, msg.commitTs);
|
|
1993
|
+
} else {
|
|
1994
|
+
this.reconciler.onMutationFailure(entry.requestId);
|
|
1995
|
+
this.notifyMutationFailed(entry.clientId, entry.seq, entry.udfPath, { message: msg.message, code: msg.code });
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
drainBatchEntry(entry) {
|
|
1999
|
+
return { requestId: entry.requestId, udfPath: entry.udfPath, args: entry.args, clientId: entry.clientId, seq: entry.seq };
|
|
2000
|
+
}
|
|
2001
|
+
/** applied settlement for a drained unit — resolve the awaiting promise (if any) and dequeue the
|
|
2002
|
+
* durable record ALWAYS; then route the layer drop by `replayed` (T4 review fix — the ungated
|
|
2003
|
+
* fresh-apply drop):
|
|
2004
|
+
* - `replayed: true` — a resend whose commit predates this session's `Connect`, reusing the same
|
|
2005
|
+
* primitive `settleVerdict`'s `applied` case uses (T3's unconditional baseline-gated drop).
|
|
2006
|
+
* Drop-soundness (T3 watch item, scoped to replays ONLY): the drop is gated on baseline
|
|
2007
|
+
* adoption, not on the replay's carried commitTs — the entry's commit necessarily predates this
|
|
2008
|
+
* session's `Connect`, so it predates the baseline's read snapshot and the baseline already
|
|
2009
|
+
* renders the effect. Historical-ts-vs-current-base is therefore still covered; the drop is
|
|
2010
|
+
* flicker-free by the same one-pass rule as T3's handshake.
|
|
2011
|
+
* - a FRESH apply (`replayed` absent/false — this session's OWN first execution, a genuinely new
|
|
2012
|
+
* `ts`) — the argument above does NOT apply: nothing proves this commit predates the baseline,
|
|
2013
|
+
* so an unconditional drop here would remove a still-rendered layer before its authoritative row
|
|
2014
|
+
* ever appears (a flicker). Instead this routes through the normal same-session gate,
|
|
2015
|
+
* `onMutationSuccess` (the response `ts`) — the exact same shipped no-flicker discipline the
|
|
2016
|
+
* direct-send path uses at `MutationResponse` (see the `case "MutationResponse"` handler above):
|
|
2017
|
+
* hold the layer `completed` until this client's own reactive feed observes `ts`.
|
|
2018
|
+
*
|
|
2019
|
+
* T-crosstab: AFTER the local settle above, the leader (this tab, if it holds the drain lock)
|
|
2020
|
+
* also posts a targeted `settled` broadcast so a tab MIRRORING this same `(clientId, seq)` gets
|
|
2021
|
+
* the flicker-free fast path instead of waiting for its own next `enqueued`-triggered backstop
|
|
2022
|
+
* read. Posted only when the entry carries a durable `(clientId, seq)` — a plain non-outbox
|
|
2023
|
+
* mutation has nothing for another tab to have mirrored in the first place.
|
|
2024
|
+
*/
|
|
2025
|
+
drainSettleApplied(requestId, value, replayed, ts) {
|
|
2026
|
+
const entry = this.reconciler.getEntry(requestId);
|
|
2027
|
+
this.resolvePending(requestId, value);
|
|
2028
|
+
if (entry?.clientId !== void 0 && entry.seq !== void 0) this.outboxDequeue(entry.clientId, entry.seq);
|
|
2029
|
+
if (!entry) return;
|
|
2030
|
+
if (replayed) this.dropAfterBaseline(requestId);
|
|
2031
|
+
else this.reconciler.onMutationSuccess(requestId, ts);
|
|
2032
|
+
if (entry.clientId !== void 0 && entry.seq !== void 0) {
|
|
2033
|
+
this.outboxBroadcast?.postMessage({ kind: "settled", clientId: entry.clientId, seq: entry.seq, commitTs: ts ?? 0 });
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
/** Terminal settlement for a drained unit (a coded server verdict, or the identity gate) — reject
|
|
2037
|
+
* the awaiting promise (coded), MARK the durable record `"failed"` (R9: never dequeue a terminal
|
|
2038
|
+
* failure — it persists until dismissed/retried), drop the layer, and (T5) refire `onMutationFailed`
|
|
2039
|
+
* / the dev-loud default when nothing awaited this failure this session. T-crosstab: AFTER all of
|
|
2040
|
+
* the above, also posts a targeted `failed` broadcast (same durability gate as `drainSettleApplied`
|
|
2041
|
+
* above) — a mirroring tab's own `onCrossTabSettle` fires ITS OWN `onMutationFailed`/dev-loud
|
|
2042
|
+
* default; it never double-delivers THIS tab's own notification above. */
|
|
2043
|
+
drainSettleTerminal(requestId, code, message) {
|
|
2044
|
+
const entry = this.reconciler.getEntry(requestId);
|
|
2045
|
+
const hadAwaiter = this.pendingMutationCallbacks.has(requestId);
|
|
2046
|
+
this.rejectPending(requestId, this.mutationError(message, code));
|
|
2047
|
+
if (entry?.clientId !== void 0 && entry.seq !== void 0) this.outboxMarkFailed(entry.clientId, entry.seq, message, code);
|
|
2048
|
+
if (entry) this.reconciler.onMutationFailure(requestId);
|
|
2049
|
+
if (!hadAwaiter) this.notifyMutationFailed(entry?.clientId, entry?.seq, entry?.udfPath ?? "unknown", { message, code });
|
|
2050
|
+
if (entry?.clientId !== void 0 && entry.seq !== void 0) {
|
|
2051
|
+
this.outboxBroadcast?.postMessage({ kind: "failed", clientId: entry.clientId, seq: entry.seq, code, message });
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
/* ---------------------------------------------------------------------------------------------
|
|
2055
|
+
* T5 — R9 observability: `pendingMutations()`/`usePendingMutations()`, `pendingSummary()`,
|
|
2056
|
+
* `onMutationFailed` refire, the dev-mode loud default, and the outbox-change notification bus
|
|
2057
|
+
* every durable-mutating operation above funnels through.
|
|
2058
|
+
* ------------------------------------------------------------------------------------------- */
|
|
2059
|
+
/** A snapshot of the durable outbox — `usePendingMutations()`'s underlying read. `[]` without an
|
|
2060
|
+
* outbox configured (verdict §(d) R9). Each row's `retry()`/`dismiss()` close over the entry as
|
|
2061
|
+
* read HERE (no extra storage round-trip — `retry()` needs `args`/`seed`/`identityFingerprint`,
|
|
2062
|
+
* all captured already). */
|
|
2063
|
+
async pendingMutations() {
|
|
2064
|
+
if (!this.outbox) return [];
|
|
2065
|
+
const { entries } = await this.outbox.loadAll();
|
|
2066
|
+
return entries.map((e) => this.toPendingMutationEntry(e));
|
|
2067
|
+
}
|
|
2068
|
+
/** T5 (R9, hazard 2's client half): count + oldest-age advisory over the durable queue — cheap
|
|
2069
|
+
* enough to poll for a "your offline changes may be lost soon" banner ahead of a storage cliff
|
|
2070
|
+
* (Safari's 7-day eviction). `{count: 0, oldestEnqueuedAt: undefined, oldestAgeMs: undefined}`
|
|
2071
|
+
* without an outbox configured, or with an empty one. */
|
|
2072
|
+
async pendingSummary() {
|
|
2073
|
+
if (!this.outbox) return { count: 0, oldestEnqueuedAt: void 0, oldestAgeMs: void 0 };
|
|
2074
|
+
const { entries } = await this.outbox.loadAll();
|
|
2075
|
+
if (entries.length === 0) return { count: 0, oldestEnqueuedAt: void 0, oldestAgeMs: void 0 };
|
|
2076
|
+
let oldest = entries[0].enqueuedAt;
|
|
2077
|
+
for (const e of entries) if (e.enqueuedAt < oldest) oldest = e.enqueuedAt;
|
|
2078
|
+
return { count: entries.length, oldestEnqueuedAt: oldest, oldestAgeMs: Date.now() - oldest };
|
|
2079
|
+
}
|
|
2080
|
+
toPendingMutationEntry(e) {
|
|
2081
|
+
return {
|
|
2082
|
+
clientId: e.clientId,
|
|
2083
|
+
seq: e.seq,
|
|
2084
|
+
udfPath: e.udfPath,
|
|
2085
|
+
status: e.status,
|
|
2086
|
+
enqueuedAt: e.enqueuedAt,
|
|
2087
|
+
error: e.error,
|
|
2088
|
+
retry: () => this.retryOutboxEntry(e),
|
|
2089
|
+
dismiss: () => this.dismissOutboxEntry(e)
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
/** `entry.retry()` (R9): a FAILED entry only — everything else is a harmless no-op (verdict §(b):
|
|
2093
|
+
* "never reuse a seq for a new attempt"). Dequeues the OLD (failed-verdict) durable record and
|
|
2094
|
+
* builds a brand-new `PendingMutation` — fresh requestId/seq/order, the CURRENT session's identity
|
|
2095
|
+
* fingerprint (a fair shot even if identity rotated since the original failure), reconstructed
|
|
2096
|
+
* exactly like a hydrated entry (same udfPath/args/seed; the registry is consulted — there is no
|
|
2097
|
+
* live call-site closure for a retry either). No live promise is registered: like a hydrated
|
|
2098
|
+
* entry, its eventual outcome surfaces via `usePendingMutations()`/`onMutationFailed`, never a
|
|
2099
|
+
* returned `Promise<Value>` (the durable record outlives any promise). */
|
|
2100
|
+
async retryOutboxEntry(e) {
|
|
2101
|
+
if (!this.outbox || e.status !== "failed" || this.outboxClientId === void 0) return;
|
|
2102
|
+
const requestId = String(this.nextRequestId++);
|
|
2103
|
+
const entry = {
|
|
2104
|
+
requestId,
|
|
2105
|
+
udfPath: e.udfPath,
|
|
2106
|
+
args: e.args,
|
|
2107
|
+
seed: e.seed,
|
|
2108
|
+
touched: /* @__PURE__ */ new Set(),
|
|
2109
|
+
status: { type: "unsent" },
|
|
2110
|
+
clientId: this.outboxClientId,
|
|
2111
|
+
seq: this.outboxNextSeq++,
|
|
2112
|
+
order: this.nextOutboxOrder(),
|
|
2113
|
+
identityFingerprint: this.outboxFingerprint,
|
|
2114
|
+
enqueuedAt: Date.now(),
|
|
2115
|
+
durable: false,
|
|
2116
|
+
update: this.lookupHydratedUpdate(e.udfPath)
|
|
2117
|
+
};
|
|
2118
|
+
this.reconciler.addHydrated(entry);
|
|
2119
|
+
this.outboxAppend(entry);
|
|
2120
|
+
this.outboxDequeue(e.clientId, e.seq);
|
|
2121
|
+
this.outboxDrain?.nudge();
|
|
2122
|
+
}
|
|
2123
|
+
/** `entry.dismiss()` (R9): a FAILED entry only — permanently forget it without retrying. */
|
|
2124
|
+
async dismissOutboxEntry(e) {
|
|
2125
|
+
if (!this.outbox || e.status !== "failed") return;
|
|
2126
|
+
this.outboxDequeue(e.clientId, e.seq);
|
|
2127
|
+
}
|
|
2128
|
+
/** T5 (R9): subscribe to "the durable outbox changed" — `usePendingMutations()`'s re-read trigger.
|
|
2129
|
+
* Fires on every local outbox-mutating op AND on an incoming cross-tab `outboxBroadcast` message. */
|
|
2130
|
+
onOutboxChange(listener) {
|
|
2131
|
+
this.outboxChangeListeners.add(listener);
|
|
2132
|
+
return () => this.outboxChangeListeners.delete(listener);
|
|
2133
|
+
}
|
|
2134
|
+
notifyOutboxChange() {
|
|
2135
|
+
for (const l of this.outboxChangeListeners) l();
|
|
2136
|
+
this.outboxBroadcast?.postMessage({ kind: "enqueued" });
|
|
2137
|
+
}
|
|
2138
|
+
/** Every durable-mutating outbox call site funnels through these four wrappers (instead of a bare
|
|
2139
|
+
* `this.outbox?.xxx(...)`) so `notifyOutboxChange()` — and a rejection's route through
|
|
2140
|
+
* `handleOutboxWriteError` — is never missed at a new call site. */
|
|
2141
|
+
outboxAppend(entry) {
|
|
2142
|
+
if (!this.outbox) return;
|
|
2143
|
+
void this.outbox.append(this.toOutboxEntry(entry)).then(() => {
|
|
2144
|
+
entry.durable = true;
|
|
2145
|
+
this.outboxDrain?.nudge();
|
|
2146
|
+
this.notifyOutboxChange();
|
|
2147
|
+
}).catch((err) => this.handleOutboxWriteError("append", entry.clientId, entry.seq, entry.udfPath, err));
|
|
2148
|
+
}
|
|
2149
|
+
outboxDequeue(clientId, seq) {
|
|
2150
|
+
if (!this.outbox) return;
|
|
2151
|
+
void this.outbox.dequeue(clientId, seq).then(() => this.notifyOutboxChange()).catch((err) => this.handleOutboxWriteError("dequeue", clientId, seq, void 0, err));
|
|
2152
|
+
}
|
|
2153
|
+
outboxUpdateStatus(clientId, seq, status) {
|
|
2154
|
+
if (!this.outbox) return;
|
|
2155
|
+
void this.outbox.updateStatus(clientId, seq, status).then(() => this.notifyOutboxChange()).catch((err) => this.handleOutboxWriteError("updateStatus", clientId, seq, void 0, err));
|
|
2156
|
+
}
|
|
2157
|
+
/** Record a terminal failure DURABLY (`status: "failed"` + the error) instead of dequeuing — R9:
|
|
2158
|
+
* "failed entries persist until dismissed/retried". */
|
|
2159
|
+
outboxMarkFailed(clientId, seq, message, code) {
|
|
2160
|
+
if (!this.outbox) return;
|
|
2161
|
+
void this.outbox.updateStatus(clientId, seq, "failed", { message, code }).then(() => this.notifyOutboxChange()).catch((err) => this.handleOutboxWriteError("updateStatus", clientId, seq, void 0, err));
|
|
2162
|
+
}
|
|
2163
|
+
/** Routes a rejected fire-and-forget durable-outbox write (append/updateStatus/dequeue/setMeta —
|
|
2164
|
+
* never awaited by its caller, per the write-behind contract) to observability instead of letting
|
|
2165
|
+
* it become an unhandled promise rejection, which several Node/Electron hosts treat as fatal by
|
|
2166
|
+
* default (a `fsOutbox` that has fail-stopped after a disk error rejects EVERY subsequent op —
|
|
2167
|
+
* see `outbox-fs.ts`'s `OutboxClosedError`). When the write carries a `(clientId, seq)` — every
|
|
2168
|
+
* case except a meta-only write — it routes through the SAME R9 channel as any other terminal
|
|
2169
|
+
* mutation failure (`onMutationFailed`, or `notifyMutationFailed`'s own dev-mode loud
|
|
2170
|
+
* `console.error` default). With no such record to attach the failure to (a meta write), a
|
|
2171
|
+
* dev-loud `console.error` is the floor — NEVER swallowed silently either way. */
|
|
2172
|
+
handleOutboxWriteError(op, clientId, seq, udfPath, err) {
|
|
2173
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2174
|
+
const code = typeof err === "object" && err !== null && "code" in err ? String(err.code) : void 0;
|
|
2175
|
+
if (clientId !== void 0 && seq !== void 0) {
|
|
2176
|
+
this.notifyMutationFailed(clientId, seq, udfPath ?? "unknown", { message: `durable outbox ${op} failed: ${message}`, code });
|
|
2177
|
+
} else if (isDevMode()) {
|
|
2178
|
+
console.error(`[helipod] durable outbox ${op} failed (clientId=${clientId ?? "unknown"}):`, err);
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
/** T5 (R9): fire `onMutationFailed` for a terminal durable failure with NO live promise awaiter
|
|
2182
|
+
* this session (`hadAwaiter` already checked by every call site) — or, absent a registered
|
|
2183
|
+
* handler, the dev-mode loud `console.error` default (spec-review: "the five-line courtesy" no
|
|
2184
|
+
* position shipped). A no-op for a non-outbox-tracked entry (`clientId`/`seq` undefined) — R9 is
|
|
2185
|
+
* entirely a durable-outbox concern. */
|
|
2186
|
+
notifyMutationFailed(clientId, seq, udfPath, error) {
|
|
2187
|
+
if (clientId === void 0 || seq === void 0) return;
|
|
2188
|
+
if (this.onMutationFailedCallback) {
|
|
2189
|
+
this.onMutationFailedCallback({ clientId, seq, udfPath, error });
|
|
2190
|
+
} else if (isDevMode()) {
|
|
2191
|
+
console.error(
|
|
2192
|
+
`[helipod] outbox: mutation "${udfPath}" (clientId=${clientId}, seq=${seq}) failed terminally${error.code ? ` (${error.code})` : ""} with no onMutationFailed handler registered: ${error.message}`
|
|
2193
|
+
);
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
/** R9 "resume" refire (constructor-only, verdict §(d) Observability: "`onMutationFailed` refires
|
|
2197
|
+
* from durable records on resume"): a fresh `HelipodClient` instance has made zero `mutation()`
|
|
2198
|
+
* calls yet, so EVERY already-`"failed"` durable record found here is trivially "no live awaiter" —
|
|
2199
|
+
* Lunora's `hadAwaiter` check is unconditionally false at this point, no gating needed. */
|
|
2200
|
+
async refireDurableFailures() {
|
|
2201
|
+
if (!this.outbox) return;
|
|
2202
|
+
const { entries } = await this.outbox.loadAll();
|
|
2203
|
+
for (const e of entries) {
|
|
2204
|
+
if (e.status === "failed") {
|
|
2205
|
+
this.notifyMutationFailed(e.clientId, e.seq, e.udfPath, e.error ?? { message: `mutation "${e.udfPath}" failed` });
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
};
|
|
2210
|
+
|
|
2211
|
+
export {
|
|
2212
|
+
getFunctionPath,
|
|
2213
|
+
anyApi,
|
|
2214
|
+
MutationUndeliveredError,
|
|
2215
|
+
createOptimisticLocalStore,
|
|
2216
|
+
computeDrainBackoff,
|
|
2217
|
+
DEFAULT_DRAIN_CHUNK_SIZE,
|
|
2218
|
+
DEFAULT_DRAIN_INTERVAL_MS,
|
|
2219
|
+
OFFLINE_IDENTITY_CHANGED,
|
|
2220
|
+
AUTH_REFRESH_UDF_PATH,
|
|
2221
|
+
NON_REPLAYABLE_MUTATION_DROPPED,
|
|
2222
|
+
dropIfNonReplayable,
|
|
2223
|
+
OutboxDrain,
|
|
2224
|
+
outboxHeldFromLog,
|
|
2225
|
+
outboxHeldFromStore,
|
|
2226
|
+
outboxAckedThrough,
|
|
2227
|
+
buildConnectMessage,
|
|
2228
|
+
sha256Hex,
|
|
2229
|
+
sessionFingerprintKey,
|
|
2230
|
+
HelipodClient
|
|
2231
|
+
};
|
|
2232
|
+
//# sourceMappingURL=chunk-I7ZJFW4E.js.map
|