@lunora/client 1.0.0-alpha.21 → 1.0.0-alpha.23

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/dist/auth/index.d.mts +1 -1
  2. package/dist/auth/index.d.ts +1 -1
  3. package/dist/index.d.mts +249 -4
  4. package/dist/index.d.ts +249 -4
  5. package/dist/index.mjs +10 -4
  6. package/dist/packem_shared/ClientServiceWorker-C3PAFwy0.mjs +100 -0
  7. package/dist/packem_shared/{LunoraClient-kXpHNyaE.mjs → LunoraClient-BBCQjjbl.mjs} +382 -243
  8. package/dist/packem_shared/{OfflineQueue-GGYJRmhF.mjs → OfflineQueue-B4HUF7rt.mjs} +1 -1
  9. package/dist/packem_shared/SubscriptionRegistry-CxS_Inha.mjs +31 -0
  10. package/dist/packem_shared/TabCoordinator-BwRR8H06.mjs +222 -0
  11. package/dist/packem_shared/createClientQuery-CQ51bWAE.mjs +71 -0
  12. package/dist/packem_shared/createLocalStore-BtqUmOQA.mjs +2 -0
  13. package/dist/packem_shared/createReply-lI4tVS2w.mjs +36 -0
  14. package/dist/packem_shared/{createServerClient-DF-3mLmb.mjs → createServerClient-CTTAmvMx.mjs} +1 -1
  15. package/dist/packem_shared/createSnapshotPrecondition-CxQ1T4ZP.mjs +18 -0
  16. package/dist/packem_shared/httpStream-BJU-aflc.mjs +159 -0
  17. package/dist/packem_shared/{local-store-BveBeFEo.mjs → local-store-DIq-UWfD.mjs} +1 -1
  18. package/dist/packem_shared/{lunora-client.d-BYkEjCEJ.d.mts → lunora-client.d-JvtVpf8A.d.mts} +302 -18
  19. package/dist/packem_shared/{lunora-client.d-BYkEjCEJ.d.ts → lunora-client.d-JvtVpf8A.d.ts} +302 -18
  20. package/dist/packem_shared/{offline-queue-B9vfdSqp.mjs → offline-queue-CF4_Co5k.mjs} +29 -0
  21. package/dist/packem_shared/{preload.d-B-vyHnml.d.ts → preload.d-C4_d_l5v.d.ts} +1 -1
  22. package/dist/packem_shared/{preload.d-DrfuisCE.d.mts → preload.d-DKbjGN5O.d.mts} +1 -1
  23. package/dist/packem_shared/wire-key-Djie6aaR.mjs +266 -0
  24. package/dist/query/index.d.mts +2 -2
  25. package/dist/query/index.d.ts +2 -2
  26. package/dist/ssr/index.d.mts +3 -3
  27. package/dist/ssr/index.d.ts +3 -3
  28. package/dist/ssr/index.mjs +1 -1
  29. package/package.json +2 -2
  30. package/dist/packem_shared/SubscriptionRegistry-DjGKZsqq.mjs +0 -1
  31. package/dist/packem_shared/createLocalStore-jRoqmazl.mjs +0 -2
  32. package/dist/packem_shared/subscription-BjynOXCU.mjs +0 -68
@@ -1,13 +1,17 @@
1
1
  import { LunoraError } from '@lunora/errors';
2
- import { S as SubscriptionRegistry, s as stableStringify } from './subscription-BjynOXCU.mjs';
2
+ import { s as stableWireKey, d as decodeWire, e as encodeWire } from './wire-key-Djie6aaR.mjs';
3
3
  import createInMemoryBookmarkStorage from './createInMemoryBookmarkStorage-BoN7a7TH.mjs';
4
+ import { ClientQueryStore } from './createClientQuery-CQ51bWAE.mjs';
5
+ import { TabCoordinator } from './TabCoordinator-BwRR8H06.mjs';
4
6
  import { isMutationDelta, applyDelta } from './applyDelta-CRKZ1PBt.mjs';
5
- import { a as applyOptimisticLayer, d as dropConfirmedLayers, n as notifySubscription, f as foldOptimistic, c as createLocalStore } from './local-store-BveBeFEo.mjs';
6
- import { O as OfflineQueue, n as nextId, i as isStaleVersion, r as reportPersistenceError } from './offline-queue-B9vfdSqp.mjs';
7
+ import { httpStream } from './httpStream-BJU-aflc.mjs';
8
+ import { f as foldOptimistic, a as applyOptimisticLayer, d as dropConfirmedLayers, n as notifySubscription, c as createLocalStore } from './local-store-DIq-UWfD.mjs';
9
+ import { O as OfflineQueue, n as nextId, i as isStaleVersion, r as reportPersistenceError } from './offline-queue-CF4_Co5k.mjs';
7
10
  import { resolvePersistenceAdapter } from './createInMemoryPersistence-DZ2VHWgm.mjs';
8
11
  import { resolveQueryCacheAdapter, queryCacheKey } from './createInMemoryQueryCache-DiaGZkA2.mjs';
9
12
  import { createReconnect } from './createReconnect-Di_-oHH7.mjs';
10
13
  import { createStream } from './DEFAULT_MAX_BUFFER-7hFnzNk9.mjs';
14
+ import { SubscriptionRegistry } from './SubscriptionRegistry-CxS_Inha.mjs';
11
15
 
12
16
  const MAX_BATCH_ENTRIES = 500;
13
17
 
@@ -21,229 +25,6 @@ const evictOldestEntry = (map, capacity) => {
21
25
  }
22
26
  };
23
27
 
24
- const TAG = "$lunora.wire$";
25
- const MAX_DEPTH = 64;
26
- const MAX_BIGINT_DIGITS = 1024;
27
- const UNSAFE_KEY = "__proto__";
28
- const TYPED_ARRAY_CTORS = {
29
- BigInt64Array,
30
- BigUint64Array,
31
- Float32Array,
32
- Float64Array,
33
- Int8Array,
34
- Int16Array,
35
- Int32Array,
36
- Uint8Array,
37
- Uint8ClampedArray,
38
- Uint16Array,
39
- Uint32Array
40
- };
41
- const ERROR_CTORS = {
42
- Error,
43
- EvalError,
44
- RangeError,
45
- ReferenceError,
46
- SyntaxError,
47
- TypeError,
48
- URIError
49
- };
50
- const toBase64 = (bytes) => {
51
- let binary = "";
52
- const chunk = 32768;
53
- for (let index = 0; index < bytes.length; index += chunk) {
54
- binary += String.fromCharCode(...bytes.subarray(index, index + chunk));
55
- }
56
- return btoa(binary);
57
- };
58
- const fromBase64 = (base64) => {
59
- const binary = atob(base64);
60
- const bytes = new Uint8Array(binary.length);
61
- for (let index = 0; index < binary.length; index += 1) {
62
- bytes[index] = binary.codePointAt(index) ?? 0;
63
- }
64
- return bytes;
65
- };
66
- const encodeWire = (value, depth = 0) => {
67
- if (depth > MAX_DEPTH) {
68
- throw new RangeError(`wire-codec: value nesting exceeds the ${MAX_DEPTH}-level limit`);
69
- }
70
- if (value === void 0) {
71
- return [TAG, "undefined"];
72
- }
73
- if (value === null) {
74
- return null;
75
- }
76
- const kind = typeof value;
77
- if (kind === "bigint") {
78
- return [TAG, "bigint", value.toString()];
79
- }
80
- if (kind === "number") {
81
- const numeric = value;
82
- if (Number.isNaN(numeric)) {
83
- return [TAG, "nan"];
84
- }
85
- if (numeric === Infinity) {
86
- return [TAG, "inf"];
87
- }
88
- if (numeric === -Infinity) {
89
- return [TAG, "-inf"];
90
- }
91
- return numeric;
92
- }
93
- if (kind !== "object") {
94
- return value;
95
- }
96
- if (value instanceof Date) {
97
- return [TAG, "date", encodeWire(value.getTime(), depth + 1)];
98
- }
99
- if (value instanceof Error) {
100
- const error = value;
101
- const properties = {};
102
- for (const key of Object.keys(error)) {
103
- if (error[key] !== void 0) {
104
- properties[key] = encodeWire(error[key], depth + 1);
105
- }
106
- }
107
- const encodedError = [TAG, "error", error.name, error.message, properties];
108
- if (error.cause !== void 0) {
109
- encodedError.push(encodeWire(error.cause, depth + 1));
110
- }
111
- return encodedError;
112
- }
113
- if (value instanceof URL) {
114
- return [TAG, "url", value.href];
115
- }
116
- if (value instanceof Map) {
117
- return [TAG, "map", [...value.entries()].map(([k, v]) => [encodeWire(k, depth + 1), encodeWire(v, depth + 1)])];
118
- }
119
- if (value instanceof Set) {
120
- return [TAG, "set", [...value].map((item) => encodeWire(item, depth + 1))];
121
- }
122
- if (value instanceof ArrayBuffer) {
123
- return [TAG, "bytes", toBase64(new Uint8Array(value)), "ArrayBuffer"];
124
- }
125
- if (ArrayBuffer.isView(value)) {
126
- const view = value;
127
- const ctorName = view.constructor.name;
128
- const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
129
- return ctorName === "Uint8Array" ? [TAG, "bytes", toBase64(bytes)] : [TAG, "bytes", toBase64(bytes), ctorName];
130
- }
131
- if (Array.isArray(value)) {
132
- const encoded = value.map((item) => encodeWire(item, depth + 1));
133
- return encoded.length > 0 && encoded[0] === TAG ? [TAG, "arr", encoded] : encoded;
134
- }
135
- const proto = Object.getPrototypeOf(value);
136
- if (proto !== null && proto !== Object.prototype) {
137
- const name = value.constructor?.name ?? "value";
138
- throw new TypeError(
139
- `wire-codec: cannot encode a ${name} over the Lunora wire — only plain objects, arrays, and the supported built-ins (Date, Error, URL, Map, Set, ArrayBuffer/typed arrays, bigint) round-trip`
140
- );
141
- }
142
- const source = value;
143
- const result = {};
144
- for (const key of Object.keys(source)) {
145
- const field = source[key];
146
- if (field !== void 0) {
147
- result[key] = encodeWire(field, depth + 1);
148
- }
149
- }
150
- return result;
151
- };
152
- const decodeWire = (value, depth = 0) => {
153
- if (depth > MAX_DEPTH) {
154
- throw new RangeError(`wire-codec: value nesting exceeds the ${MAX_DEPTH}-level limit`);
155
- }
156
- if (value === null || typeof value !== "object") {
157
- return value;
158
- }
159
- if (Array.isArray(value)) {
160
- if (value[0] === TAG) {
161
- const tag = value[1];
162
- switch (tag) {
163
- case "-inf": {
164
- return -Infinity;
165
- }
166
- case "arr": {
167
- return value[2].map((item) => decodeWire(item, depth + 1));
168
- }
169
- case "bigint": {
170
- const raw = value[2];
171
- if (typeof raw !== "string" || raw.length > MAX_BIGINT_DIGITS || !/^-?\d+$/.test(raw)) {
172
- throw new RangeError(`wire-codec: invalid or over-long bigint (max ${MAX_BIGINT_DIGITS} digits)`);
173
- }
174
- return BigInt(raw);
175
- }
176
- case "date": {
177
- return new Date(decodeWire(value[2], depth + 1));
178
- }
179
- case "map": {
180
- return new Map(value[2].map(([k, v]) => [decodeWire(k, depth + 1), decodeWire(v, depth + 1)]));
181
- }
182
- case "set": {
183
- return new Set(value[2].map((item) => decodeWire(item, depth + 1)));
184
- }
185
- case "url": {
186
- return new URL(value[2]);
187
- }
188
- case "error": {
189
- const name = value[2];
190
- const message = value[3];
191
- const Ctor = (Object.hasOwn(ERROR_CTORS, name) ? ERROR_CTORS[name] : void 0) ?? Error;
192
- const error = new Ctor(message);
193
- if (error.name !== name) {
194
- Object.defineProperty(error, "name", { configurable: true, value: name, writable: true });
195
- }
196
- const props = decodeWire(value[4], depth + 1);
197
- for (const key of Object.keys(props)) {
198
- if (key === UNSAFE_KEY) {
199
- Object.defineProperty(error, key, { configurable: true, enumerable: true, value: props[key], writable: true });
200
- } else {
201
- error[key] = props[key];
202
- }
203
- }
204
- if (value.length > 5) {
205
- Object.defineProperty(error, "cause", { configurable: true, value: decodeWire(value[5], depth + 1), writable: true });
206
- }
207
- return error;
208
- }
209
- case "bytes": {
210
- const bytes = fromBase64(value[2]);
211
- const ctorName = value[3] ?? "Uint8Array";
212
- if (ctorName === "ArrayBuffer") {
213
- return bytes.buffer.byteLength === bytes.byteLength ? bytes.buffer : bytes.slice().buffer;
214
- }
215
- const Ctor = Object.hasOwn(TYPED_ARRAY_CTORS, ctorName) ? TYPED_ARRAY_CTORS[ctorName] : void 0;
216
- return Ctor ? new Ctor(bytes.slice().buffer) : bytes;
217
- }
218
- case "inf": {
219
- return Infinity;
220
- }
221
- case "nan": {
222
- return Number.NaN;
223
- }
224
- case "undefined": {
225
- return void 0;
226
- }
227
- default: {
228
- return value.map((item) => decodeWire(item, depth + 1));
229
- }
230
- }
231
- }
232
- return value.map((item) => decodeWire(item, depth + 1));
233
- }
234
- const source = value;
235
- const result = {};
236
- for (const key of Object.keys(source)) {
237
- const decoded = decodeWire(source[key], depth + 1);
238
- if (key === UNSAFE_KEY) {
239
- Object.defineProperty(result, key, { configurable: true, enumerable: true, value: decoded, writable: true });
240
- } else {
241
- result[key] = decoded;
242
- }
243
- }
244
- return result;
245
- };
246
-
247
28
  class Listeners {
248
29
  listeners = /* @__PURE__ */ new Set();
249
30
  add(listener) {
@@ -463,11 +244,25 @@ const demuxBatchResults = (rawResults, count) => {
463
244
  return slots.map((slot) => slot ?? { error: new Error("batch call returned no result"), ok: false });
464
245
  };
465
246
  const TRANSIENT_BATCH_ERROR_CODES = /* @__PURE__ */ new Set(["SHARD_ERROR", "SHARD_UNAVAILABLE"]);
247
+ const RESOLVED_PROMISE = Promise.resolve();
466
248
  class LunoraClient {
467
249
  /** 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. */
468
250
  static MAX_POKE_BUFFERS = 256;
251
+ /**
252
+ * Create a typed {@link ClientQueryRef}. Convenience wrapper around
253
+ * {@link createClientQuery} so you don't need a separate import.
254
+ * @example
255
+ * ```ts
256
+ * const sidebarOpen = LunoraClient.createClientQuery("sidebarOpen", true);
257
+ * ```
258
+ */
259
+ static createClientQuery(key, defaultValue) {
260
+ return { defaultValue, key };
261
+ }
469
262
  url;
470
263
  wsUrl;
264
+ /** Local reactive store for {@link ClientQueryRef} values — no server round-trip. Private; reach it via `getClientQuery` / `setClientQuery` / `subscribeClientQuery`. */
265
+ clientQueryStore;
471
266
  wsToken;
472
267
  /** Better-auth base path (trailing slash stripped) for the `get-session` lookup. */
473
268
  authBasePath;
@@ -488,6 +283,20 @@ class LunoraClient {
488
283
  outbox;
489
284
  /** Stable per-client id stamped onto every `OutboxMutation` (custom-mutator watermark). */
490
285
  clientId;
286
+ /**
287
+ * `true` when the constructor's hydration microtask has finished loading the
288
+ * durable read cache (Pillar 2) into `hydratedQueryCache`. Signals that
289
+ * the cache is ready for synchronous `peekHydratedQuery` reads.
290
+ */
291
+ readyResolved = false;
292
+ /** Resolvers for `whenReady()` — called once hydration completes. */
293
+ readyResolve;
294
+ /**
295
+ * Promise that resolves once the durable read cache has been loaded. When
296
+ * `hydrateOnStart` is not set or no query cache is configured, resolves
297
+ * immediately (the constructor creates an already-resolved promise).
298
+ */
299
+ readyPromise;
491
300
  /**
492
301
  * Highest custom-mutator watermark the server has echoed for this client,
493
302
  * keyed by shard bucket (`shardKey ?? ""`) since the DO tracks one
@@ -521,6 +330,12 @@ class LunoraClient {
521
330
  pendingCacheWrites = /* @__PURE__ */ new Map();
522
331
  cacheFlushTimer;
523
332
  subscriptions = new SubscriptionRegistry();
333
+ /**
334
+ * Cross-tab coordinator; created only when `crossTabSync: true`. When the
335
+ * client is not the elected leader, all WebSocket operations are skipped.
336
+ * Not `readonly` — `close()` clears it (mirrors `outboxLeaderRelease`).
337
+ */
338
+ tabCoordinator;
524
339
  /** One {@link ShardConnection} per shard key (keyed by `shardKey ?? ""`). */
525
340
  connections = /* @__PURE__ */ new Map();
526
341
  /** Default `connect`-envelope context applied to a shard with no explicit override. */
@@ -616,6 +431,59 @@ class LunoraClient {
616
431
  this.persistenceVersion = options.persistenceVersion;
617
432
  this.queryCache = resolveQueryCacheAdapter(options.queryCache);
618
433
  this.onPersistenceError = options.offlineQueue?.onPersistenceError;
434
+ this.clientQueryStore = new ClientQueryStore();
435
+ if (options.crossTabSync) {
436
+ this.tabCoordinator = new TabCoordinator({
437
+ onBecomeLeader: () => {
438
+ for (const state of this.subscriptions.all()) {
439
+ this.ensureSocket(state.shardKey);
440
+ this.sendSubscribeIfOpen(state);
441
+ }
442
+ },
443
+ onStopBeingLeader: () => {
444
+ for (const [key, conn] of this.connections) {
445
+ conn.socket?.close();
446
+ this.connections.delete(key);
447
+ }
448
+ },
449
+ onSubscriptionData: (key, data) => {
450
+ const state = this.subscriptions.get(key);
451
+ if (state) {
452
+ state.serverBase = data;
453
+ const folded = state.optimisticLayers.length === 0 ? data : foldOptimistic(data, state.optimisticLayers);
454
+ state.lastValue = folded;
455
+ for (const callback of state.callbacks) {
456
+ try {
457
+ callback(folded);
458
+ } catch {
459
+ }
460
+ }
461
+ }
462
+ },
463
+ onSubscriptionError: (key, error) => {
464
+ const state = this.subscriptions.get(key);
465
+ if (state) {
466
+ for (const callback of state.errorCallbacks) {
467
+ try {
468
+ callback(error);
469
+ } catch {
470
+ }
471
+ }
472
+ }
473
+ }
474
+ });
475
+ this.tabCoordinator.start();
476
+ } else {
477
+ this.tabCoordinator = void 0;
478
+ }
479
+ if (options.hydrateOnStart && this.queryCache) {
480
+ this.readyPromise = new Promise((resolve) => {
481
+ this.readyResolve = resolve;
482
+ });
483
+ } else {
484
+ this.readyResolved = true;
485
+ this.readyPromise = RESOLVED_PROMISE;
486
+ }
619
487
  this.offlineQueue = new OfflineQueue(options.offlineQueue, {
620
488
  onEvict: (entry, error) => {
621
489
  this.emitItemSettled(entry, "rejected", error);
@@ -635,7 +503,17 @@ class LunoraClient {
635
503
  }
636
504
  if (this.queryCache) {
637
505
  queueMicrotask(() => {
638
- this.hydrateQueryCache().catch(() => void 0);
506
+ const hydration = async () => {
507
+ try {
508
+ await this.hydrateQueryCache();
509
+ } catch {
510
+ } finally {
511
+ this.readyResolved = true;
512
+ this.readyResolve?.();
513
+ }
514
+ };
515
+ hydration().catch(() => {
516
+ });
639
517
  });
640
518
  }
641
519
  }
@@ -791,8 +669,10 @@ class LunoraClient {
791
669
  * Replace the token appended to WS upgrade URLs as `?token=…` and close
792
670
  * every open shard socket so the reconnect picks up the new value. Call
793
671
  * this whenever the user's WS credential changes (rotating the admin token
794
- * in the studio, switching workspaces, etc.). Bearer tokens for HTTP
795
- * RPC are independentsee {@link setAuthToken}.
672
+ * in the studio, switching workspaces, etc.). Accepts a static string or a
673
+ * {@link WsTokenProvider} resolved fresh at every (re)connect the channel
674
+ * for short-lived credentials like the minted ephemeral admin sub-token.
675
+ * Bearer tokens for HTTP RPC are independent — see {@link setAuthToken}.
796
676
  */
797
677
  setWsToken(token) {
798
678
  if (this.wsToken === token) {
@@ -1008,6 +888,135 @@ class LunoraClient {
1008
888
  onMutationSettled(listener) {
1009
889
  return this.mutationSettledListeners.add(listener);
1010
890
  }
891
+ // --- Client Query (local state) ----------------------------------------
892
+ /**
893
+ * Read the current value for a {@link ClientQueryRef}. Returns
894
+ * `ref.defaultValue` when no value has been explicitly set.
895
+ */
896
+ getClientQuery(ref) {
897
+ return this.clientQueryStore.get(ref);
898
+ }
899
+ /**
900
+ * Set a new value for `ref` and notify every subscriber. Pass `undefined`
901
+ * to reset the slot to `ref.defaultValue`.
902
+ */
903
+ setClientQuery(ref, value) {
904
+ this.clientQueryStore.set(ref, value);
905
+ }
906
+ /**
907
+ * Subscribe to changes for `ref`. The callback is NOT invoked on
908
+ * registration — call {@link getClientQuery} for the current value.
909
+ * Returns an unsubscribe function.
910
+ */
911
+ subscribeClientQuery(ref, callback) {
912
+ return this.clientQueryStore.subscribe(ref, callback);
913
+ }
914
+ /**
915
+ * Reset a {@link ClientQueryRef} to its default value, notifying every
916
+ * subscriber. Equivalent to `setClientQuery(ref, ref.defaultValue)` but
917
+ * removes the stored entry so a future {@link getClientQuery} returns
918
+ * the default rather than an explicitly-set value.
919
+ */
920
+ resetClientQuery(ref) {
921
+ this.clientQueryStore.reset(ref);
922
+ }
923
+ /**
924
+ * Capture a snapshot of the current live query value at call time and
925
+ * produce a `() => boolean` precondition that compares it against the
926
+ * value at replay time (on queue drain / reconnect).
927
+ *
928
+ * When the precondition is checked it re-reads the query's current value
929
+ * via `peekActiveQueryValue`. If the value differs from what was
930
+ * captured at call time the precondition returns `false` and the offline
931
+ * mutation is dropped as stale.
932
+ *
933
+ * This is a method wrapper around `createSnapshotPrecondition` that
934
+ * binds the client instance for you — no need to pass `client` explicitly.
935
+ * @example
936
+ * ```ts
937
+ * client.mutation(api.todos.update, { id, text }, {
938
+ * precondition: client.snapshotPrecondition(api.todos.list, { userId }),
939
+ * });
940
+ * ```
941
+ */
942
+ snapshotPrecondition(functionRef, args, shardKey) {
943
+ const snapshot = this.peekActiveQueryValue(functionRef.__lunoraRef, args, shardKey);
944
+ const snapshotKey = snapshot === void 0 ? void 0 : stableWireKey(snapshot);
945
+ return () => {
946
+ const current = this.peekActiveQueryValue(functionRef.__lunoraRef, args, shardKey);
947
+ if (snapshotKey === void 0 && current === void 0) {
948
+ return true;
949
+ }
950
+ if (snapshotKey === void 0 || current === void 0) {
951
+ return false;
952
+ }
953
+ return stableWireKey(current) === snapshotKey;
954
+ };
955
+ }
956
+ // --- Hydration helpers --------------------------------------------------
957
+ /**
958
+ * Resolves once the durable read cache has been loaded into memory. When
959
+ * `hydrateOnStart` is not configured or no query cache adapter is active,
960
+ * returns an already-resolved promise so callers can always await it
961
+ * unconditionally.
962
+ *
963
+ * Framework adapters (React, Vue, etc.) use this to gate the first
964
+ * (enabled) render of a live query behind hydration, so the user sees
965
+ * cached data instead of an undefined flash before the socket round-trip.
966
+ */
967
+ whenReady() {
968
+ return this.readyPromise;
969
+ }
970
+ /**
971
+ * Synchronously reports whether {@link whenReady} has already resolved (the
972
+ * durable read cache is loaded, or none is configured). Framework adapters
973
+ * read this to seed the hydration-gate state on the first render without
974
+ * awaiting, then subscribe via {@link whenReady} for the pending case.
975
+ */
976
+ get isReady() {
977
+ return this.readyResolved;
978
+ }
979
+ /**
980
+ * Synchronously peek at a value the durable read cache loaded for the given
981
+ * function path + args + shard key. Returns `undefined` when:
982
+ *
983
+ * - No query cache adapter is configured.
984
+ * - Hydration hasn't completed yet (race — await {@link whenReady} first).
985
+ * - The cached value's identity fingerprint doesn't match the current auth.
986
+ *
987
+ * Unlike the internal {@link takeHydratedCache}, this is a READ-ONLY peek:
988
+ * the cached entry stays in `hydratedQueryCache` so the subscription created
989
+ * later by {@link subscribe} consumes it normally.
990
+ */
991
+ peekHydratedQuery(functionPath, args, shardKey) {
992
+ if (!this.readyResolved) {
993
+ return void 0;
994
+ }
995
+ const argsKey = stableWireKey(args);
996
+ const key = queryCacheKey(functionPath, argsKey, shardKey);
997
+ const entry = this.hydratedQueryCache.get(key);
998
+ if (entry === void 0) {
999
+ return void 0;
1000
+ }
1001
+ return entry.identity === this.identityFingerprint() ? entry.value : void 0;
1002
+ }
1003
+ /**
1004
+ * Peek at the **current live value** of an active subscription, if one
1005
+ * exists. Returns the subscription's `lastValue` (which includes any
1006
+ * optimistic overlay) or `undefined` if no subscription is active for the
1007
+ * given `(functionPath, args, shardKey)`.
1008
+ *
1009
+ * Unlike {@link peekHydratedQuery} (which reads from the durable read cache
1010
+ * and is independent of active subscriptions), this method reflects the
1011
+ * current in-memory state of an already-opened subscription — useful for
1012
+ * offline mutation preconditions that need to snapshot the value at call time
1013
+ * and compare it at replay time.
1014
+ */
1015
+ peekActiveQueryValue(functionPath, args, shardKey) {
1016
+ const key = SubscriptionRegistry.key(functionPath, args, shardKey);
1017
+ const state = this.subscriptions.get(key);
1018
+ return state?.lastValue;
1019
+ }
1011
1020
  // --- RPC ---------------------------------------------------------------
1012
1021
  async query(function_, args, options = {}) {
1013
1022
  if (this.closed) {
@@ -1105,7 +1114,15 @@ class LunoraClient {
1105
1114
  const shouldQueueOffline = this.WebSocketImpl !== void 0 && connectedGate;
1106
1115
  const midReconnect = wsState === "connecting" && connectedGate;
1107
1116
  if (wsState !== "open" && !hasSocket && shouldQueueOffline || midReconnect) {
1108
- return this.enqueueOfflineMutation(function_, argsRecord, options.shardKey, mutationId, optimisticRollbacks, optimisticConfirms);
1117
+ return this.enqueueOfflineMutation(
1118
+ function_,
1119
+ argsRecord,
1120
+ options.shardKey,
1121
+ mutationId,
1122
+ optimisticRollbacks,
1123
+ optimisticConfirms,
1124
+ options.precondition
1125
+ );
1109
1126
  }
1110
1127
  try {
1111
1128
  let commitCursor;
@@ -1296,8 +1313,10 @@ class LunoraClient {
1296
1313
  * Subscribe to the live scheduled-jobs list over the SchedulerDO's admin
1297
1314
  * WebSocket. `onJobs` fires with the full list on connect and on every
1298
1315
  * change (schedule / cancel / alarm-fire). Reconnects with the client's
1299
- * configured backoff. Requires `wsToken` to be set to the admin token (the
1300
- * browser can't send an `Authorization` header on a WS). Returns an
1316
+ * configured backoff. Requires `wsToken` to be set to an admin credential
1317
+ * (the browser can't send an `Authorization` header on a WS) the master
1318
+ * token, or preferably a {@link WsTokenProvider} minting the ephemeral
1319
+ * sub-token so the master credential stays out of the URL. Returns an
1301
1320
  * unsubscribe function that closes the socket and stops reconnecting.
1302
1321
  */
1303
1322
  subscribeScheduledJobs(onJobs) {
@@ -1312,11 +1331,11 @@ class LunoraClient {
1312
1331
  let socket;
1313
1332
  let timer;
1314
1333
  let closed = false;
1315
- const connect = () => {
1334
+ const openWith = (token) => {
1316
1335
  if (closed || this.WebSocketImpl === void 0) {
1317
1336
  return;
1318
1337
  }
1319
- const url = this.wsToken === void 0 ? base : `${base}?token=${encodeURIComponent(this.wsToken)}`;
1338
+ const url = token === void 0 ? base : `${base}?token=${encodeURIComponent(token)}`;
1320
1339
  socket = new this.WebSocketImpl(url);
1321
1340
  socket.addEventListener("open", () => {
1322
1341
  reconnect.reset();
@@ -1339,6 +1358,29 @@ class LunoraClient {
1339
1358
  socket.addEventListener("error", () => {
1340
1359
  });
1341
1360
  };
1361
+ const connectWithProvider = async (provider) => {
1362
+ let token;
1363
+ try {
1364
+ token = await provider();
1365
+ } catch {
1366
+ if (!closed) {
1367
+ timer = setTimeout(connect, reconnect.next());
1368
+ }
1369
+ return;
1370
+ }
1371
+ openWith(token);
1372
+ };
1373
+ const connect = () => {
1374
+ if (closed || this.WebSocketImpl === void 0) {
1375
+ return;
1376
+ }
1377
+ const { wsToken } = this;
1378
+ if (typeof wsToken === "function") {
1379
+ connectWithProvider(wsToken).catch(() => void 0);
1380
+ return;
1381
+ }
1382
+ openWith(wsToken);
1383
+ };
1342
1384
  connect();
1343
1385
  return () => {
1344
1386
  closed = true;
@@ -1905,7 +1947,7 @@ class LunoraClient {
1905
1947
  if (!state) {
1906
1948
  this.nextSubId += 1;
1907
1949
  const id = `sub_${this.nextSubId.toString()}`;
1908
- const argsKey = stableStringify(argsRecord);
1950
+ const argsKey = stableWireKey(argsRecord);
1909
1951
  const cached = this.takeHydratedCache(function_.__lunoraRef, argsKey, options.shardKey);
1910
1952
  state = {
1911
1953
  acked: false,
@@ -1985,7 +2027,10 @@ class LunoraClient {
1985
2027
  name: shape.name,
1986
2028
  onCheckpoint: options.onCheckpoint,
1987
2029
  rows: /* @__PURE__ */ new Map(),
1988
- shardKey: options.shardKey
2030
+ shardKey: options.shardKey,
2031
+ // Encode ONCE, here, so an unsupported arg value throws at this call
2032
+ // site instead of inside a reconnect's open handler.
2033
+ wireArgs: shape.args === void 0 ? void 0 : encodeCallArgs(shape.args, `shape args for '${shape.name}'`)
1989
2034
  };
1990
2035
  this.shapeSubscriptions.set(id, state);
1991
2036
  this.ensureSocket(options.shardKey);
@@ -2069,6 +2114,39 @@ class LunoraClient {
2069
2114
  }
2070
2115
  return iterable;
2071
2116
  }
2117
+ /**
2118
+ * Open a typed **HTTP-SSE route stream** (`httpRoute.&lt;verb>(path).stream()`).
2119
+ * Distinct from {@link LunoraClient.stream}, which consumes the WS procedure
2120
+ * stream (`kind: "stream"`): this one opens the route's own URL with `fetch`
2121
+ * and parses the Server-Sent Events framing the route pump writes (`data:`
2122
+ * chunks, a final `event: complete`, an `event: error` on throw).
2123
+ *
2124
+ * The reference comes from the generated `httpStreams.*` registry, so the
2125
+ * yielded chunk type is the route handler's yielded type. Cancelling the
2126
+ * returned iterable (or aborting `options.signal`) aborts the fetch, which
2127
+ * the server handler observes via its `signal`. The client's bearer token
2128
+ * (when set) rides as an `authorization` header.
2129
+ * @experimental Reconnect/POST-body/wire-fidelity design questions are still open, so the shape may change.
2130
+ */
2131
+ httpStream(route, args, options = {}) {
2132
+ if (this.closed) {
2133
+ throw new LunoraError("CLIENT_CLOSED", "LunoraClient is closed");
2134
+ }
2135
+ if (!this.fetchImpl) {
2136
+ throw new LunoraError("INTERNAL", "LunoraClient: no `fetch` implementation available");
2137
+ }
2138
+ const headers = {
2139
+ ...this.authToken ? { authorization: `Bearer ${this.authToken}` } : {},
2140
+ ...options.headers
2141
+ };
2142
+ return httpStream(route, args, {
2143
+ baseUrl: this.url,
2144
+ fetch: this.fetchImpl,
2145
+ headers,
2146
+ maxBuffer: options.maxBuffer,
2147
+ signal: options.signal
2148
+ });
2149
+ }
2072
2150
  close() {
2073
2151
  this.closed = true;
2074
2152
  this.outboxLeaderRelease?.();
@@ -2113,6 +2191,8 @@ class LunoraClient {
2113
2191
  this.whisperHandlers.clear();
2114
2192
  this.shapeSubscriptions.clear();
2115
2193
  this.pokeBuffers.clear();
2194
+ this.tabCoordinator?.stop();
2195
+ this.tabCoordinator = void 0;
2116
2196
  }
2117
2197
  // --- Internals ----------------------------------------------------------
2118
2198
  /**
@@ -2126,7 +2206,7 @@ class LunoraClient {
2126
2206
  * optimistically (confirmation rides the synced view). Otherwise the
2127
2207
  * built-in `OfflineQueue` resolves/rejects the returned promise on replay.
2128
2208
  */
2129
- async enqueueOfflineMutation(function_, argsRecord, shardKey, mutationId, optimisticRollbacks, optimisticConfirms) {
2209
+ async enqueueOfflineMutation(function_, argsRecord, shardKey, mutationId, optimisticRollbacks, optimisticConfirms, precondition) {
2130
2210
  const issuingIdentity = this.identityFingerprint();
2131
2211
  if (this.outbox) {
2132
2212
  this.outboxMutationCounter += 1;
@@ -2164,6 +2244,8 @@ class LunoraClient {
2164
2244
  // Persist the stamp alongside the record so a hydrated write can
2165
2245
  // only replay under the identity that queued it.
2166
2246
  identity: issuingIdentity,
2247
+ // Optional precondition checked before replay — see drainConflict.
2248
+ precondition,
2167
2249
  // Confirm the per-call optimistic layer(s) against the commit cursor
2168
2250
  // the flush replay echoes (see flushOfflineQueue).
2169
2251
  onCommit: (commitCursor) => {
@@ -2424,13 +2506,13 @@ class LunoraClient {
2424
2506
  }
2425
2507
  return conn;
2426
2508
  }
2427
- wsUrlFor(shardKey) {
2509
+ wsUrlFor(shardKey, token) {
2428
2510
  const params = [];
2429
2511
  if (shardKey !== void 0) {
2430
2512
  params.push(`shard=${encodeURIComponent(shardKey)}`);
2431
2513
  }
2432
- if (this.wsToken !== void 0) {
2433
- params.push(`token=${encodeURIComponent(this.wsToken)}`);
2514
+ if (token !== void 0) {
2515
+ params.push(`token=${encodeURIComponent(token)}`);
2434
2516
  }
2435
2517
  if (params.length === 0) {
2436
2518
  return this.wsUrl;
@@ -2618,13 +2700,49 @@ class LunoraClient {
2618
2700
  if (this.closed || this.WebSocketImpl === void 0) {
2619
2701
  return;
2620
2702
  }
2703
+ if (this.tabCoordinator && !this.tabCoordinator.isLeader()) {
2704
+ return;
2705
+ }
2621
2706
  const conn = this.getOrCreateConnection(shardKey);
2622
2707
  if (conn.wsState === "open" || conn.wsState === "connecting") {
2623
2708
  return;
2624
2709
  }
2625
2710
  conn.wsState = "connecting";
2626
2711
  this.emitConnectionStatus();
2627
- const socket = new this.WebSocketImpl(this.wsUrlFor(shardKey));
2712
+ if (typeof this.wsToken === "function") {
2713
+ this.openSocketWithProvidedToken(conn, shardKey, this.wsToken).catch(() => void 0);
2714
+ return;
2715
+ }
2716
+ this.openSocket(conn, shardKey, this.wsToken);
2717
+ }
2718
+ /**
2719
+ * Resolve the {@link WsTokenProvider} and open the shard socket with the
2720
+ * minted token. The connection is already in the `connecting` state, so the
2721
+ * async gap is race-guarded: a client `close()`, a `setWsToken` bounce, or a
2722
+ * competing connect that landed first all abandon this attempt. A provider
2723
+ * failure fails the attempt through {@link handleDisconnect}, which arms the
2724
+ * normal reconnect backoff — a broken mint endpoint degrades to retries, not
2725
+ * a silent tokenless socket the admin gate would reject.
2726
+ */
2727
+ async openSocketWithProvidedToken(conn, shardKey, provider) {
2728
+ let token;
2729
+ try {
2730
+ token = await provider();
2731
+ } catch {
2732
+ this.handleDisconnect(conn);
2733
+ return;
2734
+ }
2735
+ if (this.closed || this.WebSocketImpl === void 0 || conn.wsState !== "connecting" || conn.socket !== void 0) {
2736
+ return;
2737
+ }
2738
+ this.openSocket(conn, shardKey, token);
2739
+ }
2740
+ /** Construct the shard socket and wire its lifecycle handlers. The connection must already be in the `connecting` state. */
2741
+ openSocket(conn, shardKey, token) {
2742
+ if (this.WebSocketImpl === void 0) {
2743
+ return;
2744
+ }
2745
+ const socket = new this.WebSocketImpl(this.wsUrlFor(shardKey, token));
2628
2746
  conn.socket = socket;
2629
2747
  if (this.connectTimeoutMs > 0) {
2630
2748
  conn.connectTimer = setTimeout(() => {
@@ -2786,7 +2904,12 @@ class LunoraClient {
2786
2904
  // sub (a hydrated read or an earlier frame), so the server can
2787
2905
  // resume instead of re-snapshotting. Omitted on a cold sub.
2788
2906
  query: {
2789
- args: state.args,
2907
+ // Wire-encode so a `bigint`/`Date`/bytes arg survives the frame's
2908
+ // `JSON.stringify` (the shard `decodeWire`s at its subscribe entry
2909
+ // point). Identity for pure-JSON args. Cannot throw here: the
2910
+ // registry key (`stableWireKey`) already encoded these args at
2911
+ // subscribe() time, so reconnect resends stay safe.
2912
+ args: encodeWire(state.args),
2790
2913
  functionPath: state.fn.__lunoraRef,
2791
2914
  table,
2792
2915
  ...state.serverCursor === void 0 ? {} : { sinceSeq: state.serverCursor },
@@ -2802,7 +2925,11 @@ class LunoraClient {
2802
2925
  }
2803
2926
  sendOn(conn, {
2804
2927
  id: state.id,
2805
- shape: { name: state.name, ...state.args === void 0 ? {} : { args: state.args } },
2928
+ // `wireArgs` is the pre-encoded form of `args` (computed at
2929
+ // `subscribeShape` time) so a `bigint`/`Date`/bytes arg survives the
2930
+ // frame's `JSON.stringify`; the shard `decodeWire`s it at its
2931
+ // `shape_subscribe` entry point. Identity for pure-JSON args.
2932
+ shape: { name: state.name, ...state.wireArgs === void 0 ? {} : { args: state.wireArgs } },
2806
2933
  type: "shape_subscribe",
2807
2934
  // Resume from the last applied checkpoint when we hold one; a cold
2808
2935
  // subscribe omits it and the server seeds the full membership.
@@ -2979,6 +3106,10 @@ class LunoraClient {
2979
3106
  this.persistQueryValue(state);
2980
3107
  dropConfirmedLayers(state, state.serverCursor);
2981
3108
  notifySubscription(state, state.optimisticLayers.length === 0 ? payload : foldOptimistic(payload, state.optimisticLayers));
3109
+ if (this.tabCoordinator?.isLeader()) {
3110
+ const key = SubscriptionRegistry.key(state.fn.__lunoraRef, state.args, state.shardKey);
3111
+ this.tabCoordinator.broadcastSubscriptionData(key, payload);
3112
+ }
2982
3113
  }
2983
3114
  /**
2984
3115
  * Handle a `resume` frame (Pillar 1b): the server proved nothing the
@@ -3217,6 +3348,14 @@ class LunoraClient {
3217
3348
  this.queryCache?.clear().catch(() => void 0);
3218
3349
  }
3219
3350
  async flushOfflineQueue(shardKey) {
3351
+ const conflicted = this.offlineQueue.drainConflict();
3352
+ for (const item of conflicted) {
3353
+ this.unpersist(item.id);
3354
+ this.queuedIdentities.delete(item.id ?? "");
3355
+ const error = new Error("offline mutation skipped: precondition failed before replay");
3356
+ error.code = "OFFLINE_PRECONDITION_FAILED";
3357
+ this.emitItemSettled(item, "rejected", error);
3358
+ }
3220
3359
  const key = connectionKey(shardKey);
3221
3360
  const drained = this.offlineQueue.drain((item) => connectionKey(item.shardKey) === key);
3222
3361
  if (drained.length === 0) {