@lunora/client 1.0.0-alpha.20 → 1.0.0-alpha.22

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 (34) hide show
  1. package/dist/auth/index.d.mts +1 -1
  2. package/dist/auth/index.d.ts +1 -1
  3. package/dist/index.d.mts +212 -4
  4. package/dist/index.d.ts +212 -4
  5. package/dist/index.mjs +12 -7
  6. package/dist/packem_shared/ClientServiceWorker-C3PAFwy0.mjs +100 -0
  7. package/dist/packem_shared/{LunoraClient-hrmLXEHd.mjs → LunoraClient-Clb118SU.mjs} +339 -33
  8. package/dist/packem_shared/{OfflineQueue-GGYJRmhF.mjs → OfflineQueue-B4HUF7rt.mjs} +1 -1
  9. package/dist/packem_shared/SubscriptionRegistry-D4jfIzZu.mjs +31 -0
  10. package/dist/packem_shared/TabCoordinator-BwRR8H06.mjs +222 -0
  11. package/dist/packem_shared/{applyDelta-4jFGTPA3.mjs → applyDelta-CRKZ1PBt.mjs} +21 -1
  12. package/dist/packem_shared/createClientQuery-CQ51bWAE.mjs +71 -0
  13. package/dist/packem_shared/{createInMemoryPersistence-Ds7z8n8d.mjs → createInMemoryPersistence-DZ2VHWgm.mjs} +7 -47
  14. package/dist/packem_shared/{createInMemoryQueryCache-iWtKPrid.mjs → createInMemoryQueryCache-DiaGZkA2.mjs} +7 -47
  15. package/dist/packem_shared/createLocalStore-BDbbkoXw.mjs +2 -0
  16. package/dist/packem_shared/createReply-lI4tVS2w.mjs +36 -0
  17. package/dist/packem_shared/{createServerClient-CKKZrXLc.mjs → createServerClient-Dxemst5C.mjs} +1 -1
  18. package/dist/packem_shared/createSnapshotPrecondition-CBwnVz6r.mjs +18 -0
  19. package/dist/packem_shared/idb-utility-DrSVX43Q.mjs +48 -0
  20. package/dist/packem_shared/{local-store-BNgN3Dw3.mjs → local-store-DtcIW4c0.mjs} +7 -12
  21. package/dist/packem_shared/{lunora-client.d-DVdVtJV8.d.mts → lunora-client.d-pw-9sLl0.d.mts} +212 -2
  22. package/dist/packem_shared/{lunora-client.d-DVdVtJV8.d.ts → lunora-client.d-pw-9sLl0.d.ts} +212 -2
  23. package/dist/packem_shared/{offline-queue-B9vfdSqp.mjs → offline-queue-CF4_Co5k.mjs} +29 -0
  24. package/dist/packem_shared/{preload.d-BXFvxpiv.d.mts → preload.d-6ME5ubgq.d.mts} +1 -1
  25. package/dist/packem_shared/{preload.d-Bb69VRgE.d.ts → preload.d-BkQr-3Vh.d.ts} +1 -1
  26. package/dist/packem_shared/{subscription-DoyO04-2.mjs → stable-key-wv6eP48B.mjs} +1 -26
  27. package/dist/query/index.d.mts +2 -2
  28. package/dist/query/index.d.ts +2 -2
  29. package/dist/ssr/index.d.mts +3 -3
  30. package/dist/ssr/index.d.ts +3 -3
  31. package/dist/ssr/index.mjs +1 -1
  32. package/package.json +2 -2
  33. package/dist/packem_shared/SubscriptionRegistry-Cxr70og-.mjs +0 -1
  34. package/dist/packem_shared/createLocalStore-IOur0jHF.mjs +0 -1
@@ -1,16 +1,46 @@
1
1
  import { LunoraError } from '@lunora/errors';
2
- import { S as SubscriptionRegistry, s as stableStringify } from './subscription-DoyO04-2.mjs';
2
+ import { s as stableStringify } from './stable-key-wv6eP48B.mjs';
3
3
  import createInMemoryBookmarkStorage from './createInMemoryBookmarkStorage-BoN7a7TH.mjs';
4
- import { isMutationDelta, applyDelta } from './applyDelta-4jFGTPA3.mjs';
5
- import { a as applyOptimisticLayer, d as dropConfirmedLayers, n as notifySubscription, f as foldOptimistic, c as createLocalStore } from './local-store-BNgN3Dw3.mjs';
6
- import { O as OfflineQueue, n as nextId, i as isStaleVersion, r as reportPersistenceError } from './offline-queue-B9vfdSqp.mjs';
7
- import { resolvePersistenceAdapter } from './createInMemoryPersistence-Ds7z8n8d.mjs';
8
- import { resolveQueryCacheAdapter, queryCacheKey } from './createInMemoryQueryCache-iWtKPrid.mjs';
4
+ import { ClientQueryStore } from './createClientQuery-CQ51bWAE.mjs';
5
+ import { TabCoordinator } from './TabCoordinator-BwRR8H06.mjs';
6
+ import { isMutationDelta, applyDelta } from './applyDelta-CRKZ1PBt.mjs';
7
+ import { f as foldOptimistic, a as applyOptimisticLayer, d as dropConfirmedLayers, n as notifySubscription, c as createLocalStore } from './local-store-DtcIW4c0.mjs';
8
+ import { O as OfflineQueue, n as nextId, i as isStaleVersion, r as reportPersistenceError } from './offline-queue-CF4_Co5k.mjs';
9
+ import { resolvePersistenceAdapter } from './createInMemoryPersistence-DZ2VHWgm.mjs';
10
+ import { resolveQueryCacheAdapter, queryCacheKey } from './createInMemoryQueryCache-DiaGZkA2.mjs';
9
11
  import { createReconnect } from './createReconnect-Di_-oHH7.mjs';
10
12
  import { createStream } from './DEFAULT_MAX_BUFFER-7hFnzNk9.mjs';
13
+ import { SubscriptionRegistry } from './SubscriptionRegistry-D4jfIzZu.mjs';
11
14
 
12
15
  const MAX_BATCH_ENTRIES = 500;
13
16
 
17
+ const evictOldestEntry = (map, capacity) => {
18
+ if (map.size < capacity) {
19
+ return;
20
+ }
21
+ const oldest = map.keys().next().value;
22
+ if (oldest !== void 0) {
23
+ map.delete(oldest);
24
+ }
25
+ };
26
+
27
+ const toBase64 = (bytes) => {
28
+ let binary = "";
29
+ const chunk = 32768;
30
+ for (let index = 0; index < bytes.length; index += chunk) {
31
+ binary += String.fromCharCode(...bytes.subarray(index, index + chunk));
32
+ }
33
+ return btoa(binary);
34
+ };
35
+ const fromBase64 = (base64) => {
36
+ const binary = atob(base64);
37
+ const bytes = new Uint8Array(binary.length);
38
+ for (let index = 0; index < binary.length; index += 1) {
39
+ bytes[index] = binary.codePointAt(index) ?? 0;
40
+ }
41
+ return bytes;
42
+ };
43
+
14
44
  const TAG = "$lunora.wire$";
15
45
  const MAX_DEPTH = 64;
16
46
  const MAX_BIGINT_DIGITS = 1024;
@@ -37,22 +67,6 @@ const ERROR_CTORS = {
37
67
  TypeError,
38
68
  URIError
39
69
  };
40
- const toBase64 = (bytes) => {
41
- let binary = "";
42
- const chunk = 32768;
43
- for (let index = 0; index < bytes.length; index += chunk) {
44
- binary += String.fromCharCode(...bytes.subarray(index, index + chunk));
45
- }
46
- return btoa(binary);
47
- };
48
- const fromBase64 = (base64) => {
49
- const binary = atob(base64);
50
- const bytes = new Uint8Array(binary.length);
51
- for (let index = 0; index < binary.length; index += 1) {
52
- bytes[index] = binary.codePointAt(index) ?? 0;
53
- }
54
- return bytes;
55
- };
56
70
  const encodeWire = (value, depth = 0) => {
57
71
  if (depth > MAX_DEPTH) {
58
72
  throw new RangeError(`wire-codec: value nesting exceeds the ${MAX_DEPTH}-level limit`);
@@ -453,11 +467,25 @@ const demuxBatchResults = (rawResults, count) => {
453
467
  return slots.map((slot) => slot ?? { error: new Error("batch call returned no result"), ok: false });
454
468
  };
455
469
  const TRANSIENT_BATCH_ERROR_CODES = /* @__PURE__ */ new Set(["SHARD_ERROR", "SHARD_UNAVAILABLE"]);
470
+ const RESOLVED_PROMISE = Promise.resolve();
456
471
  class LunoraClient {
457
472
  /** 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. */
458
473
  static MAX_POKE_BUFFERS = 256;
474
+ /**
475
+ * Create a typed {@link ClientQueryRef}. Convenience wrapper around
476
+ * {@link createClientQuery} so you don't need a separate import.
477
+ * @example
478
+ * ```ts
479
+ * const sidebarOpen = LunoraClient.createClientQuery("sidebarOpen", true);
480
+ * ```
481
+ */
482
+ static createClientQuery(key, defaultValue) {
483
+ return { defaultValue, key };
484
+ }
459
485
  url;
460
486
  wsUrl;
487
+ /** Local reactive store for {@link ClientQueryRef} values — no server round-trip. Private; reach it via `getClientQuery` / `setClientQuery` / `subscribeClientQuery`. */
488
+ clientQueryStore;
461
489
  wsToken;
462
490
  /** Better-auth base path (trailing slash stripped) for the `get-session` lookup. */
463
491
  authBasePath;
@@ -478,6 +506,20 @@ class LunoraClient {
478
506
  outbox;
479
507
  /** Stable per-client id stamped onto every `OutboxMutation` (custom-mutator watermark). */
480
508
  clientId;
509
+ /**
510
+ * `true` when the constructor's hydration microtask has finished loading the
511
+ * durable read cache (Pillar 2) into `hydratedQueryCache`. Signals that
512
+ * the cache is ready for synchronous `peekHydratedQuery` reads.
513
+ */
514
+ readyResolved = false;
515
+ /** Resolvers for `whenReady()` — called once hydration completes. */
516
+ readyResolve;
517
+ /**
518
+ * Promise that resolves once the durable read cache has been loaded. When
519
+ * `hydrateOnStart` is not set or no query cache is configured, resolves
520
+ * immediately (the constructor creates an already-resolved promise).
521
+ */
522
+ readyPromise;
481
523
  /**
482
524
  * Highest custom-mutator watermark the server has echoed for this client,
483
525
  * keyed by shard bucket (`shardKey ?? ""`) since the DO tracks one
@@ -511,6 +553,12 @@ class LunoraClient {
511
553
  pendingCacheWrites = /* @__PURE__ */ new Map();
512
554
  cacheFlushTimer;
513
555
  subscriptions = new SubscriptionRegistry();
556
+ /**
557
+ * Cross-tab coordinator; created only when `crossTabSync: true`. When the
558
+ * client is not the elected leader, all WebSocket operations are skipped.
559
+ * Not `readonly` — `close()` clears it (mirrors `outboxLeaderRelease`).
560
+ */
561
+ tabCoordinator;
514
562
  /** One {@link ShardConnection} per shard key (keyed by `shardKey ?? ""`). */
515
563
  connections = /* @__PURE__ */ new Map();
516
564
  /** Default `connect`-envelope context applied to a shard with no explicit override. */
@@ -606,6 +654,59 @@ class LunoraClient {
606
654
  this.persistenceVersion = options.persistenceVersion;
607
655
  this.queryCache = resolveQueryCacheAdapter(options.queryCache);
608
656
  this.onPersistenceError = options.offlineQueue?.onPersistenceError;
657
+ this.clientQueryStore = new ClientQueryStore();
658
+ if (options.crossTabSync) {
659
+ this.tabCoordinator = new TabCoordinator({
660
+ onBecomeLeader: () => {
661
+ for (const state of this.subscriptions.all()) {
662
+ this.ensureSocket(state.shardKey);
663
+ this.sendSubscribeIfOpen(state);
664
+ }
665
+ },
666
+ onStopBeingLeader: () => {
667
+ for (const [key, conn] of this.connections) {
668
+ conn.socket?.close();
669
+ this.connections.delete(key);
670
+ }
671
+ },
672
+ onSubscriptionData: (key, data) => {
673
+ const state = this.subscriptions.get(key);
674
+ if (state) {
675
+ state.serverBase = data;
676
+ const folded = state.optimisticLayers.length === 0 ? data : foldOptimistic(data, state.optimisticLayers);
677
+ state.lastValue = folded;
678
+ for (const callback of state.callbacks) {
679
+ try {
680
+ callback(folded);
681
+ } catch {
682
+ }
683
+ }
684
+ }
685
+ },
686
+ onSubscriptionError: (key, error) => {
687
+ const state = this.subscriptions.get(key);
688
+ if (state) {
689
+ for (const callback of state.errorCallbacks) {
690
+ try {
691
+ callback(error);
692
+ } catch {
693
+ }
694
+ }
695
+ }
696
+ }
697
+ });
698
+ this.tabCoordinator.start();
699
+ } else {
700
+ this.tabCoordinator = void 0;
701
+ }
702
+ if (options.hydrateOnStart && this.queryCache) {
703
+ this.readyPromise = new Promise((resolve) => {
704
+ this.readyResolve = resolve;
705
+ });
706
+ } else {
707
+ this.readyResolved = true;
708
+ this.readyPromise = RESOLVED_PROMISE;
709
+ }
609
710
  this.offlineQueue = new OfflineQueue(options.offlineQueue, {
610
711
  onEvict: (entry, error) => {
611
712
  this.emitItemSettled(entry, "rejected", error);
@@ -625,7 +726,17 @@ class LunoraClient {
625
726
  }
626
727
  if (this.queryCache) {
627
728
  queueMicrotask(() => {
628
- this.hydrateQueryCache().catch(() => void 0);
729
+ const hydration = async () => {
730
+ try {
731
+ await this.hydrateQueryCache();
732
+ } catch {
733
+ } finally {
734
+ this.readyResolved = true;
735
+ this.readyResolve?.();
736
+ }
737
+ };
738
+ hydration().catch(() => {
739
+ });
629
740
  });
630
741
  }
631
742
  }
@@ -998,6 +1109,135 @@ class LunoraClient {
998
1109
  onMutationSettled(listener) {
999
1110
  return this.mutationSettledListeners.add(listener);
1000
1111
  }
1112
+ // --- Client Query (local state) ----------------------------------------
1113
+ /**
1114
+ * Read the current value for a {@link ClientQueryRef}. Returns
1115
+ * `ref.defaultValue` when no value has been explicitly set.
1116
+ */
1117
+ getClientQuery(ref) {
1118
+ return this.clientQueryStore.get(ref);
1119
+ }
1120
+ /**
1121
+ * Set a new value for `ref` and notify every subscriber. Pass `undefined`
1122
+ * to reset the slot to `ref.defaultValue`.
1123
+ */
1124
+ setClientQuery(ref, value) {
1125
+ this.clientQueryStore.set(ref, value);
1126
+ }
1127
+ /**
1128
+ * Subscribe to changes for `ref`. The callback is NOT invoked on
1129
+ * registration — call {@link getClientQuery} for the current value.
1130
+ * Returns an unsubscribe function.
1131
+ */
1132
+ subscribeClientQuery(ref, callback) {
1133
+ return this.clientQueryStore.subscribe(ref, callback);
1134
+ }
1135
+ /**
1136
+ * Reset a {@link ClientQueryRef} to its default value, notifying every
1137
+ * subscriber. Equivalent to `setClientQuery(ref, ref.defaultValue)` but
1138
+ * removes the stored entry so a future {@link getClientQuery} returns
1139
+ * the default rather than an explicitly-set value.
1140
+ */
1141
+ resetClientQuery(ref) {
1142
+ this.clientQueryStore.reset(ref);
1143
+ }
1144
+ /**
1145
+ * Capture a snapshot of the current live query value at call time and
1146
+ * produce a `() => boolean` precondition that compares it against the
1147
+ * value at replay time (on queue drain / reconnect).
1148
+ *
1149
+ * When the precondition is checked it re-reads the query's current value
1150
+ * via `peekActiveQueryValue`. If the value differs from what was
1151
+ * captured at call time the precondition returns `false` and the offline
1152
+ * mutation is dropped as stale.
1153
+ *
1154
+ * This is a method wrapper around `createSnapshotPrecondition` that
1155
+ * binds the client instance for you — no need to pass `client` explicitly.
1156
+ * @example
1157
+ * ```ts
1158
+ * client.mutation(api.todos.update, { id, text }, {
1159
+ * precondition: client.snapshotPrecondition(api.todos.list, { userId }),
1160
+ * });
1161
+ * ```
1162
+ */
1163
+ snapshotPrecondition(functionRef, args, shardKey) {
1164
+ const snapshot = this.peekActiveQueryValue(functionRef.__lunoraRef, args, shardKey);
1165
+ const snapshotKey = snapshot === void 0 ? void 0 : stableStringify(snapshot);
1166
+ return () => {
1167
+ const current = this.peekActiveQueryValue(functionRef.__lunoraRef, args, shardKey);
1168
+ if (snapshotKey === void 0 && current === void 0) {
1169
+ return true;
1170
+ }
1171
+ if (snapshotKey === void 0 || current === void 0) {
1172
+ return false;
1173
+ }
1174
+ return stableStringify(current) === snapshotKey;
1175
+ };
1176
+ }
1177
+ // --- Hydration helpers --------------------------------------------------
1178
+ /**
1179
+ * Resolves once the durable read cache has been loaded into memory. When
1180
+ * `hydrateOnStart` is not configured or no query cache adapter is active,
1181
+ * returns an already-resolved promise so callers can always await it
1182
+ * unconditionally.
1183
+ *
1184
+ * Framework adapters (React, Vue, etc.) use this to gate the first
1185
+ * (enabled) render of a live query behind hydration, so the user sees
1186
+ * cached data instead of an undefined flash before the socket round-trip.
1187
+ */
1188
+ whenReady() {
1189
+ return this.readyPromise;
1190
+ }
1191
+ /**
1192
+ * Synchronously reports whether {@link whenReady} has already resolved (the
1193
+ * durable read cache is loaded, or none is configured). Framework adapters
1194
+ * read this to seed the hydration-gate state on the first render without
1195
+ * awaiting, then subscribe via {@link whenReady} for the pending case.
1196
+ */
1197
+ get isReady() {
1198
+ return this.readyResolved;
1199
+ }
1200
+ /**
1201
+ * Synchronously peek at a value the durable read cache loaded for the given
1202
+ * function path + args + shard key. Returns `undefined` when:
1203
+ *
1204
+ * - No query cache adapter is configured.
1205
+ * - Hydration hasn't completed yet (race — await {@link whenReady} first).
1206
+ * - The cached value's identity fingerprint doesn't match the current auth.
1207
+ *
1208
+ * Unlike the internal {@link takeHydratedCache}, this is a READ-ONLY peek:
1209
+ * the cached entry stays in `hydratedQueryCache` so the subscription created
1210
+ * later by {@link subscribe} consumes it normally.
1211
+ */
1212
+ peekHydratedQuery(functionPath, args, shardKey) {
1213
+ if (!this.readyResolved) {
1214
+ return void 0;
1215
+ }
1216
+ const argsKey = stableStringify(args);
1217
+ const key = queryCacheKey(functionPath, argsKey, shardKey);
1218
+ const entry = this.hydratedQueryCache.get(key);
1219
+ if (entry === void 0) {
1220
+ return void 0;
1221
+ }
1222
+ return entry.identity === this.identityFingerprint() ? entry.value : void 0;
1223
+ }
1224
+ /**
1225
+ * Peek at the **current live value** of an active subscription, if one
1226
+ * exists. Returns the subscription's `lastValue` (which includes any
1227
+ * optimistic overlay) or `undefined` if no subscription is active for the
1228
+ * given `(functionPath, args, shardKey)`.
1229
+ *
1230
+ * Unlike {@link peekHydratedQuery} (which reads from the durable read cache
1231
+ * and is independent of active subscriptions), this method reflects the
1232
+ * current in-memory state of an already-opened subscription — useful for
1233
+ * offline mutation preconditions that need to snapshot the value at call time
1234
+ * and compare it at replay time.
1235
+ */
1236
+ peekActiveQueryValue(functionPath, args, shardKey) {
1237
+ const key = SubscriptionRegistry.key(functionPath, args, shardKey);
1238
+ const state = this.subscriptions.get(key);
1239
+ return state?.lastValue;
1240
+ }
1001
1241
  // --- RPC ---------------------------------------------------------------
1002
1242
  async query(function_, args, options = {}) {
1003
1243
  if (this.closed) {
@@ -1095,7 +1335,15 @@ class LunoraClient {
1095
1335
  const shouldQueueOffline = this.WebSocketImpl !== void 0 && connectedGate;
1096
1336
  const midReconnect = wsState === "connecting" && connectedGate;
1097
1337
  if (wsState !== "open" && !hasSocket && shouldQueueOffline || midReconnect) {
1098
- return this.enqueueOfflineMutation(function_, argsRecord, options.shardKey, mutationId, optimisticRollbacks, optimisticConfirms);
1338
+ return this.enqueueOfflineMutation(
1339
+ function_,
1340
+ argsRecord,
1341
+ options.shardKey,
1342
+ mutationId,
1343
+ optimisticRollbacks,
1344
+ optimisticConfirms,
1345
+ options.precondition
1346
+ );
1099
1347
  }
1100
1348
  try {
1101
1349
  let commitCursor;
@@ -2103,6 +2351,8 @@ class LunoraClient {
2103
2351
  this.whisperHandlers.clear();
2104
2352
  this.shapeSubscriptions.clear();
2105
2353
  this.pokeBuffers.clear();
2354
+ this.tabCoordinator?.stop();
2355
+ this.tabCoordinator = void 0;
2106
2356
  }
2107
2357
  // --- Internals ----------------------------------------------------------
2108
2358
  /**
@@ -2116,7 +2366,7 @@ class LunoraClient {
2116
2366
  * optimistically (confirmation rides the synced view). Otherwise the
2117
2367
  * built-in `OfflineQueue` resolves/rejects the returned promise on replay.
2118
2368
  */
2119
- async enqueueOfflineMutation(function_, argsRecord, shardKey, mutationId, optimisticRollbacks, optimisticConfirms) {
2369
+ async enqueueOfflineMutation(function_, argsRecord, shardKey, mutationId, optimisticRollbacks, optimisticConfirms, precondition) {
2120
2370
  const issuingIdentity = this.identityFingerprint();
2121
2371
  if (this.outbox) {
2122
2372
  this.outboxMutationCounter += 1;
@@ -2154,6 +2404,8 @@ class LunoraClient {
2154
2404
  // Persist the stamp alongside the record so a hydrated write can
2155
2405
  // only replay under the identity that queued it.
2156
2406
  identity: issuingIdentity,
2407
+ // Optional precondition checked before replay — see drainConflict.
2408
+ precondition,
2157
2409
  // Confirm the per-call optimistic layer(s) against the commit cursor
2158
2410
  // the flush replay echoes (see flushOfflineQueue).
2159
2411
  onCommit: (commitCursor) => {
@@ -2380,7 +2632,7 @@ class LunoraClient {
2380
2632
  * mutation or leave a partial patch live.
2381
2633
  */
2382
2634
  applyOptimisticUpdate(optimisticUpdate, args, shardKey, optimisticRollbacks, optimisticConfirms) {
2383
- const { confirms, rollbacks, store } = createLocalStore(this.subscriptions, shardKey, stableStringify);
2635
+ const { confirms, rollbacks, store } = createLocalStore(this.subscriptions, shardKey);
2384
2636
  try {
2385
2637
  optimisticUpdate(store, args);
2386
2638
  } catch {
@@ -2608,6 +2860,9 @@ class LunoraClient {
2608
2860
  if (this.closed || this.WebSocketImpl === void 0) {
2609
2861
  return;
2610
2862
  }
2863
+ if (this.tabCoordinator && !this.tabCoordinator.isLeader()) {
2864
+ return;
2865
+ }
2611
2866
  const conn = this.getOrCreateConnection(shardKey);
2612
2867
  if (conn.wsState === "open" || conn.wsState === "connecting") {
2613
2868
  return;
@@ -2709,6 +2964,13 @@ class LunoraClient {
2709
2964
  conn.wsState = "idle";
2710
2965
  this.emitConnectionStatus();
2711
2966
  this.markShardPendingAck(conn.shardKey);
2967
+ const pendingStreamIds = new Set((conn.pendingStreams ?? []).map((message) => message.id));
2968
+ for (const [id, stream] of this.streams) {
2969
+ if (connectionKey(stream.shardKey) === connectionKey(conn.shardKey) && !pendingStreamIds.has(id)) {
2970
+ stream.handle.fail(new LunoraError("STREAM_DISCONNECTED", "stream terminated: WebSocket disconnected"));
2971
+ this.streams.delete(id);
2972
+ }
2973
+ }
2712
2974
  if (this.WebSocketImpl === void 0) {
2713
2975
  return;
2714
2976
  }
@@ -2881,12 +3143,7 @@ class LunoraClient {
2881
3143
  }
2882
3144
  }
2883
3145
  handlePokeStart(message) {
2884
- if (this.pokeBuffers.size >= LunoraClient.MAX_POKE_BUFFERS) {
2885
- const oldest = this.pokeBuffers.keys().next().value;
2886
- if (oldest !== void 0) {
2887
- this.pokeBuffers.delete(oldest);
2888
- }
2889
- }
3146
+ evictOldestEntry(this.pokeBuffers, LunoraClient.MAX_POKE_BUFFERS);
2890
3147
  this.pokeBuffers.set(message.pokeId, { baseCheckpoint: message.baseCheckpoint, epoch: message.epoch, lastMutationId: /* @__PURE__ */ new Map(), parts: /* @__PURE__ */ new Map() });
2891
3148
  }
2892
3149
  handlePokePart(message) {
@@ -2967,6 +3224,10 @@ class LunoraClient {
2967
3224
  this.persistQueryValue(state);
2968
3225
  dropConfirmedLayers(state, state.serverCursor);
2969
3226
  notifySubscription(state, state.optimisticLayers.length === 0 ? payload : foldOptimistic(payload, state.optimisticLayers));
3227
+ if (this.tabCoordinator?.isLeader()) {
3228
+ const key = SubscriptionRegistry.key(state.fn.__lunoraRef, state.args, state.shardKey);
3229
+ this.tabCoordinator.broadcastSubscriptionData(key, payload);
3230
+ }
2970
3231
  }
2971
3232
  /**
2972
3233
  * Handle a `resume` frame (Pillar 1b): the server proved nothing the
@@ -3108,6 +3369,24 @@ class LunoraClient {
3108
3369
  if (token === null) {
3109
3370
  return null;
3110
3371
  }
3372
+ return this.hashToken(token);
3373
+ }
3374
+ /**
3375
+ * Stable token-hash fingerprint of a bearer token (the `&lt;len>:&lt;fnv>:&lt;djb2>`
3376
+ * format a token-stamped queued write carries). Extracted so the replay gate
3377
+ * can recompute the hash of the current credential and recognise a write
3378
+ * stamped under it — even after the fingerprint was relabelled to a subject.
3379
+ *
3380
+ * Two independent 32-bit passes (FNV-1a + djb2) give a ~64-bit digest, so
3381
+ * two distinct equal-length tokens are astronomically unlikely to share a
3382
+ * fingerprint. A single 32-bit hash collides ~1-in-4e9 per equal-length
3383
+ * pair — enough that, on a shared device, user B could hydrate A's cached
3384
+ * reads. Different algorithms (not the same FNV with a different seed, which
3385
+ * would be affine-related) keep the two passes genuinely independent.
3386
+ * Still synchronous (no crypto) and stable across surrogate pairs.
3387
+ */
3388
+ // eslint-disable-next-line class-methods-use-this -- pure helper; a method for locality with identityFingerprint, reads no shared state
3389
+ hashToken(token) {
3111
3390
  let fnv = 2166136261;
3112
3391
  let djb2 = 5381;
3113
3392
  for (let index = 0; index < token.length; index += 1) {
@@ -3118,6 +3397,25 @@ class LunoraClient {
3118
3397
  }
3119
3398
  return `${token.length.toString(36)}:${(fnv >>> 0).toString(36)}:${(djb2 >>> 0).toString(36)}`;
3120
3399
  }
3400
+ /**
3401
+ * True when `stamped` is a token-hash of the SAME credential still held now,
3402
+ * even though the live identity has since been relabelled to a subject. Covers
3403
+ * `setAuthToken(token, userId)` where the subject resolved a tick after the
3404
+ * token was set: a write persisted (or requeued) under the token hash must
3405
+ * still replay — the credential never changed, only its label — instead of
3406
+ * being dropped as an identity mismatch. This is the durable counterpart to
3407
+ * {@link restampQueuedIdentity}, which only relabels the in-memory live stamp
3408
+ * (consumed on the first flush) and never touches `item.identity` or the
3409
+ * persisted record, so a reload or a transient-failure requeue would otherwise
3410
+ * fall back to the stale token-hash and wrongly reject the same user's write.
3411
+ */
3412
+ isSameCredentialUnderTokenHash(stamped) {
3413
+ if (stamped === null || stamped.startsWith("subj:")) {
3414
+ return false;
3415
+ }
3416
+ const token = this.authToken;
3417
+ return token === null ? false : this.hashToken(token) === stamped;
3418
+ }
3121
3419
  /**
3122
3420
  * Drain every in-memory offline write and reject it because the auth
3123
3421
  * identity changed. Durable entries are also dropped from persistence so a
@@ -3168,6 +3466,14 @@ class LunoraClient {
3168
3466
  this.queryCache?.clear().catch(() => void 0);
3169
3467
  }
3170
3468
  async flushOfflineQueue(shardKey) {
3469
+ const conflicted = this.offlineQueue.drainConflict();
3470
+ for (const item of conflicted) {
3471
+ this.unpersist(item.id);
3472
+ this.queuedIdentities.delete(item.id ?? "");
3473
+ const error = new Error("offline mutation skipped: precondition failed before replay");
3474
+ error.code = "OFFLINE_PRECONDITION_FAILED";
3475
+ this.emitItemSettled(item, "rejected", error);
3476
+ }
3171
3477
  const key = connectionKey(shardKey);
3172
3478
  const drained = this.offlineQueue.drain((item) => connectionKey(item.shardKey) === key);
3173
3479
  if (drained.length === 0) {
@@ -3246,7 +3552,7 @@ class LunoraClient {
3246
3552
  passesReplayIdentityGate(item, currentIdentity) {
3247
3553
  const liveStamp = item.id === void 0 ? void 0 : this.queuedIdentities.get(item.id);
3248
3554
  const stamped = liveStamp === void 0 ? item.identity : liveStamp;
3249
- if (stamped !== void 0 && stamped !== currentIdentity) {
3555
+ if (stamped !== void 0 && stamped !== currentIdentity && !this.isSameCredentialUnderTokenHash(stamped)) {
3250
3556
  this.queuedIdentities.delete(item.id ?? "");
3251
3557
  this.unpersist(item.id);
3252
3558
  const error = new Error("offline mutation skipped: auth identity changed before replay");
@@ -1 +1 @@
1
- export { O as OfflineQueue, n as nextId, r as reportPersistenceError } from './offline-queue-B9vfdSqp.mjs';
1
+ export { O as OfflineQueue, n as nextId, r as reportPersistenceError } from './offline-queue-CF4_Co5k.mjs';
@@ -0,0 +1,31 @@
1
+ import { s as stableStringify } from './stable-key-wv6eP48B.mjs';
2
+
3
+ class SubscriptionRegistry {
4
+ static key(functionPath, args, shardKey) {
5
+ return `${functionPath}::${stableStringify(args)}::${shardKey ?? ""}`;
6
+ }
7
+ byKey = /* @__PURE__ */ new Map();
8
+ byId = /* @__PURE__ */ new Map();
9
+ get(key) {
10
+ return this.byKey.get(key);
11
+ }
12
+ getById(id) {
13
+ return this.byId.get(id);
14
+ }
15
+ add(state) {
16
+ this.byKey.set(SubscriptionRegistry.key(state.fn.__lunoraRef, state.args, state.shardKey), state);
17
+ this.byId.set(state.id, state);
18
+ }
19
+ remove(state) {
20
+ const key = SubscriptionRegistry.key(state.fn.__lunoraRef, state.args, state.shardKey);
21
+ if (this.byKey.get(key) === state) {
22
+ this.byKey.delete(key);
23
+ }
24
+ this.byId.delete(state.id);
25
+ }
26
+ all() {
27
+ return [...this.byKey.values()];
28
+ }
29
+ }
30
+
31
+ export { SubscriptionRegistry };