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