@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.
Files changed (32) hide show
  1. package/README.md +2 -0
  2. package/__assets__/package-og.svg +1 -1
  3. package/dist/auth/index.d.mts +1 -1
  4. package/dist/auth/index.d.ts +1 -1
  5. package/dist/index.d.mts +109 -5
  6. package/dist/index.d.ts +109 -5
  7. package/dist/index.mjs +8 -7
  8. package/dist/packem_shared/{LunoraClient-B00f7VUM.mjs → LunoraClient-CgZ6FhKP.mjs} +741 -185
  9. package/dist/packem_shared/OfflineQueue-BI0FNNvc.mjs +1 -0
  10. package/dist/packem_shared/SubscriptionRegistry-Dn-7k7eo.mjs +1 -0
  11. package/dist/packem_shared/createLocalStore-IOur0jHF.mjs +1 -0
  12. package/dist/packem_shared/createMutatorRunner-BETvCd0p.mjs +31 -0
  13. package/dist/packem_shared/{createServerClient-DyFIHnWZ.mjs → createServerClient-BxkNcRlR.mjs} +1 -1
  14. package/dist/packem_shared/local-store-BNgN3Dw3.mjs +111 -0
  15. package/dist/packem_shared/{lunora-client.d-DGvyuJ_p.d.mts → lunora-client.d-B5vWSgvD.d.mts} +637 -38
  16. package/dist/packem_shared/{lunora-client.d-DGvyuJ_p.d.ts → lunora-client.d-B5vWSgvD.d.ts} +637 -38
  17. package/dist/packem_shared/{OfflineQueue-D5p_QgF_.mjs → offline-queue-7Wc4onA0.mjs} +42 -5
  18. package/dist/packem_shared/{preload.d-dSaRMuhL.d.mts → preload.d-3XJD-2hM.d.mts} +1 -1
  19. package/dist/packem_shared/{preload.d-BoDmFqSG.d.ts → preload.d-CKZR675M.d.ts} +1 -1
  20. package/dist/packem_shared/subscription-C1Jy7HiF.mjs +55 -0
  21. package/dist/query/index.d.mts +2 -2
  22. package/dist/query/index.d.ts +2 -2
  23. package/dist/ssr/index.d.mts +3 -3
  24. package/dist/ssr/index.d.ts +3 -3
  25. package/dist/ssr/index.mjs +2 -2
  26. package/package.json +2 -2
  27. package/dist/packem_shared/SubscriptionRegistry-B-Qx_Gux.mjs +0 -26
  28. package/dist/packem_shared/createLocalStore-DSUfoLqY.mjs +0 -36
  29. /package/dist/packem_shared/{createStream-BDkqO5PW.mjs → DEFAULT_MAX_BUFFER-BDkqO5PW.mjs} +0 -0
  30. /package/dist/packem_shared/{createIndexedDbPersistence-CW82inU5.mjs → createInMemoryPersistence-CW82inU5.mjs} +0 -0
  31. /package/dist/packem_shared/{createIndexedDbQueryCache-B1PQ9Twl.mjs → createInMemoryQueryCache-B1PQ9Twl.mjs} +0 -0
  32. /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 './createLocalStore-DSUfoLqY.mjs';
4
- import { OfflineQueue, nextId, reportPersistenceError } from './OfflineQueue-D5p_QgF_.mjs';
5
- import { queryCacheKey } from './createIndexedDbQueryCache-B1PQ9Twl.mjs';
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 './createStream-BDkqO5PW.mjs';
8
- import { SubscriptionRegistry } from './SubscriptionRegistry-B-Qx_Gux.mjs';
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 = /* @__PURE__ */ new Set();
278
+ authTokenListeners = new Listeners();
247
279
  /** Subscribers to aggregate connection-status changes (see `onConnectionStatus`). */
248
- statusListeners = /* @__PURE__ */ new Set();
280
+ statusListeners = new Listeners();
249
281
  /** Subscribers notified when the server drops a socket for an expired token (see `onTokenExpired`). */
250
- tokenExpiredListeners = /* @__PURE__ */ new Set();
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, options.persistence);
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.hydratePersistedQueue().catch(() => void 0);
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
- if (this.authToken === token) {
308
- return;
309
- }
372
+ setAuthToken(token, subject) {
373
+ const tokenChanged = this.authToken !== token;
374
+ const previousIdentity = this.identityFingerprint();
310
375
  this.authToken = token;
311
- this.rejectQueuedForIdentityChange();
312
- for (const listener of this.authTokenListeners) {
313
- try {
314
- listener(token);
315
- } catch {
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
- this.statusListeners.delete(listener);
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(function_.__lunoraRef, argsRecord, options.shardKey, options.optimistic);
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
- const issuingIdentity = this.identityFingerprint();
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
- return await this.rpc(function_.__lunoraRef, argsRecord, options.shardKey, { captureBookmark: true, mutationId });
625
- } catch (error) {
626
- for (let index = optimisticRollbacks.length - 1; index >= 0; index -= 1) {
627
- optimisticRollbacks[index]?.();
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); otherwise the proxy
743
- * responds 501 and this rejects. `name` is the deployed workflow name.
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 { instances: body.instances ?? [], page: body.page ?? 1, perPage: body.perPage ?? options.perPage ?? 0, totalCount: body.totalCount };
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
- if (!this.queryCache || state.lastValue === void 0) {
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: state.lastValue,
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
- for (const listener of this.statusListeners) {
1508
- try {
1509
- listener(next);
1510
- } catch {
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 every subscription that matches the
1516
- * mutation's function ref, shard key, and args, returning the rollback
1517
- * callbacks to invoke if the mutation later fails. Scoping to the same
1518
- * (fn, shardKey, args) keeps one user's mutation from clobbering another
1519
- * subscriber's value on the same function (e.g. two users on different rooms).
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 optimisticRollbacks = [];
1865
+ const confirms = [];
1866
+ const rollbacks = [];
1523
1867
  if (!optimistic) {
1524
- return optimisticRollbacks;
1868
+ return { confirms, rollbacks };
1525
1869
  }
1526
- const mutationArgsKey = stableStringify(argsRecord);
1527
- for (const state of this.subscriptions.all()) {
1528
- if (state.fn.__lunoraRef !== functionRef || state.shardKey !== mutationShardKey || state.argsKey !== mutationArgsKey) {
1529
- continue;
1530
- }
1531
- const rollback = applyOptimisticToState(state, optimistic);
1532
- if (rollback) {
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 optimisticRollbacks;
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, appending each `setQuery` write's
1541
- * rollback to `optimisticRollbacks` (the same LIFO list the legacy path uses,
1542
- * unwound on settle/error). A throwing callback unwinds its own partial
1543
- * writes LIFO over just the rollbacks it producedand is swallowed, so a
1544
- * buggy optimistic update can never fail the mutation or leave a partial
1545
- * patch live, mirroring the legacy transform's throw handling.
1546
- */
1547
- applyOptimisticUpdate(optimisticUpdate, args, shardKey, optimisticRollbacks) {
1548
- const { rollbacks, store } = createLocalStore(this.subscriptions, shardKey, writeOptimisticToState, stableStringify);
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 cursorits `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: "unsubscribe" });
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
- const error = buildSubscriptionError(message);
1952
- for (const errorCallback of state.errorCallbacks) {
1953
- try {
1954
- errorCallback(error);
1955
- } catch {
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.lastValue = payload;
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
- for (const callback of state.callbacks) {
1977
- try {
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 (message.cursor !== void 0 && message.cursor !== state.serverCursor || message.epoch !== void 0 && message.epoch !== state.serverEpoch) {
1998
- if (message.cursor !== void 0) {
1999
- state.serverCursor = message.cursor;
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 (message.epoch !== void 0) {
2002
- state.serverEpoch = message.epoch;
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.lastValue !== void 0) {
2026
- const merged = applyDelta(state.lastValue, delta);
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
- for (const listener of this.tokenExpiredListeners) {
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
- const value = await this.rpc(item.functionPath, item.args, item.shardKey, { captureBookmark: true, mutationId: item.id });
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));