@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.
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 +136 -5
  6. package/dist/index.d.ts +136 -5
  7. package/dist/index.mjs +8 -7
  8. package/dist/packem_shared/{LunoraClient-B00f7VUM.mjs → LunoraClient-6J-4BGWK.mjs} +744 -187
  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/{createIndexedDbPersistence-CW82inU5.mjs → createInMemoryPersistence-DlFjWtOm.mjs} +13 -1
  12. package/dist/packem_shared/{createIndexedDbQueryCache-B1PQ9Twl.mjs → createInMemoryQueryCache-4AkTV38-.mjs} +13 -1
  13. package/dist/packem_shared/createLocalStore-IOur0jHF.mjs +1 -0
  14. package/dist/packem_shared/createMutatorRunner-BETvCd0p.mjs +31 -0
  15. package/dist/packem_shared/{createServerClient-DyFIHnWZ.mjs → createServerClient-CtklXrs2.mjs} +1 -1
  16. package/dist/packem_shared/local-store-BNgN3Dw3.mjs +111 -0
  17. package/dist/packem_shared/{lunora-client.d-DGvyuJ_p.d.mts → lunora-client.d-Dm1BYq19.d.mts} +652 -43
  18. package/dist/packem_shared/{lunora-client.d-DGvyuJ_p.d.ts → lunora-client.d-Dm1BYq19.d.ts} +652 -43
  19. package/dist/packem_shared/{OfflineQueue-D5p_QgF_.mjs → offline-queue-7Wc4onA0.mjs} +42 -5
  20. package/dist/packem_shared/{preload.d-dSaRMuhL.d.mts → preload.d-BfWzGJWV.d.mts} +1 -1
  21. package/dist/packem_shared/{preload.d-BoDmFqSG.d.ts → preload.d-Sp_Ef-_u.d.ts} +1 -1
  22. package/dist/packem_shared/subscription-C1Jy7HiF.mjs +55 -0
  23. package/dist/query/index.d.mts +2 -2
  24. package/dist/query/index.d.ts +2 -2
  25. package/dist/ssr/index.d.mts +3 -3
  26. package/dist/ssr/index.d.ts +3 -3
  27. package/dist/ssr/index.mjs +2 -2
  28. package/package.json +2 -2
  29. package/dist/packem_shared/SubscriptionRegistry-B-Qx_Gux.mjs +0 -26
  30. package/dist/packem_shared/createLocalStore-DSUfoLqY.mjs +0 -36
  31. /package/dist/packem_shared/{createStream-BDkqO5PW.mjs → DEFAULT_MAX_BUFFER-BDkqO5PW.mjs} +0 -0
  32. /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 './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 { 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 './createStream-BDkqO5PW.mjs';
8
- import { SubscriptionRegistry } from './SubscriptionRegistry-B-Qx_Gux.mjs';
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 = /* @__PURE__ */ new Set();
279
+ authTokenListeners = new Listeners();
247
280
  /** Subscribers to aggregate connection-status changes (see `onConnectionStatus`). */
248
- statusListeners = /* @__PURE__ */ new Set();
281
+ statusListeners = new Listeners();
249
282
  /** Subscribers notified when the server drops a socket for an expired token (see `onTokenExpired`). */
250
- tokenExpiredListeners = /* @__PURE__ */ new Set();
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.queryCache = options.queryCache === false ? void 0 : options.queryCache;
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, options.persistence);
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.hydratePersistedQueue().catch(() => void 0);
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
- if (this.authToken === token) {
308
- return;
309
- }
373
+ setAuthToken(token, subject) {
374
+ const tokenChanged = this.authToken !== token;
375
+ const previousIdentity = this.identityFingerprint();
310
376
  this.authToken = token;
311
- this.rejectQueuedForIdentityChange();
312
- for (const listener of this.authTokenListeners) {
313
- try {
314
- listener(token);
315
- } catch {
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
- this.statusListeners.delete(listener);
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(function_.__lunoraRef, argsRecord, options.shardKey, options.optimistic);
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
- 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
- });
760
+ return this.enqueueOfflineMutation(function_, argsRecord, options.shardKey, mutationId, optimisticRollbacks, optimisticConfirms);
622
761
  }
623
762
  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]?.();
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); otherwise the proxy
743
- * responds 501 and this rejects. `name` is the deployed workflow name.
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 { instances: body.instances ?? [], page: body.page ?? 1, perPage: body.perPage ?? options.perPage ?? 0, totalCount: body.totalCount };
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
- if (!this.queryCache || state.lastValue === void 0) {
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: state.lastValue,
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
- for (const listener of this.statusListeners) {
1508
- try {
1509
- listener(next);
1510
- } catch {
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 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).
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 optimisticRollbacks = [];
1866
+ const confirms = [];
1867
+ const rollbacks = [];
1523
1868
  if (!optimistic) {
1524
- return optimisticRollbacks;
1869
+ return { confirms, rollbacks };
1525
1870
  }
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);
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 optimisticRollbacks;
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, 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);
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 cursorits `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: "unsubscribe" });
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
- const error = buildSubscriptionError(message);
1952
- for (const errorCallback of state.errorCallbacks) {
1953
- try {
1954
- errorCallback(error);
1955
- } catch {
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.lastValue = payload;
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
- for (const callback of state.callbacks) {
1977
- try {
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 (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;
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 (message.epoch !== void 0) {
2002
- state.serverEpoch = message.epoch;
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.lastValue !== void 0) {
2026
- const merged = applyDelta(state.lastValue, delta);
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
- for (const listener of this.tokenExpiredListeners) {
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
- const value = await this.rpc(item.functionPath, item.args, item.shardKey, { captureBookmark: true, mutationId: item.id });
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));