@lunora/client 1.0.0-alpha.1 → 1.0.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/__assets__/package-og.svg +1 -1
- package/dist/auth/index.d.mts +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/index.d.mts +109 -5
- package/dist/index.d.ts +109 -5
- package/dist/index.mjs +8 -7
- package/dist/packem_shared/{LunoraClient-B00f7VUM.mjs → LunoraClient-CgZ6FhKP.mjs} +741 -185
- package/dist/packem_shared/OfflineQueue-BI0FNNvc.mjs +1 -0
- package/dist/packem_shared/SubscriptionRegistry-Dn-7k7eo.mjs +1 -0
- package/dist/packem_shared/createLocalStore-IOur0jHF.mjs +1 -0
- package/dist/packem_shared/createMutatorRunner-BETvCd0p.mjs +31 -0
- package/dist/packem_shared/{createServerClient-DyFIHnWZ.mjs → createServerClient-BxkNcRlR.mjs} +1 -1
- package/dist/packem_shared/local-store-BNgN3Dw3.mjs +111 -0
- package/dist/packem_shared/{lunora-client.d-DGvyuJ_p.d.mts → lunora-client.d-B5vWSgvD.d.mts} +637 -38
- package/dist/packem_shared/{lunora-client.d-DGvyuJ_p.d.ts → lunora-client.d-B5vWSgvD.d.ts} +637 -38
- package/dist/packem_shared/{OfflineQueue-D5p_QgF_.mjs → offline-queue-7Wc4onA0.mjs} +42 -5
- package/dist/packem_shared/{preload.d-dSaRMuhL.d.mts → preload.d-3XJD-2hM.d.mts} +1 -1
- package/dist/packem_shared/{preload.d-BoDmFqSG.d.ts → preload.d-CKZR675M.d.ts} +1 -1
- package/dist/packem_shared/subscription-C1Jy7HiF.mjs +55 -0
- package/dist/query/index.d.mts +2 -2
- package/dist/query/index.d.ts +2 -2
- package/dist/ssr/index.d.mts +3 -3
- package/dist/ssr/index.d.ts +3 -3
- package/dist/ssr/index.mjs +2 -2
- package/package.json +2 -2
- package/dist/packem_shared/SubscriptionRegistry-B-Qx_Gux.mjs +0 -26
- package/dist/packem_shared/createLocalStore-DSUfoLqY.mjs +0 -36
- /package/dist/packem_shared/{createStream-BDkqO5PW.mjs → DEFAULT_MAX_BUFFER-BDkqO5PW.mjs} +0 -0
- /package/dist/packem_shared/{createIndexedDbPersistence-CW82inU5.mjs → createInMemoryPersistence-CW82inU5.mjs} +0 -0
- /package/dist/packem_shared/{createIndexedDbQueryCache-B1PQ9Twl.mjs → createInMemoryQueryCache-B1PQ9Twl.mjs} +0 -0
- /package/dist/packem_shared/{serializePreloaded-C0eJTY_W.mjs → deserializePreloaded-C0eJTY_W.mjs} +0 -0
|
@@ -1,17 +1,57 @@
|
|
|
1
|
+
import { S as SubscriptionRegistry, s as stableStringify } from './subscription-C1Jy7HiF.mjs';
|
|
1
2
|
import createInMemoryBookmarkStorage from './createInMemoryBookmarkStorage-BoN7a7TH.mjs';
|
|
2
3
|
import { isMutationDelta, applyDelta } from './applyDelta-4jFGTPA3.mjs';
|
|
3
|
-
import { createLocalStore } from './
|
|
4
|
-
import { OfflineQueue, nextId, reportPersistenceError } from './
|
|
5
|
-
import { queryCacheKey } from './
|
|
4
|
+
import { a as applyOptimisticLayer, d as dropConfirmedLayers, n as notifySubscription, f as foldOptimistic, c as createLocalStore } from './local-store-BNgN3Dw3.mjs';
|
|
5
|
+
import { O as OfflineQueue, n as nextId, i as isStaleVersion, r as reportPersistenceError } from './offline-queue-7Wc4onA0.mjs';
|
|
6
|
+
import { queryCacheKey } from './createInMemoryQueryCache-B1PQ9Twl.mjs';
|
|
6
7
|
import { createReconnect } from './createReconnect-Di_-oHH7.mjs';
|
|
7
|
-
import { createStream } from './
|
|
8
|
-
|
|
8
|
+
import { createStream } from './DEFAULT_MAX_BUFFER-BDkqO5PW.mjs';
|
|
9
|
+
|
|
10
|
+
class Listeners {
|
|
11
|
+
listeners = /* @__PURE__ */ new Set();
|
|
12
|
+
add(listener) {
|
|
13
|
+
this.listeners.add(listener);
|
|
14
|
+
return () => {
|
|
15
|
+
this.listeners.delete(listener);
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
// The conditional rest tuple makes `emit()` argument-free for a
|
|
19
|
+
// `Listeners<void>` and one-argument for every other payload.
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- `[T] extends [void]` is the discriminant for the payload-free overload, not a value-position void
|
|
21
|
+
emit(...args) {
|
|
22
|
+
const [value] = args;
|
|
23
|
+
for (const listener of this.listeners) {
|
|
24
|
+
try {
|
|
25
|
+
listener(value);
|
|
26
|
+
} catch {
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
clear() {
|
|
31
|
+
this.listeners.clear();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
9
34
|
|
|
10
35
|
const RPC_PATH = "/_lunora/rpc";
|
|
11
36
|
const WS_PATH = "/_lunora/ws";
|
|
12
37
|
const bucketQuery = (bucket) => bucket === void 0 || bucket === "" ? "" : `&bucket=${encodeURIComponent(bucket)}`;
|
|
38
|
+
const rollbackOptimistic = (optimisticRollbacks) => {
|
|
39
|
+
for (let index = optimisticRollbacks.length - 1; index >= 0; index -= 1) {
|
|
40
|
+
optimisticRollbacks[index]?.();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const applyRowOpsToView = (rows, ops) => {
|
|
44
|
+
for (const op of ops) {
|
|
45
|
+
if (op.op === "delete") {
|
|
46
|
+
rows.delete(op.key);
|
|
47
|
+
} else if (op.value !== void 0) {
|
|
48
|
+
rows.set(op.key, op.value);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
13
52
|
const WS_KEEPALIVE_PING = "lunora-ping";
|
|
14
53
|
const DEFAULT_HEARTBEAT_INTERVAL_MS = 3e4;
|
|
54
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
|
|
15
55
|
const QUERY_CACHE_DEBOUNCE_MS = 250;
|
|
16
56
|
const MAX_PENDING_STREAMS = 64;
|
|
17
57
|
const SHARD_TRAFFIC_PATH = "/_lunora/admin/shard-traffic";
|
|
@@ -63,22 +103,6 @@ const AUTH_REMOVE_MEMBER_PATH = "/_lunora/admin/auth/organizations/members/remov
|
|
|
63
103
|
const AUTH_CANCEL_INVITATION_PATH = "/_lunora/admin/auth/organizations/invitations/cancel";
|
|
64
104
|
const DEFAULT_AUTH_BASE_PATH = "/api/auth";
|
|
65
105
|
const GET_SESSION_PATH = "/get-session";
|
|
66
|
-
const compareEntryKeys = ([a], [b]) => {
|
|
67
|
-
if (a < b) {
|
|
68
|
-
return -1;
|
|
69
|
-
}
|
|
70
|
-
return a > b ? 1 : 0;
|
|
71
|
-
};
|
|
72
|
-
const stableStringify = (value) => {
|
|
73
|
-
if (value === null || typeof value !== "object") {
|
|
74
|
-
return JSON.stringify(value);
|
|
75
|
-
}
|
|
76
|
-
if (Array.isArray(value)) {
|
|
77
|
-
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
|
|
78
|
-
}
|
|
79
|
-
const entries = Object.entries(value).toSorted(compareEntryKeys);
|
|
80
|
-
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
|
|
81
|
-
};
|
|
82
106
|
const deriveWsUrl = (url) => {
|
|
83
107
|
if (url.startsWith("https://")) {
|
|
84
108
|
return `wss://${url.slice("https://".length)}`;
|
|
@@ -103,41 +127,6 @@ const withQuery = (path, params) => {
|
|
|
103
127
|
return query === "" ? path : `${path}?${query}`;
|
|
104
128
|
};
|
|
105
129
|
const connectionKey = (shardKey) => shardKey ?? "";
|
|
106
|
-
const writeOptimisticToState = (state, next) => {
|
|
107
|
-
const previous = state.lastValue;
|
|
108
|
-
const versionAtApply = state.serverVersion;
|
|
109
|
-
state.lastValue = next;
|
|
110
|
-
for (const callback of state.callbacks) {
|
|
111
|
-
try {
|
|
112
|
-
callback(next);
|
|
113
|
-
} catch {
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return () => {
|
|
117
|
-
if (state.serverVersion > versionAtApply) {
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
if (state.lastValue !== next) {
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
state.lastValue = previous;
|
|
124
|
-
for (const callback of state.callbacks) {
|
|
125
|
-
try {
|
|
126
|
-
callback(previous);
|
|
127
|
-
} catch {
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
};
|
|
131
|
-
};
|
|
132
|
-
const applyOptimisticToState = (state, optimistic) => {
|
|
133
|
-
let next;
|
|
134
|
-
try {
|
|
135
|
-
next = optimistic(state.lastValue);
|
|
136
|
-
} catch {
|
|
137
|
-
return void 0;
|
|
138
|
-
}
|
|
139
|
-
return writeOptimisticToState(state, next);
|
|
140
|
-
};
|
|
141
130
|
const buildStreamError = (message) => {
|
|
142
131
|
const errorEnvelope = message.error;
|
|
143
132
|
const code = typeof errorEnvelope?.code === "string" ? errorEnvelope.code : void 0;
|
|
@@ -152,6 +141,14 @@ const buildSubscriptionError = (message) => {
|
|
|
152
141
|
const messageText = (typeof message.message === "string" ? message.message : void 0) ?? nestedMessage ?? "subscription error";
|
|
153
142
|
return { message: messageText, ...code === void 0 ? {} : { code } };
|
|
154
143
|
};
|
|
144
|
+
const fanSubscriptionError = (callbacks, error) => {
|
|
145
|
+
for (const errorCallback of callbacks) {
|
|
146
|
+
try {
|
|
147
|
+
errorCallback(error);
|
|
148
|
+
} catch {
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
155
152
|
const sharedDecoder = new TextDecoder();
|
|
156
153
|
const decodeServerFrame = (raw) => {
|
|
157
154
|
if (typeof raw === "string") {
|
|
@@ -174,6 +171,8 @@ const sendOn = (conn, message) => {
|
|
|
174
171
|
}
|
|
175
172
|
};
|
|
176
173
|
class LunoraClient {
|
|
174
|
+
/** Hard cap on concurrently-buffered pokes — a backstop that reclaims buffers abandoned by a mid-poke disconnect (no `pokeEnd`). Far above any real concurrent-in-flight count. */
|
|
175
|
+
static MAX_POKE_BUFFERS = 256;
|
|
177
176
|
url;
|
|
178
177
|
wsUrl;
|
|
179
178
|
wsToken;
|
|
@@ -183,11 +182,36 @@ class LunoraClient {
|
|
|
183
182
|
WebSocketImpl;
|
|
184
183
|
bookmark;
|
|
185
184
|
reconnectOptions;
|
|
185
|
+
/** WS connect timeout (ms); `0` disables it. See {@link LunoraClientOptions.connectTimeoutMs}. */
|
|
186
|
+
connectTimeoutMs;
|
|
186
187
|
/** Keepalive cadence (ms); `0` disables the heartbeat. See {@link LunoraClientOptions.heartbeatIntervalMs}. */
|
|
187
188
|
heartbeatIntervalMs;
|
|
188
189
|
offlineQueue;
|
|
190
|
+
/**
|
|
191
|
+
* Durable outbox seam (the `@lunora/db` `createExecutorOutboxSink`). When
|
|
192
|
+
* set, offline writes are delegated here and the built-in {@link OfflineQueue}
|
|
193
|
+
* is bypassed, so a db app has exactly one durable write path.
|
|
194
|
+
*/
|
|
195
|
+
outbox;
|
|
196
|
+
/** Stable per-client id stamped onto every `OutboxMutation` (custom-mutator watermark). */
|
|
197
|
+
clientId;
|
|
198
|
+
/**
|
|
199
|
+
* Highest custom-mutator watermark the server has echoed for this client,
|
|
200
|
+
* keyed by shard bucket (`shardKey ?? ""`) since the DO tracks one
|
|
201
|
+
* `__client_watermark` per shard. `callMutator` bumps it from every
|
|
202
|
+
* ack; the `@lunora/db` mutator runtime seeds its `clientSeq` generator from
|
|
203
|
+
* it so a reload (which resets the in-memory counter) never reissues a stale
|
|
204
|
+
* sequence the server would silently swallow as a replay.
|
|
205
|
+
*/
|
|
206
|
+
clientWatermarks = /* @__PURE__ */ new Map();
|
|
207
|
+
/** Monotonic per-client mutation counter backing the server `__client_watermark`. */
|
|
208
|
+
outboxMutationCounter = 0;
|
|
189
209
|
onPersistenceError;
|
|
190
210
|
persistence;
|
|
211
|
+
/** App/schema version stamped on persisted writes + cached reads; mismatches are purged. */
|
|
212
|
+
persistenceVersion;
|
|
213
|
+
/** Releases the multi-tab outbox-leader Web Lock on close (see `hydrateAsOutboxLeader`). */
|
|
214
|
+
outboxLeaderRelease;
|
|
191
215
|
/** Durable read cache (Pillar 2); `undefined` when `queryCache` is omitted or `false`. */
|
|
192
216
|
queryCache;
|
|
193
217
|
/**
|
|
@@ -233,6 +257,14 @@ class LunoraClient {
|
|
|
233
257
|
// setAuthToken / onAuthTokenChange — part of the exported API contract.
|
|
234
258
|
// eslint-disable-next-line unicorn/no-null -- public auth-token contract sentinel
|
|
235
259
|
authToken = null;
|
|
260
|
+
/**
|
|
261
|
+
* Optional STABLE identity subject (a user id), the basis of the offline-queue
|
|
262
|
+
* identity stamp when supplied. Keeps a same-user token *refresh* from looking
|
|
263
|
+
* like an identity change (which would discard queued writes). `undefined` =
|
|
264
|
+
* not supplied, so identity falls back to a hash of the raw token. See
|
|
265
|
+
* `setAuthToken` / `identityFingerprint`.
|
|
266
|
+
*/
|
|
267
|
+
authSubject = void 0;
|
|
236
268
|
/**
|
|
237
269
|
* Identity stamp recorded against each queued offline mutation, keyed by
|
|
238
270
|
* the queue-assigned mutation id. Captured at enqueue from the auth token
|
|
@@ -243,11 +275,15 @@ class LunoraClient {
|
|
|
243
275
|
queuedIdentities = /* @__PURE__ */ new Map();
|
|
244
276
|
closed = false;
|
|
245
277
|
/** Subscribers to auth-token changes (see `onAuthTokenChange`). */
|
|
246
|
-
authTokenListeners =
|
|
278
|
+
authTokenListeners = new Listeners();
|
|
247
279
|
/** Subscribers to aggregate connection-status changes (see `onConnectionStatus`). */
|
|
248
|
-
statusListeners =
|
|
280
|
+
statusListeners = new Listeners();
|
|
249
281
|
/** Subscribers notified when the server drops a socket for an expired token (see `onTokenExpired`). */
|
|
250
|
-
tokenExpiredListeners =
|
|
282
|
+
tokenExpiredListeners = new Listeners();
|
|
283
|
+
/** Subscribers to offline-queued mutation verdicts (see `onMutationSettled`). */
|
|
284
|
+
mutationSettledListeners = new Listeners();
|
|
285
|
+
/** Subscribers to the offline-queue pending-count (see `onPendingChange`). */
|
|
286
|
+
pendingChangeListeners = new Listeners();
|
|
251
287
|
/**
|
|
252
288
|
* Whisper-topic handlers, keyed by `connectionKey(shardKey)` → topic → set
|
|
253
289
|
* of callbacks. Membership doubles as the resubscribe set replayed on every
|
|
@@ -265,6 +301,11 @@ class LunoraClient {
|
|
|
265
301
|
* calls `.cancel()` or the iterator is garbage-collected.
|
|
266
302
|
*/
|
|
267
303
|
streams = /* @__PURE__ */ new Map();
|
|
304
|
+
/** Live shape subscriptions (partial replication), keyed by their wire id. */
|
|
305
|
+
shapeSubscriptions = /* @__PURE__ */ new Map();
|
|
306
|
+
/** In-flight pokes being assembled between `pokeStart` and `pokeEnd`, keyed by `pokeId`. */
|
|
307
|
+
pokeBuffers = /* @__PURE__ */ new Map();
|
|
308
|
+
nextShapeId = 0;
|
|
268
309
|
constructor(options) {
|
|
269
310
|
this.url = options.url;
|
|
270
311
|
this.wsUrl = options.wsUrl ?? joinUrl(deriveWsUrl(options.url), WS_PATH);
|
|
@@ -276,14 +317,27 @@ class LunoraClient {
|
|
|
276
317
|
this.bookmark = options.bookmarkStorage ?? createInMemoryBookmarkStorage();
|
|
277
318
|
this.reconnectOptions = options.reconnect;
|
|
278
319
|
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
320
|
+
this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
279
321
|
this.defaultConnectionContext = options.connectionContext;
|
|
280
322
|
this.persistence = options.persistence;
|
|
323
|
+
this.persistenceVersion = options.persistenceVersion;
|
|
281
324
|
this.queryCache = options.queryCache === false ? void 0 : options.queryCache;
|
|
282
325
|
this.onPersistenceError = options.offlineQueue?.onPersistenceError;
|
|
283
|
-
this.offlineQueue = new OfflineQueue(options.offlineQueue,
|
|
326
|
+
this.offlineQueue = new OfflineQueue(options.offlineQueue, {
|
|
327
|
+
onEvict: (entry, error) => {
|
|
328
|
+
this.emitItemSettled(entry, "rejected", error);
|
|
329
|
+
},
|
|
330
|
+
onSizeChange: (size) => {
|
|
331
|
+
this.pendingChangeListeners.emit(size);
|
|
332
|
+
},
|
|
333
|
+
persistence: options.persistence,
|
|
334
|
+
version: options.persistenceVersion
|
|
335
|
+
});
|
|
336
|
+
this.outbox = options.outbox;
|
|
337
|
+
this.clientId = options.clientId ?? `client-${nextId()}`;
|
|
284
338
|
if (this.persistence) {
|
|
285
339
|
queueMicrotask(() => {
|
|
286
|
-
this.
|
|
340
|
+
this.hydrateAsOutboxLeader();
|
|
287
341
|
});
|
|
288
342
|
}
|
|
289
343
|
if (this.queryCache) {
|
|
@@ -298,37 +352,113 @@ class LunoraClient {
|
|
|
298
352
|
* {@link onAuthTokenChange} listeners so React hooks like `useAuth` stay in
|
|
299
353
|
* sync across all mounted instances.
|
|
300
354
|
*
|
|
355
|
+
* Pass a STABLE `subject` (the user id) to key the offline-queue identity on
|
|
356
|
+
* it instead of the token bytes, so a token *refresh* (same user, new JWT)
|
|
357
|
+
* doesn't read as an identity change and discard queued writes. The subject is
|
|
358
|
+
* **sticky**: a later call that omits it (or passes `undefined`) keeps the
|
|
359
|
+
* established subject — so `setAuthToken(refreshedToken)` after a prior
|
|
360
|
+
* `setAuthToken(token, user.id)` retains the identity. Pass `null` to clear it
|
|
361
|
+
* (an explicit sign-out). Establishing the subject for the first time on an
|
|
362
|
+
* UNCHANGED token (e.g. the user id resolves a tick after the token was set)
|
|
363
|
+
* re-stamps any in-flight queued writes rather than dropping them — same
|
|
364
|
+
* credential, just a more stable label. A real user switch (the token AND
|
|
365
|
+
* subject both change) still drops the previous user's writes.
|
|
366
|
+
*
|
|
301
367
|
* Does NOT update the WebSocket auth — the WS token is fixed at upgrade
|
|
302
368
|
* time and lives in the URL. To refresh live WS auth, call
|
|
303
369
|
* {@link setWsToken} explicitly, which closes existing shard sockets to
|
|
304
370
|
* force a reconnect with the new credential.
|
|
305
371
|
*/
|
|
306
|
-
setAuthToken(token) {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
372
|
+
setAuthToken(token, subject) {
|
|
373
|
+
const tokenChanged = this.authToken !== token;
|
|
374
|
+
const previousIdentity = this.identityFingerprint();
|
|
310
375
|
this.authToken = token;
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
376
|
+
if (subject !== void 0) {
|
|
377
|
+
this.authSubject = subject;
|
|
378
|
+
}
|
|
379
|
+
const newIdentity = this.identityFingerprint();
|
|
380
|
+
if (newIdentity !== previousIdentity) {
|
|
381
|
+
if (tokenChanged) {
|
|
382
|
+
this.rejectQueuedForIdentityChange();
|
|
383
|
+
} else {
|
|
384
|
+
this.restampQueuedIdentity(previousIdentity, newIdentity);
|
|
316
385
|
}
|
|
317
386
|
}
|
|
387
|
+
if (tokenChanged) {
|
|
388
|
+
this.authTokenListeners.emit(token);
|
|
389
|
+
}
|
|
318
390
|
}
|
|
319
391
|
getAuthToken() {
|
|
320
392
|
return this.authToken;
|
|
321
393
|
}
|
|
394
|
+
/**
|
|
395
|
+
* The current identity fingerprint (the same stamp queued offline writes
|
|
396
|
+
* carry). Exposed so a durable {@link OutboxSink}'s replay handler — which
|
|
397
|
+
* owns its own at-least-once replay outside the built-in `OfflineQueue` —
|
|
398
|
+
* can drop a persisted write whose captured `identity` no longer matches the
|
|
399
|
+
* signed-in user, the guard the queue path applies in `flushOfflineQueue`.
|
|
400
|
+
*/
|
|
401
|
+
currentIdentity() {
|
|
402
|
+
return this.identityFingerprint();
|
|
403
|
+
}
|
|
404
|
+
/** This client's stable identifier — the watermark key the server's custom-mutator protocol advances per `clientSeq`. */
|
|
405
|
+
clientIdentifier() {
|
|
406
|
+
return this.clientId;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* The highest custom-mutator watermark the server has echoed for this client
|
|
410
|
+
* on the given shard (0 if none yet). The `@lunora/db` mutator runtime seeds
|
|
411
|
+
* its `clientSeq` generator from this so a reload never reissues a sequence
|
|
412
|
+
* the server has already applied (which it would swallow as a replay, silently
|
|
413
|
+
* dropping the write).
|
|
414
|
+
*/
|
|
415
|
+
confirmedMutationWatermark(shardKey) {
|
|
416
|
+
return this.clientWatermarks.get(shardKey ?? "") ?? 0;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Push a custom mutator to its authoritative server impl over the watermark
|
|
420
|
+
* protocol (Phase 4): the request carries `x-lunora-client-id` + a monotonic
|
|
421
|
+
* `x-lunora-client-seq`, so the DO runs it exactly once and advances this
|
|
422
|
+
* client's `__client_watermark`.
|
|
423
|
+
*
|
|
424
|
+
* Returns the server `result` plus `applied`: `true` when the DO ran this push
|
|
425
|
+
* as the next-in-order mutation, `false` when it was a replay ack (`clientSeq`
|
|
426
|
+
* was at or below the stored watermark — e.g. a stale sequence after a reload).
|
|
427
|
+
* A `false` verdict tells the caller to reissue above the now-known watermark
|
|
428
|
+
* (echoed into {@link confirmedMutationWatermark}) rather than treat the benign
|
|
429
|
+
* ack as a confirmed write. Every ack — applied or not — bumps the watermark.
|
|
430
|
+
*
|
|
431
|
+
* This is the online transport for `@lunora/db`'s client-mutator runtime; the
|
|
432
|
+
* optimistic overlay + durable-outbox concerns live in that runtime, not here.
|
|
433
|
+
*/
|
|
434
|
+
async callMutator(functionPath, args, options) {
|
|
435
|
+
const clientSeq = options?.clientSeq;
|
|
436
|
+
if (clientSeq !== void 0 && (!Number.isInteger(clientSeq) || clientSeq <= 0)) {
|
|
437
|
+
throw new Error(`callMutator: clientSeq must be a positive integer, got ${String(clientSeq)}`);
|
|
438
|
+
}
|
|
439
|
+
const bucket = options?.shardKey ?? "";
|
|
440
|
+
let ackWatermark;
|
|
441
|
+
const result = await this.rpc(functionPath, args, options?.shardKey, {
|
|
442
|
+
captureBookmark: true,
|
|
443
|
+
clientId: this.clientId,
|
|
444
|
+
clientSeq,
|
|
445
|
+
onMutationAck: (lastMutationId) => {
|
|
446
|
+
ackWatermark = lastMutationId;
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
if (ackWatermark !== void 0 && ackWatermark > (this.clientWatermarks.get(bucket) ?? 0)) {
|
|
450
|
+
this.clientWatermarks.set(bucket, ackWatermark);
|
|
451
|
+
}
|
|
452
|
+
const applied = ackWatermark === void 0 || ackWatermark === clientSeq;
|
|
453
|
+
return { applied, result };
|
|
454
|
+
}
|
|
322
455
|
/**
|
|
323
456
|
* Subscribe to auth-token changes. Returns an unsubscribe function. The
|
|
324
457
|
* listener is NOT invoked on registration — use {@link getAuthToken} for
|
|
325
458
|
* the current value.
|
|
326
459
|
*/
|
|
327
460
|
onAuthTokenChange(listener) {
|
|
328
|
-
this.authTokenListeners.add(listener);
|
|
329
|
-
return () => {
|
|
330
|
-
this.authTokenListeners.delete(listener);
|
|
331
|
-
};
|
|
461
|
+
return this.authTokenListeners.add(listener);
|
|
332
462
|
}
|
|
333
463
|
/**
|
|
334
464
|
* Fetch the currently authenticated user from better-auth's `get-session`
|
|
@@ -531,10 +661,7 @@ class LunoraClient {
|
|
|
531
661
|
* with a freshly minted one. Returns an unsubscribe function.
|
|
532
662
|
*/
|
|
533
663
|
onTokenExpired(listener) {
|
|
534
|
-
this.tokenExpiredListeners.add(listener);
|
|
535
|
-
return () => {
|
|
536
|
-
this.tokenExpiredListeners.delete(listener);
|
|
537
|
-
};
|
|
664
|
+
return this.tokenExpiredListeners.add(listener);
|
|
538
665
|
}
|
|
539
666
|
// --- Connection status --------------------------------------------------
|
|
540
667
|
/**
|
|
@@ -550,11 +677,43 @@ class LunoraClient {
|
|
|
550
677
|
* unsubscribe function.
|
|
551
678
|
*/
|
|
552
679
|
onConnectionStatus(listener) {
|
|
553
|
-
this.statusListeners.add(listener);
|
|
680
|
+
const unsubscribe = this.statusListeners.add(listener);
|
|
554
681
|
listener(this.computeStatus());
|
|
555
|
-
return
|
|
556
|
-
|
|
557
|
-
|
|
682
|
+
return unsubscribe;
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Number of offline writes waiting in the built-in queue to be sent — the
|
|
686
|
+
* depth for a "N changes waiting to sync" indicator. Counts writes that are
|
|
687
|
+
* queued (offline / mid-reconnect), not ones already in flight on the wire.
|
|
688
|
+
* A `@lunora/db` app whose writes ride the unified outbox should read
|
|
689
|
+
* `LunoraDb.pendingCount()` instead (this counts only the built-in queue).
|
|
690
|
+
*/
|
|
691
|
+
pendingCount() {
|
|
692
|
+
return this.offlineQueue.size;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Subscribe to changes in {@link pendingCount}. Invokes `listener` immediately
|
|
696
|
+
* with the current count, then whenever the queue depth changes (a write is
|
|
697
|
+
* enqueued, flushed, or discarded). Returns an unsubscribe function.
|
|
698
|
+
*/
|
|
699
|
+
onPendingChange(listener) {
|
|
700
|
+
const unsubscribe = this.pendingChangeListeners.add(listener);
|
|
701
|
+
listener(this.offlineQueue.size);
|
|
702
|
+
return unsubscribe;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Subscribe to terminal verdicts for offline-queued mutations. The listener
|
|
706
|
+
* fires once per queued write that commits or is rejected — including a write
|
|
707
|
+
* restored from durable storage after a reload, whose original `mutation()`
|
|
708
|
+
* Promise no longer exists (`hadAwaiter: false`), and a write the queue
|
|
709
|
+
* evicts on overflow or discards on an identity change. This is the durable
|
|
710
|
+
* channel for surfacing a rolled-back optimistic write to the UI; an online
|
|
711
|
+
* mutation that never queued still surfaces through the Promise `mutation()`
|
|
712
|
+
* returns. The listener is NOT invoked on registration. Returns an
|
|
713
|
+
* unsubscribe function. See {@link MutationSettledEvent}.
|
|
714
|
+
*/
|
|
715
|
+
onMutationSettled(listener) {
|
|
716
|
+
return this.mutationSettledListeners.add(listener);
|
|
558
717
|
}
|
|
559
718
|
// --- RPC ---------------------------------------------------------------
|
|
560
719
|
async query(function_, args, options = {}) {
|
|
@@ -578,10 +737,15 @@ class LunoraClient {
|
|
|
578
737
|
throw new Error("LunoraClient is closed");
|
|
579
738
|
}
|
|
580
739
|
const argsRecord = args;
|
|
581
|
-
const mutationId = nextId();
|
|
582
|
-
const optimisticRollbacks = this.applyOptimisticUpdates(
|
|
740
|
+
const mutationId = options.mutationId ?? nextId();
|
|
741
|
+
const { confirms: optimisticConfirms, rollbacks: optimisticRollbacks } = this.applyOptimisticUpdates(
|
|
742
|
+
function_.__lunoraRef,
|
|
743
|
+
argsRecord,
|
|
744
|
+
options.shardKey,
|
|
745
|
+
options.optimistic
|
|
746
|
+
);
|
|
583
747
|
if (options.optimisticUpdate) {
|
|
584
|
-
this.applyOptimisticUpdate(options.optimisticUpdate, args, options.shardKey, optimisticRollbacks);
|
|
748
|
+
this.applyOptimisticUpdate(options.optimisticUpdate, args, options.shardKey, optimisticRollbacks, optimisticConfirms);
|
|
585
749
|
}
|
|
586
750
|
const conn = this.getConnection(options.shardKey);
|
|
587
751
|
const wsState = conn?.wsState ?? "idle";
|
|
@@ -592,40 +756,23 @@ class LunoraClient {
|
|
|
592
756
|
const shouldQueueOffline = this.WebSocketImpl !== void 0 && connectedGate;
|
|
593
757
|
const midReconnect = wsState === "connecting" && connectedGate;
|
|
594
758
|
if (wsState !== "open" && !hasSocket && shouldQueueOffline || midReconnect) {
|
|
595
|
-
|
|
596
|
-
return new Promise((resolve, reject) => {
|
|
597
|
-
const entry = {
|
|
598
|
-
args: argsRecord,
|
|
599
|
-
functionPath: function_.__lunoraRef,
|
|
600
|
-
// Reuse the call's idempotency key as the queue id so the
|
|
601
|
-
// replay carries the same `x-lunora-mutation-id` the server
|
|
602
|
-
// dedups on.
|
|
603
|
-
id: mutationId,
|
|
604
|
-
// Persist the stamp alongside the record so a hydrated write
|
|
605
|
-
// can only replay under the identity that queued it.
|
|
606
|
-
identity: issuingIdentity,
|
|
607
|
-
reject: (error) => {
|
|
608
|
-
this.queuedIdentities.delete(mutationId);
|
|
609
|
-
for (let index = optimisticRollbacks.length - 1; index >= 0; index -= 1) {
|
|
610
|
-
optimisticRollbacks[index]?.();
|
|
611
|
-
}
|
|
612
|
-
reject(error instanceof Error ? error : new Error(String(error)));
|
|
613
|
-
},
|
|
614
|
-
resolve,
|
|
615
|
-
shardKey: options.shardKey
|
|
616
|
-
};
|
|
617
|
-
this.offlineQueue.enqueue(entry);
|
|
618
|
-
if (entry.id !== void 0) {
|
|
619
|
-
this.queuedIdentities.set(entry.id, issuingIdentity);
|
|
620
|
-
}
|
|
621
|
-
});
|
|
759
|
+
return this.enqueueOfflineMutation(function_, argsRecord, options.shardKey, mutationId, optimisticRollbacks, optimisticConfirms);
|
|
622
760
|
}
|
|
623
761
|
try {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
762
|
+
let commitCursor;
|
|
763
|
+
const result = await this.rpc(function_.__lunoraRef, argsRecord, options.shardKey, {
|
|
764
|
+
captureBookmark: true,
|
|
765
|
+
mutationId,
|
|
766
|
+
onCommitCursor: (cursor) => {
|
|
767
|
+
commitCursor = cursor;
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
for (const confirm of optimisticConfirms) {
|
|
771
|
+
confirm(commitCursor);
|
|
628
772
|
}
|
|
773
|
+
return result;
|
|
774
|
+
} catch (error) {
|
|
775
|
+
rollbackOptimistic(optimisticRollbacks);
|
|
629
776
|
throw error;
|
|
630
777
|
}
|
|
631
778
|
}
|
|
@@ -739,8 +886,12 @@ class LunoraClient {
|
|
|
739
886
|
* List a workflow's instances via the admin Workflows proxy
|
|
740
887
|
* (`/_lunora/admin/workflows/instances`) — the Cloudflare control-plane data
|
|
741
888
|
* the `Workflow` binding can't expose. Requires the worker to be built with a
|
|
742
|
-
* `workflowsClient` (Cloudflare account id + API token)
|
|
743
|
-
*
|
|
889
|
+
* `workflowsClient` (Cloudflare account id + API token). When one isn't
|
|
890
|
+
* configured this does NOT reject: the proxy returns a `200 { configured:
|
|
891
|
+
* false }` sentinel, so the result resolves with `configured === false` and an
|
|
892
|
+
* empty `instances` list — callers should branch on that flag rather than
|
|
893
|
+
* try/catch. (The instance-detail / status endpoints still reject with 501.)
|
|
894
|
+
* `name` is the deployed workflow name.
|
|
744
895
|
*/
|
|
745
896
|
async listWorkflowInstances(options) {
|
|
746
897
|
if (this.closed) {
|
|
@@ -757,7 +908,13 @@ class LunoraClient {
|
|
|
757
908
|
query.set("perPage", String(options.perPage));
|
|
758
909
|
}
|
|
759
910
|
const body = await this.adminFetch(`${WORKFLOWS_INSTANCES_PATH}?${query.toString()}`, "GET");
|
|
760
|
-
return {
|
|
911
|
+
return {
|
|
912
|
+
configured: body.configured,
|
|
913
|
+
instances: body.instances ?? [],
|
|
914
|
+
page: body.page ?? 1,
|
|
915
|
+
perPage: body.perPage ?? options.perPage ?? 0,
|
|
916
|
+
totalCount: body.totalCount
|
|
917
|
+
};
|
|
761
918
|
}
|
|
762
919
|
/** Read one workflow instance with its step timeline (`/_lunora/admin/workflows/instance`). */
|
|
763
920
|
async getWorkflowInstance(options) {
|
|
@@ -1260,12 +1417,14 @@ class LunoraClient {
|
|
|
1260
1417
|
args: argsRecord,
|
|
1261
1418
|
argsKey,
|
|
1262
1419
|
callbacks: /* @__PURE__ */ new Set(),
|
|
1420
|
+
checkpointCallbacks: /* @__PURE__ */ new Set(),
|
|
1263
1421
|
errorCallbacks: /* @__PURE__ */ new Set(),
|
|
1264
1422
|
fn: function_,
|
|
1265
1423
|
id,
|
|
1266
1424
|
lastValue: cached?.value,
|
|
1425
|
+
optimisticLayers: [],
|
|
1426
|
+
serverBase: cached?.value,
|
|
1267
1427
|
serverCursor: cached?.serverCursor,
|
|
1268
|
-
serverVersion: 0,
|
|
1269
1428
|
shardKey: options.shardKey,
|
|
1270
1429
|
...cached?.serverEpoch === void 0 ? {} : { serverEpoch: cached.serverEpoch }
|
|
1271
1430
|
};
|
|
@@ -1275,6 +1434,9 @@ class LunoraClient {
|
|
|
1275
1434
|
if (errorCallback) {
|
|
1276
1435
|
state.errorCallbacks.add(errorCallback);
|
|
1277
1436
|
}
|
|
1437
|
+
if (options.onCheckpoint) {
|
|
1438
|
+
state.checkpointCallbacks.add(options.onCheckpoint);
|
|
1439
|
+
}
|
|
1278
1440
|
if (state.lastValue !== void 0) {
|
|
1279
1441
|
try {
|
|
1280
1442
|
subscriptionCallback(state.lastValue);
|
|
@@ -1289,16 +1451,59 @@ class LunoraClient {
|
|
|
1289
1451
|
if (errorCallback) {
|
|
1290
1452
|
subscriptionState.errorCallbacks.delete(errorCallback);
|
|
1291
1453
|
}
|
|
1454
|
+
if (options.onCheckpoint) {
|
|
1455
|
+
subscriptionState.checkpointCallbacks.delete(options.onCheckpoint);
|
|
1456
|
+
}
|
|
1292
1457
|
if (subscriptionState.callbacks.size === 0) {
|
|
1293
1458
|
const conn = this.getConnection(subscriptionState.shardKey);
|
|
1294
1459
|
const ok = conn ? sendOn(conn, { id: subscriptionState.id, type: "unsubscribe" }) : false;
|
|
1295
1460
|
if (!ok && conn) {
|
|
1296
|
-
conn.pendingUnsubscribes.push(subscriptionState.id);
|
|
1461
|
+
conn.pendingUnsubscribes.push({ id: subscriptionState.id, type: "unsubscribe" });
|
|
1297
1462
|
}
|
|
1298
1463
|
this.subscriptions.remove(subscriptionState);
|
|
1299
1464
|
}
|
|
1300
1465
|
};
|
|
1301
1466
|
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Subscribe to a declarative **shape** — server-side partial replication
|
|
1469
|
+
* scoped by `shardBy` + the shape's predicate + RLS. The parallel to
|
|
1470
|
+
* {@link subscribe} for the poke protocol: the client sends the shape *name* +
|
|
1471
|
+
* validated `args` (never a `where` the client could forge), the server seeds
|
|
1472
|
+
* the current membership as an insert-poke and streams live membership diffs.
|
|
1473
|
+
* Each applied poke materializes the shape's rowset and invokes `callback`.
|
|
1474
|
+
*
|
|
1475
|
+
* Unlike {@link subscribe}, shape subscriptions are NOT deduped by
|
|
1476
|
+
* (name, args): the server resolves them under the socket's verified identity,
|
|
1477
|
+
* so every call gets its own id + view. The returned function unsubscribes.
|
|
1478
|
+
*/
|
|
1479
|
+
subscribeShape(shape, callback, options = {}) {
|
|
1480
|
+
if (this.closed) {
|
|
1481
|
+
throw new Error("LunoraClient is closed");
|
|
1482
|
+
}
|
|
1483
|
+
this.nextShapeId += 1;
|
|
1484
|
+
const id = `shape_${this.nextShapeId.toString()}`;
|
|
1485
|
+
const state = {
|
|
1486
|
+
args: shape.args,
|
|
1487
|
+
callbacks: /* @__PURE__ */ new Set([callback]),
|
|
1488
|
+
errorCallbacks: options.onError ? /* @__PURE__ */ new Set([options.onError]) : /* @__PURE__ */ new Set(),
|
|
1489
|
+
id,
|
|
1490
|
+
name: shape.name,
|
|
1491
|
+
onCheckpoint: options.onCheckpoint,
|
|
1492
|
+
rows: /* @__PURE__ */ new Map(),
|
|
1493
|
+
shardKey: options.shardKey
|
|
1494
|
+
};
|
|
1495
|
+
this.shapeSubscriptions.set(id, state);
|
|
1496
|
+
this.ensureSocket(options.shardKey);
|
|
1497
|
+
this.sendShapeSubscribeIfOpen(state);
|
|
1498
|
+
return () => {
|
|
1499
|
+
this.shapeSubscriptions.delete(id);
|
|
1500
|
+
const conn = this.getConnection(state.shardKey);
|
|
1501
|
+
const ok = conn ? sendOn(conn, { id, type: "shape_unsubscribe" }) : false;
|
|
1502
|
+
if (!ok && conn) {
|
|
1503
|
+
conn.pendingUnsubscribes.push({ id, type: "shape_unsubscribe" });
|
|
1504
|
+
}
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1302
1507
|
/**
|
|
1303
1508
|
* Open a streaming query. The function reference must be a
|
|
1304
1509
|
* `kind:"stream"` registration (built with `c.query.input(...).stream(...)`);
|
|
@@ -1366,6 +1571,8 @@ class LunoraClient {
|
|
|
1366
1571
|
}
|
|
1367
1572
|
close() {
|
|
1368
1573
|
this.closed = true;
|
|
1574
|
+
this.outboxLeaderRelease?.();
|
|
1575
|
+
this.outboxLeaderRelease = void 0;
|
|
1369
1576
|
for (const stream of this.streams.values()) {
|
|
1370
1577
|
stream.handle.fail(Object.assign(new Error("LunoraClient closed"), { code: "CLIENT_CLOSED" }));
|
|
1371
1578
|
}
|
|
@@ -1375,6 +1582,10 @@ class LunoraClient {
|
|
|
1375
1582
|
clearTimeout(conn.reconnectTimer);
|
|
1376
1583
|
conn.reconnectTimer = void 0;
|
|
1377
1584
|
}
|
|
1585
|
+
if (conn.connectTimer !== void 0) {
|
|
1586
|
+
clearTimeout(conn.connectTimer);
|
|
1587
|
+
conn.connectTimer = void 0;
|
|
1588
|
+
}
|
|
1378
1589
|
this.stopHeartbeat(conn);
|
|
1379
1590
|
if (conn.socket) {
|
|
1380
1591
|
try {
|
|
@@ -1397,9 +1608,83 @@ class LunoraClient {
|
|
|
1397
1608
|
this.authTokenListeners.clear();
|
|
1398
1609
|
this.statusListeners.clear();
|
|
1399
1610
|
this.tokenExpiredListeners.clear();
|
|
1611
|
+
this.mutationSettledListeners.clear();
|
|
1612
|
+
this.pendingChangeListeners.clear();
|
|
1400
1613
|
this.whisperHandlers.clear();
|
|
1614
|
+
this.shapeSubscriptions.clear();
|
|
1615
|
+
this.pokeBuffers.clear();
|
|
1401
1616
|
}
|
|
1402
1617
|
// --- Internals ----------------------------------------------------------
|
|
1618
|
+
/**
|
|
1619
|
+
* Persist a mutation that can't go out on the wire right now (offline, or
|
|
1620
|
+
* mid-reconnect after a prior connect). The optimistic update has already
|
|
1621
|
+
* been applied by `mutation`; this only chooses the durable write path and
|
|
1622
|
+
* rolls the optimistic write back if persistence is rejected.
|
|
1623
|
+
*
|
|
1624
|
+
* Two paths: when an `outbox` sink is wired (the `@lunora/db` executor) it
|
|
1625
|
+
* owns persistence + at-least-once replay, so we delegate and return
|
|
1626
|
+
* optimistically (confirmation rides the synced view). Otherwise the
|
|
1627
|
+
* built-in `OfflineQueue` resolves/rejects the returned promise on replay.
|
|
1628
|
+
*/
|
|
1629
|
+
async enqueueOfflineMutation(function_, argsRecord, shardKey, mutationId, optimisticRollbacks, optimisticConfirms) {
|
|
1630
|
+
const issuingIdentity = this.identityFingerprint();
|
|
1631
|
+
if (this.outbox) {
|
|
1632
|
+
this.outboxMutationCounter += 1;
|
|
1633
|
+
const outboxMutationId = this.outboxMutationCounter;
|
|
1634
|
+
try {
|
|
1635
|
+
await this.outbox.enqueue({
|
|
1636
|
+
args: argsRecord,
|
|
1637
|
+
clientId: this.clientId,
|
|
1638
|
+
functionPath: function_.__lunoraRef,
|
|
1639
|
+
idempotencyKey: `${this.clientId}:${String(outboxMutationId)}`,
|
|
1640
|
+
identity: issuingIdentity,
|
|
1641
|
+
mutationId: outboxMutationId,
|
|
1642
|
+
shardKey
|
|
1643
|
+
});
|
|
1644
|
+
} catch (error) {
|
|
1645
|
+
rollbackOptimistic(optimisticRollbacks);
|
|
1646
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
1647
|
+
}
|
|
1648
|
+
for (const confirm of optimisticConfirms) {
|
|
1649
|
+
confirm(void 0);
|
|
1650
|
+
}
|
|
1651
|
+
return void 0;
|
|
1652
|
+
}
|
|
1653
|
+
return new Promise((resolve, reject) => {
|
|
1654
|
+
const entry = {
|
|
1655
|
+
args: argsRecord,
|
|
1656
|
+
functionPath: function_.__lunoraRef,
|
|
1657
|
+
// A live caller is awaiting this Promise, so a terminal verdict
|
|
1658
|
+
// reaches them directly; the observer event carries
|
|
1659
|
+
// `hadAwaiter: true`. Hydrated replays leave this unset.
|
|
1660
|
+
liveAwaiter: true,
|
|
1661
|
+
// Reuse the call's idempotency key as the queue id so the replay
|
|
1662
|
+
// carries the same `x-lunora-mutation-id` the server dedups on.
|
|
1663
|
+
id: mutationId,
|
|
1664
|
+
// Persist the stamp alongside the record so a hydrated write can
|
|
1665
|
+
// only replay under the identity that queued it.
|
|
1666
|
+
identity: issuingIdentity,
|
|
1667
|
+
// Confirm the per-call optimistic layer(s) against the commit cursor
|
|
1668
|
+
// the flush replay echoes (see flushOfflineQueue).
|
|
1669
|
+
onCommit: (commitCursor) => {
|
|
1670
|
+
for (const confirm of optimisticConfirms) {
|
|
1671
|
+
confirm(commitCursor);
|
|
1672
|
+
}
|
|
1673
|
+
},
|
|
1674
|
+
reject: (error) => {
|
|
1675
|
+
this.queuedIdentities.delete(mutationId);
|
|
1676
|
+
rollbackOptimistic(optimisticRollbacks);
|
|
1677
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
1678
|
+
},
|
|
1679
|
+
resolve,
|
|
1680
|
+
shardKey
|
|
1681
|
+
};
|
|
1682
|
+
this.offlineQueue.enqueue(entry);
|
|
1683
|
+
if (entry.id !== void 0) {
|
|
1684
|
+
this.queuedIdentities.set(entry.id, issuingIdentity);
|
|
1685
|
+
}
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1403
1688
|
/**
|
|
1404
1689
|
* Restore offline mutations persisted in a prior session and open a socket
|
|
1405
1690
|
* for each shard they target so they flush once the WS reconnects. Failures
|
|
@@ -1414,6 +1699,38 @@ class LunoraClient {
|
|
|
1414
1699
|
} catch {
|
|
1415
1700
|
}
|
|
1416
1701
|
}
|
|
1702
|
+
/**
|
|
1703
|
+
* Re-queue the durable offline writes — but only as the multi-tab LEADER. The
|
|
1704
|
+
* persisted queue is shared across a profile's tabs; without coordination
|
|
1705
|
+
* every tab would re-queue and replay the same writes (correct only because
|
|
1706
|
+
* the server dedups by idempotency key, but wasteful + racy). A Web Lock makes
|
|
1707
|
+
* exactly one tab hydrate; it holds the lock for its lifetime, so when it
|
|
1708
|
+
* closes another tab acquires the lock and takes over. Falls back to
|
|
1709
|
+
* unconditional hydration where Web Locks are unavailable (React Native, older
|
|
1710
|
+
* browsers, SSR) — single-context there, so no coordination is needed.
|
|
1711
|
+
*/
|
|
1712
|
+
hydrateAsOutboxLeader() {
|
|
1713
|
+
const hydrate = () => {
|
|
1714
|
+
this.hydratePersistedQueue().catch(() => void 0);
|
|
1715
|
+
};
|
|
1716
|
+
const locks = globalThis.navigator?.locks;
|
|
1717
|
+
if (!locks) {
|
|
1718
|
+
hydrate();
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
locks.request(`lunora:outbox-leader:${this.url}`, () => {
|
|
1722
|
+
if (!this.closed) {
|
|
1723
|
+
hydrate();
|
|
1724
|
+
}
|
|
1725
|
+
return new Promise((resolve) => {
|
|
1726
|
+
if (this.closed) {
|
|
1727
|
+
resolve();
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
this.outboxLeaderRelease = resolve;
|
|
1731
|
+
});
|
|
1732
|
+
}).catch(hydrate);
|
|
1733
|
+
}
|
|
1417
1734
|
/**
|
|
1418
1735
|
* Load every cached query into {@link hydratedQueryCache} so the next
|
|
1419
1736
|
* `subscribe()` for each key seeds its initial value off disk. A
|
|
@@ -1428,6 +1745,10 @@ class LunoraClient {
|
|
|
1428
1745
|
try {
|
|
1429
1746
|
const entries = await this.queryCache.load();
|
|
1430
1747
|
for (const { key, ...entry } of entries) {
|
|
1748
|
+
if (isStaleVersion(this.persistenceVersion, entry.version)) {
|
|
1749
|
+
this.queryCache.remove(key).catch(() => void 0);
|
|
1750
|
+
continue;
|
|
1751
|
+
}
|
|
1431
1752
|
this.hydratedQueryCache.set(key, entry);
|
|
1432
1753
|
}
|
|
1433
1754
|
} catch {
|
|
@@ -1456,7 +1777,8 @@ class LunoraClient {
|
|
|
1456
1777
|
* (nothing to render offline).
|
|
1457
1778
|
*/
|
|
1458
1779
|
persistQueryValue(state) {
|
|
1459
|
-
|
|
1780
|
+
const authoritative = state.serverBase;
|
|
1781
|
+
if (!this.queryCache || authoritative === void 0) {
|
|
1460
1782
|
return;
|
|
1461
1783
|
}
|
|
1462
1784
|
const key = queryCacheKey(state.fn.__lunoraRef, state.argsKey, state.shardKey);
|
|
@@ -1464,8 +1786,9 @@ class LunoraClient {
|
|
|
1464
1786
|
identity: this.identityFingerprint(),
|
|
1465
1787
|
serverCursor: state.serverCursor,
|
|
1466
1788
|
ts: Date.now(),
|
|
1467
|
-
value:
|
|
1468
|
-
...state.serverEpoch === void 0 ? {} : { serverEpoch: state.serverEpoch }
|
|
1789
|
+
value: authoritative,
|
|
1790
|
+
...state.serverEpoch === void 0 ? {} : { serverEpoch: state.serverEpoch },
|
|
1791
|
+
...this.persistenceVersion === void 0 ? {} : { version: this.persistenceVersion }
|
|
1469
1792
|
});
|
|
1470
1793
|
this.cacheFlushTimer ??= setTimeout(() => {
|
|
1471
1794
|
this.flushQueryCacheWrites().catch(() => void 0);
|
|
@@ -1504,48 +1827,70 @@ class LunoraClient {
|
|
|
1504
1827
|
return;
|
|
1505
1828
|
}
|
|
1506
1829
|
this.lastStatus = next;
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1830
|
+
this.statusListeners.emit(next);
|
|
1831
|
+
}
|
|
1832
|
+
/**
|
|
1833
|
+
* Build a {@link MutationSettledEvent} from a queued entry and emit it on the
|
|
1834
|
+
* {@link onMutationSettled} channel. `item.id` is always assigned by the time
|
|
1835
|
+
* a write settles (`enqueue`/`hydrate` guarantee it), so the `?? ""` fallback
|
|
1836
|
+
* is unreachable — present only to satisfy the optional queue-id type.
|
|
1837
|
+
*/
|
|
1838
|
+
emitItemSettled(item, status, error) {
|
|
1839
|
+
this.mutationSettledListeners.emit({
|
|
1840
|
+
args: item.args,
|
|
1841
|
+
code: error === void 0 ? void 0 : error.code,
|
|
1842
|
+
error,
|
|
1843
|
+
functionPath: item.functionPath,
|
|
1844
|
+
hadAwaiter: item.liveAwaiter ?? false,
|
|
1845
|
+
id: item.id ?? "",
|
|
1846
|
+
shardKey: item.shardKey,
|
|
1847
|
+
status
|
|
1848
|
+
});
|
|
1513
1849
|
}
|
|
1514
1850
|
/**
|
|
1515
|
-
* Apply an optimistic update to
|
|
1516
|
-
*
|
|
1517
|
-
*
|
|
1518
|
-
*
|
|
1519
|
-
*
|
|
1851
|
+
* Apply an optimistic update to the subscription that matches the mutation's
|
|
1852
|
+
* `(functionRef, args, shardKey)` triple, returning the rollback callbacks to
|
|
1853
|
+
* invoke if the mutation later fails.
|
|
1854
|
+
*
|
|
1855
|
+
* The registry is already indexed by exactly this triple via
|
|
1856
|
+
* `SubscriptionRegistry.key`, so at most one subscription can match. A direct
|
|
1857
|
+
* O(1) keyed lookup replaces the former O(N) linear scan over all subscriptions.
|
|
1858
|
+
*
|
|
1859
|
+
* `shardKey` normalization: both `undefined` and `""` map to the empty string
|
|
1860
|
+
* inside `SubscriptionRegistry.key` (via `?? ""`), so a mutation fired without
|
|
1861
|
+
* a shardKey correctly matches a subscription registered without one regardless
|
|
1862
|
+
* of whether the caller passed `undefined` or omitted the field.
|
|
1520
1863
|
*/
|
|
1521
1864
|
applyOptimisticUpdates(functionRef, argsRecord, mutationShardKey, optimistic) {
|
|
1522
|
-
const
|
|
1865
|
+
const confirms = [];
|
|
1866
|
+
const rollbacks = [];
|
|
1523
1867
|
if (!optimistic) {
|
|
1524
|
-
return
|
|
1868
|
+
return { confirms, rollbacks };
|
|
1525
1869
|
}
|
|
1526
|
-
const
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
optimisticRollbacks.push(rollback);
|
|
1870
|
+
const matchKey = SubscriptionRegistry.key(functionRef, argsRecord, mutationShardKey);
|
|
1871
|
+
const state = this.subscriptions.get(matchKey);
|
|
1872
|
+
if (state) {
|
|
1873
|
+
const handle = applyOptimisticLayer(state, optimistic);
|
|
1874
|
+
if (handle) {
|
|
1875
|
+
confirms.push(handle.confirm);
|
|
1876
|
+
rollbacks.push(handle.rollback);
|
|
1534
1877
|
}
|
|
1535
1878
|
}
|
|
1536
|
-
return
|
|
1879
|
+
return { confirms, rollbacks };
|
|
1537
1880
|
}
|
|
1538
1881
|
/**
|
|
1539
1882
|
* Run a Convex-parity `optimisticUpdate` callback against a localStore bound
|
|
1540
|
-
* to the live subscription registry
|
|
1541
|
-
*
|
|
1542
|
-
*
|
|
1543
|
-
*
|
|
1544
|
-
*
|
|
1545
|
-
*
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1883
|
+
* to the live subscription registry. Each `setQuery` registers a constant
|
|
1884
|
+
* optimistic LAYER on its target subscription (via the same engine the
|
|
1885
|
+
* per-call `optimistic` path uses), so the multi-query patch rebases onto
|
|
1886
|
+
* incoming deltas and drops gaplessly on its commit cursor — its `confirm` /
|
|
1887
|
+
* `rollback` closures are appended to the mutation's settle lists. A throwing
|
|
1888
|
+
* callback unwinds its own partial writes — LIFO over just the rollbacks it
|
|
1889
|
+
* produced — and is swallowed, so a buggy optimistic update can never fail the
|
|
1890
|
+
* mutation or leave a partial patch live.
|
|
1891
|
+
*/
|
|
1892
|
+
applyOptimisticUpdate(optimisticUpdate, args, shardKey, optimisticRollbacks, optimisticConfirms) {
|
|
1893
|
+
const { confirms, rollbacks, store } = createLocalStore(this.subscriptions, shardKey, stableStringify);
|
|
1549
1894
|
try {
|
|
1550
1895
|
optimisticUpdate(store, args);
|
|
1551
1896
|
} catch {
|
|
@@ -1555,6 +1900,7 @@ class LunoraClient {
|
|
|
1555
1900
|
return;
|
|
1556
1901
|
}
|
|
1557
1902
|
optimisticRollbacks.push(...rollbacks);
|
|
1903
|
+
optimisticConfirms.push(...confirms);
|
|
1558
1904
|
}
|
|
1559
1905
|
getConnection(shardKey) {
|
|
1560
1906
|
return this.connections.get(connectionKey(shardKey));
|
|
@@ -1564,6 +1910,7 @@ class LunoraClient {
|
|
|
1564
1910
|
let conn = this.connections.get(key);
|
|
1565
1911
|
if (!conn) {
|
|
1566
1912
|
conn = {
|
|
1913
|
+
connectTimer: void 0,
|
|
1567
1914
|
heartbeatTimer: void 0,
|
|
1568
1915
|
pendingUnsubscribes: [],
|
|
1569
1916
|
reconnect: createReconnect(this.reconnectOptions),
|
|
@@ -1607,6 +1954,12 @@ class LunoraClient {
|
|
|
1607
1954
|
if (flags.mutationId) {
|
|
1608
1955
|
headers["x-lunora-mutation-id"] = flags.mutationId;
|
|
1609
1956
|
}
|
|
1957
|
+
if (flags.clientId !== void 0) {
|
|
1958
|
+
headers["x-lunora-client-id"] = flags.clientId;
|
|
1959
|
+
}
|
|
1960
|
+
if (flags.clientSeq !== void 0) {
|
|
1961
|
+
headers["x-lunora-client-seq"] = flags.clientSeq.toString();
|
|
1962
|
+
}
|
|
1610
1963
|
if (flags.attachBookmark) {
|
|
1611
1964
|
const bookmark = this.bookmark.get();
|
|
1612
1965
|
if (bookmark) {
|
|
@@ -1647,6 +2000,8 @@ class LunoraClient {
|
|
|
1647
2000
|
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
1648
2001
|
throw new Error(`LunoraClient: request failed (status ${response.status.toString()}${statusText})`);
|
|
1649
2002
|
}
|
|
2003
|
+
flags.onMutationAck?.(body.lastMutationId);
|
|
2004
|
+
flags.onCommitCursor?.(body.commitCursor);
|
|
1650
2005
|
return body.result;
|
|
1651
2006
|
}
|
|
1652
2007
|
/**
|
|
@@ -1737,11 +2092,27 @@ class LunoraClient {
|
|
|
1737
2092
|
sendConnectEnvelope(conn) {
|
|
1738
2093
|
const context = this.effectiveConnectionContext(connectionKey(conn.shardKey));
|
|
1739
2094
|
sendOn(conn, {
|
|
2095
|
+
// Lets the server scope this connection's `__client_watermark` so
|
|
2096
|
+
// custom-mutator pokes can echo this client's `lastMutationId`.
|
|
2097
|
+
clientId: this.clientId,
|
|
1740
2098
|
id: "connect",
|
|
1741
2099
|
type: "connect",
|
|
1742
2100
|
...context === void 0 ? {} : { context }
|
|
1743
2101
|
});
|
|
1744
2102
|
}
|
|
2103
|
+
/**
|
|
2104
|
+
* Re-send every shape subscription bound to `shardKey` over its (now open)
|
|
2105
|
+
* socket. Each frame carries the shape's last applied checkpoint, so the
|
|
2106
|
+
* server resumes from it — or re-seeds when the cursor fell below CDC
|
|
2107
|
+
* retention or the epoch forked.
|
|
2108
|
+
*/
|
|
2109
|
+
resendShapeSubscriptions(shardKey) {
|
|
2110
|
+
for (const state of this.shapeSubscriptions.values()) {
|
|
2111
|
+
if (connectionKey(state.shardKey) === connectionKey(shardKey)) {
|
|
2112
|
+
this.sendShapeSubscribeIfOpen(state);
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
1745
2116
|
ensureSocket(shardKey) {
|
|
1746
2117
|
if (this.closed || this.WebSocketImpl === void 0) {
|
|
1747
2118
|
return;
|
|
@@ -1754,7 +2125,27 @@ class LunoraClient {
|
|
|
1754
2125
|
this.emitConnectionStatus();
|
|
1755
2126
|
const socket = new this.WebSocketImpl(this.wsUrlFor(shardKey));
|
|
1756
2127
|
conn.socket = socket;
|
|
2128
|
+
if (this.connectTimeoutMs > 0) {
|
|
2129
|
+
conn.connectTimer = setTimeout(() => {
|
|
2130
|
+
conn.connectTimer = void 0;
|
|
2131
|
+
if (conn.socket !== socket || conn.wsState !== "connecting") {
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
try {
|
|
2135
|
+
socket.close();
|
|
2136
|
+
} catch {
|
|
2137
|
+
}
|
|
2138
|
+
this.handleDisconnect(conn);
|
|
2139
|
+
}, this.connectTimeoutMs);
|
|
2140
|
+
}
|
|
1757
2141
|
socket.addEventListener("open", () => {
|
|
2142
|
+
if (conn.socket !== socket) {
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
2145
|
+
if (conn.connectTimer !== void 0) {
|
|
2146
|
+
clearTimeout(conn.connectTimer);
|
|
2147
|
+
conn.connectTimer = void 0;
|
|
2148
|
+
}
|
|
1758
2149
|
conn.wsState = "open";
|
|
1759
2150
|
conn.wasEverConnected = true;
|
|
1760
2151
|
conn.reconnect.reset();
|
|
@@ -1766,11 +2157,12 @@ class LunoraClient {
|
|
|
1766
2157
|
this.sendSubscribeIfOpen(state);
|
|
1767
2158
|
}
|
|
1768
2159
|
}
|
|
2160
|
+
this.resendShapeSubscriptions(shardKey);
|
|
1769
2161
|
if (conn.pendingUnsubscribes.length > 0) {
|
|
1770
2162
|
const pending = conn.pendingUnsubscribes;
|
|
1771
2163
|
conn.pendingUnsubscribes = [];
|
|
1772
|
-
for (const id of pending) {
|
|
1773
|
-
sendOn(conn, { id, type
|
|
2164
|
+
for (const { id, type } of pending) {
|
|
2165
|
+
sendOn(conn, { id, type });
|
|
1774
2166
|
}
|
|
1775
2167
|
}
|
|
1776
2168
|
if (conn.pendingStreams && conn.pendingStreams.length > 0) {
|
|
@@ -1793,12 +2185,18 @@ class LunoraClient {
|
|
|
1793
2185
|
this.handleServerMessage(event.data, shardKey);
|
|
1794
2186
|
});
|
|
1795
2187
|
socket.addEventListener("close", (event) => {
|
|
2188
|
+
if (conn.socket !== socket) {
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
1796
2191
|
if (event?.code === 4001) {
|
|
1797
2192
|
this.notifyTokenExpired();
|
|
1798
2193
|
}
|
|
1799
2194
|
this.handleDisconnect(conn);
|
|
1800
2195
|
});
|
|
1801
2196
|
socket.addEventListener("error", () => {
|
|
2197
|
+
if (conn.socket !== socket) {
|
|
2198
|
+
return;
|
|
2199
|
+
}
|
|
1802
2200
|
if (conn.wsState === "connecting" || conn.wsState === "open") {
|
|
1803
2201
|
this.handleDisconnect(conn);
|
|
1804
2202
|
}
|
|
@@ -1812,6 +2210,10 @@ class LunoraClient {
|
|
|
1812
2210
|
return;
|
|
1813
2211
|
}
|
|
1814
2212
|
this.stopHeartbeat(conn);
|
|
2213
|
+
if (conn.connectTimer !== void 0) {
|
|
2214
|
+
clearTimeout(conn.connectTimer);
|
|
2215
|
+
conn.connectTimer = void 0;
|
|
2216
|
+
}
|
|
1815
2217
|
conn.socket = void 0;
|
|
1816
2218
|
conn.wsState = "idle";
|
|
1817
2219
|
this.emitConnectionStatus();
|
|
@@ -1885,6 +2287,21 @@ class LunoraClient {
|
|
|
1885
2287
|
type: "subscribe"
|
|
1886
2288
|
});
|
|
1887
2289
|
}
|
|
2290
|
+
sendShapeSubscribeIfOpen(state) {
|
|
2291
|
+
const conn = this.getConnection(state.shardKey);
|
|
2292
|
+
if (conn?.wsState !== "open") {
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
sendOn(conn, {
|
|
2296
|
+
id: state.id,
|
|
2297
|
+
shape: { name: state.name, ...state.args === void 0 ? {} : { args: state.args } },
|
|
2298
|
+
type: "shape_subscribe",
|
|
2299
|
+
// Resume from the last applied checkpoint when we hold one; a cold
|
|
2300
|
+
// subscribe omits it and the server seeds the full membership.
|
|
2301
|
+
...state.serverCursor === void 0 ? {} : { sinceCheckpoint: state.serverCursor },
|
|
2302
|
+
...state.serverEpoch === void 0 ? {} : { sinceEpoch: state.serverEpoch }
|
|
2303
|
+
});
|
|
2304
|
+
}
|
|
1888
2305
|
handleServerMessage(raw, shardKey) {
|
|
1889
2306
|
const text = decodeServerFrame(raw);
|
|
1890
2307
|
if (text === void 0) {
|
|
@@ -1923,10 +2340,26 @@ class LunoraClient {
|
|
|
1923
2340
|
this.handleErrorMessage(message);
|
|
1924
2341
|
break;
|
|
1925
2342
|
}
|
|
2343
|
+
case "pokeEnd": {
|
|
2344
|
+
this.handlePokeEnd(message);
|
|
2345
|
+
break;
|
|
2346
|
+
}
|
|
2347
|
+
case "pokePart": {
|
|
2348
|
+
this.handlePokePart(message);
|
|
2349
|
+
break;
|
|
2350
|
+
}
|
|
2351
|
+
case "pokeStart": {
|
|
2352
|
+
this.handlePokeStart(message);
|
|
2353
|
+
break;
|
|
2354
|
+
}
|
|
1926
2355
|
case "resume": {
|
|
1927
2356
|
this.handleResumeMessage(message);
|
|
1928
2357
|
break;
|
|
1929
2358
|
}
|
|
2359
|
+
case "settled": {
|
|
2360
|
+
this.handleSettledMessage(message);
|
|
2361
|
+
break;
|
|
2362
|
+
}
|
|
1930
2363
|
case "whisper": {
|
|
1931
2364
|
this.dispatchWhisper(message, shardKey);
|
|
1932
2365
|
break;
|
|
@@ -1948,12 +2381,79 @@ class LunoraClient {
|
|
|
1948
2381
|
}
|
|
1949
2382
|
const state = id === void 0 ? void 0 : this.subscriptions.getById(id);
|
|
1950
2383
|
if (state) {
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
2384
|
+
fanSubscriptionError(state.errorCallbacks, buildSubscriptionError(message));
|
|
2385
|
+
return;
|
|
2386
|
+
}
|
|
2387
|
+
const shapeState = id === void 0 ? void 0 : this.shapeSubscriptions.get(id);
|
|
2388
|
+
if (shapeState) {
|
|
2389
|
+
fanSubscriptionError(shapeState.errorCallbacks, buildSubscriptionError(message));
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
handlePokeStart(message) {
|
|
2393
|
+
if (this.pokeBuffers.size >= LunoraClient.MAX_POKE_BUFFERS) {
|
|
2394
|
+
const oldest = this.pokeBuffers.keys().next().value;
|
|
2395
|
+
if (oldest !== void 0) {
|
|
2396
|
+
this.pokeBuffers.delete(oldest);
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
this.pokeBuffers.set(message.pokeId, { baseCheckpoint: message.baseCheckpoint, epoch: message.epoch, lastMutationId: /* @__PURE__ */ new Map(), parts: /* @__PURE__ */ new Map() });
|
|
2400
|
+
}
|
|
2401
|
+
handlePokePart(message) {
|
|
2402
|
+
const buffer = this.pokeBuffers.get(message.pokeId);
|
|
2403
|
+
if (!buffer) {
|
|
2404
|
+
return;
|
|
2405
|
+
}
|
|
2406
|
+
const existing = buffer.parts.get(message.shapeId) ?? [];
|
|
2407
|
+
existing.push(...message.rowsPatch);
|
|
2408
|
+
buffer.parts.set(message.shapeId, existing);
|
|
2409
|
+
if (message.lastMutationId !== void 0) {
|
|
2410
|
+
buffer.lastMutationId.set(message.shapeId, message.lastMutationId);
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
handlePokeEnd(message) {
|
|
2414
|
+
const buffer = this.pokeBuffers.get(message.pokeId);
|
|
2415
|
+
if (!buffer) {
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
2418
|
+
this.pokeBuffers.delete(message.pokeId);
|
|
2419
|
+
for (const [shapeId, ops] of buffer.parts) {
|
|
2420
|
+
const state = this.shapeSubscriptions.get(shapeId);
|
|
2421
|
+
if (!state) {
|
|
2422
|
+
continue;
|
|
2423
|
+
}
|
|
2424
|
+
const epochForked = buffer.epoch !== void 0 && state.serverEpoch !== void 0 && buffer.epoch !== state.serverEpoch;
|
|
2425
|
+
const baseDiverged = buffer.baseCheckpoint !== void 0 && state.serverCursor !== void 0 && state.serverCursor !== buffer.baseCheckpoint;
|
|
2426
|
+
if (epochForked || baseDiverged) {
|
|
2427
|
+
state.rows.clear();
|
|
2428
|
+
state.serverCursor = void 0;
|
|
2429
|
+
state.serverEpoch = void 0;
|
|
2430
|
+
this.emitShapeRows(state);
|
|
2431
|
+
this.sendShapeSubscribeIfOpen(state);
|
|
2432
|
+
continue;
|
|
2433
|
+
}
|
|
2434
|
+
applyRowOpsToView(state.rows, ops);
|
|
2435
|
+
if (message.checkpoint !== void 0) {
|
|
2436
|
+
state.serverCursor = message.checkpoint;
|
|
2437
|
+
}
|
|
2438
|
+
if (message.epoch !== void 0) {
|
|
2439
|
+
state.serverEpoch = message.epoch;
|
|
2440
|
+
}
|
|
2441
|
+
const watermark = buffer.lastMutationId.get(shapeId);
|
|
2442
|
+
if (watermark !== void 0) {
|
|
2443
|
+
state.lastMutationId = watermark;
|
|
2444
|
+
}
|
|
2445
|
+
this.emitShapeRows(state);
|
|
2446
|
+
state.onCheckpoint?.({ checkpoint: state.serverCursor, mutationId: state.lastMutationId });
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
/** Materialize a shape's keyed view to an array and invoke its callbacks. */
|
|
2450
|
+
// eslint-disable-next-line class-methods-use-this -- a pure state→callback fan-out kept beside the shape-subscription pipeline it serves.
|
|
2451
|
+
emitShapeRows(state) {
|
|
2452
|
+
const rows = [...state.rows.values()];
|
|
2453
|
+
for (const shapeCallback of state.callbacks) {
|
|
2454
|
+
try {
|
|
2455
|
+
shapeCallback(rows);
|
|
2456
|
+
} catch {
|
|
1957
2457
|
}
|
|
1958
2458
|
}
|
|
1959
2459
|
}
|
|
@@ -1964,8 +2464,7 @@ class LunoraClient {
|
|
|
1964
2464
|
return;
|
|
1965
2465
|
}
|
|
1966
2466
|
const payload = this.resolveDataPayload(message, state);
|
|
1967
|
-
state.
|
|
1968
|
-
state.serverVersion += 1;
|
|
2467
|
+
state.serverBase = payload;
|
|
1969
2468
|
if (message.cursor !== void 0) {
|
|
1970
2469
|
state.serverCursor = message.cursor;
|
|
1971
2470
|
}
|
|
@@ -1973,12 +2472,8 @@ class LunoraClient {
|
|
|
1973
2472
|
state.serverEpoch = message.epoch;
|
|
1974
2473
|
}
|
|
1975
2474
|
this.persistQueryValue(state);
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
callback(payload);
|
|
1979
|
-
} catch {
|
|
1980
|
-
}
|
|
1981
|
-
}
|
|
2475
|
+
dropConfirmedLayers(state, state.serverCursor);
|
|
2476
|
+
notifySubscription(state, state.optimisticLayers.length === 0 ? payload : foldOptimistic(payload, state.optimisticLayers));
|
|
1982
2477
|
}
|
|
1983
2478
|
/**
|
|
1984
2479
|
* Handle a `resume` frame (Pillar 1b): the server proved nothing the
|
|
@@ -1993,15 +2488,51 @@ class LunoraClient {
|
|
|
1993
2488
|
if (!state) {
|
|
1994
2489
|
return;
|
|
1995
2490
|
}
|
|
2491
|
+
this.ackAndAdvanceCursor(state, message.cursor, message.epoch);
|
|
2492
|
+
}
|
|
2493
|
+
/**
|
|
2494
|
+
* Handle a `settled` frame: a write touched one of this subscription's read
|
|
2495
|
+
* tables but produced a byte-identical result, so the server suppressed the
|
|
2496
|
+
* data frame. Like {@link handleResumeMessage} the value didn't change — we
|
|
2497
|
+
* advance the resume position and re-persist — but we ALSO surface the echoed
|
|
2498
|
+
* custom-mutator watermark via `onCheckpoint` so a `@lunora/db` list
|
|
2499
|
+
* collection drops the optimistic overlay for the confirmed write (otherwise
|
|
2500
|
+
* its checkpoint gate, fed only by data frames, would hang forever). Sent
|
|
2501
|
+
* only to custom-mutator clients; plain `useQuery` subscribers leave
|
|
2502
|
+
* `onCheckpoint` unset and this is a near no-op.
|
|
2503
|
+
*/
|
|
2504
|
+
handleSettledMessage(message) {
|
|
2505
|
+
const state = this.subscriptions.getById(message.id);
|
|
2506
|
+
if (!state) {
|
|
2507
|
+
return;
|
|
2508
|
+
}
|
|
2509
|
+
this.ackAndAdvanceCursor(state, message.cursor, message.epoch);
|
|
2510
|
+
if (message.lastMutationId !== void 0) {
|
|
2511
|
+
state.lastMutationId = message.lastMutationId;
|
|
2512
|
+
}
|
|
2513
|
+
for (const onCheckpoint of state.checkpointCallbacks) {
|
|
2514
|
+
onCheckpoint({ checkpoint: state.serverCursor, mutationId: state.lastMutationId });
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
/**
|
|
2518
|
+
* Mark `state` acked and, when the frame carries a newer cursor/epoch than
|
|
2519
|
+
* the cached position, advance the resume watermark and re-persist. Shared by
|
|
2520
|
+
* the `resume` and `settled` frame handlers — both acknowledge "nothing the
|
|
2521
|
+
* client must re-render changed, but the resume position may have moved".
|
|
2522
|
+
*/
|
|
2523
|
+
ackAndAdvanceCursor(state, cursor, epoch) {
|
|
1996
2524
|
state.acked = true;
|
|
1997
|
-
if (
|
|
1998
|
-
if (
|
|
1999
|
-
state.serverCursor =
|
|
2525
|
+
if (cursor !== void 0 && cursor !== state.serverCursor || epoch !== void 0 && epoch !== state.serverEpoch) {
|
|
2526
|
+
if (cursor !== void 0) {
|
|
2527
|
+
state.serverCursor = cursor;
|
|
2000
2528
|
}
|
|
2001
|
-
if (
|
|
2002
|
-
state.serverEpoch =
|
|
2529
|
+
if (epoch !== void 0) {
|
|
2530
|
+
state.serverEpoch = epoch;
|
|
2003
2531
|
}
|
|
2004
2532
|
this.persistQueryValue(state);
|
|
2533
|
+
if (dropConfirmedLayers(state, state.serverCursor)) {
|
|
2534
|
+
notifySubscription(state, foldOptimistic(state.serverBase, state.optimisticLayers));
|
|
2535
|
+
}
|
|
2005
2536
|
}
|
|
2006
2537
|
}
|
|
2007
2538
|
/**
|
|
@@ -2022,8 +2553,8 @@ class LunoraClient {
|
|
|
2022
2553
|
return message.data;
|
|
2023
2554
|
}
|
|
2024
2555
|
const { delta } = message;
|
|
2025
|
-
if (isMutationDelta(delta) && state.
|
|
2026
|
-
const merged = applyDelta(state.
|
|
2556
|
+
if (isMutationDelta(delta) && state.serverBase !== void 0) {
|
|
2557
|
+
const merged = applyDelta(state.serverBase, delta);
|
|
2027
2558
|
if (merged !== void 0) {
|
|
2028
2559
|
return merged;
|
|
2029
2560
|
}
|
|
@@ -2045,12 +2576,7 @@ class LunoraClient {
|
|
|
2045
2576
|
}
|
|
2046
2577
|
/** Notify every {@link onTokenExpired} listener (best-effort, listener throws swallowed). */
|
|
2047
2578
|
notifyTokenExpired() {
|
|
2048
|
-
|
|
2049
|
-
try {
|
|
2050
|
-
listener();
|
|
2051
|
-
} catch {
|
|
2052
|
-
}
|
|
2053
|
-
}
|
|
2579
|
+
this.tokenExpiredListeners.emit();
|
|
2054
2580
|
}
|
|
2055
2581
|
handleCompleteMessage(id) {
|
|
2056
2582
|
const stream = this.streams.get(id);
|
|
@@ -2081,6 +2607,9 @@ class LunoraClient {
|
|
|
2081
2607
|
// `null` is the distinct "signed out" identity (separate from `undefined`,
|
|
2082
2608
|
// which means "not stamped / hydrated"); the two must not be conflated.
|
|
2083
2609
|
identityFingerprint() {
|
|
2610
|
+
if (this.authSubject !== void 0) {
|
|
2611
|
+
return this.authSubject === null ? null : `subj:${this.authSubject}`;
|
|
2612
|
+
}
|
|
2084
2613
|
const token = this.authToken;
|
|
2085
2614
|
if (token === null) {
|
|
2086
2615
|
return null;
|
|
@@ -2107,9 +2636,25 @@ class LunoraClient {
|
|
|
2107
2636
|
const error = new Error("offline mutation discarded: auth identity changed before replay");
|
|
2108
2637
|
error.code = "OFFLINE_IDENTITY_CHANGED";
|
|
2109
2638
|
item.reject(error);
|
|
2639
|
+
this.emitItemSettled(item, "rejected", error);
|
|
2110
2640
|
}
|
|
2111
2641
|
this.clearQueryCacheForIdentityChange();
|
|
2112
2642
|
}
|
|
2643
|
+
/**
|
|
2644
|
+
* Migrate every live identity stamp from `from` to `to` — used when the auth
|
|
2645
|
+
* identity label changes but the underlying credential (token) does NOT, e.g.
|
|
2646
|
+
* the user id resolves a tick after the token was set. The in-memory
|
|
2647
|
+
* `queuedIdentities` map is the flush-time source of truth, so re-stamping it
|
|
2648
|
+
* keeps the in-flight writes replayable under the new (more stable) identity
|
|
2649
|
+
* instead of the flush guard discarding them as a mismatch.
|
|
2650
|
+
*/
|
|
2651
|
+
restampQueuedIdentity(from, to) {
|
|
2652
|
+
for (const [id, stamp] of this.queuedIdentities) {
|
|
2653
|
+
if (stamp === from) {
|
|
2654
|
+
this.queuedIdentities.set(id, to);
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2113
2658
|
/**
|
|
2114
2659
|
* Drop the durable read cache on an identity change so a cached value stamped
|
|
2115
2660
|
* under the previous identity can never hydrate into a new session. Clears
|
|
@@ -2142,17 +2687,28 @@ class LunoraClient {
|
|
|
2142
2687
|
const error = new Error("offline mutation skipped: auth identity changed before replay");
|
|
2143
2688
|
error.code = "OFFLINE_IDENTITY_CHANGED";
|
|
2144
2689
|
item.reject(error);
|
|
2690
|
+
this.emitItemSettled(item, "rejected", error);
|
|
2145
2691
|
continue;
|
|
2146
2692
|
}
|
|
2147
2693
|
this.queuedIdentities.delete(item.id ?? "");
|
|
2148
2694
|
try {
|
|
2149
|
-
|
|
2695
|
+
let commitCursor;
|
|
2696
|
+
const value = await this.rpc(item.functionPath, item.args, item.shardKey, {
|
|
2697
|
+
captureBookmark: true,
|
|
2698
|
+
mutationId: item.id,
|
|
2699
|
+
onCommitCursor: (cursor) => {
|
|
2700
|
+
commitCursor = cursor;
|
|
2701
|
+
}
|
|
2702
|
+
});
|
|
2150
2703
|
this.unpersist(item.id);
|
|
2704
|
+
item.onCommit?.(commitCursor);
|
|
2151
2705
|
item.resolve(value);
|
|
2706
|
+
this.emitItemSettled(item, "committed");
|
|
2152
2707
|
} catch (error) {
|
|
2153
2708
|
if (error.code !== void 0) {
|
|
2154
2709
|
this.unpersist(item.id);
|
|
2155
2710
|
item.reject(error);
|
|
2711
|
+
this.emitItemSettled(item, "rejected", error);
|
|
2156
2712
|
continue;
|
|
2157
2713
|
}
|
|
2158
2714
|
this.offlineQueue.requeue(drained.slice(index));
|