@lunora/client 1.0.0-alpha.21 → 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 (30) 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 +9 -4
  6. package/dist/packem_shared/ClientServiceWorker-C3PAFwy0.mjs +100 -0
  7. package/dist/packem_shared/{LunoraClient-kXpHNyaE.mjs → LunoraClient-Clb118SU.mjs} +279 -22
  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/createClientQuery-CQ51bWAE.mjs +71 -0
  12. package/dist/packem_shared/createLocalStore-BDbbkoXw.mjs +2 -0
  13. package/dist/packem_shared/createReply-lI4tVS2w.mjs +36 -0
  14. package/dist/packem_shared/{createServerClient-DF-3mLmb.mjs → createServerClient-Dxemst5C.mjs} +1 -1
  15. package/dist/packem_shared/createSnapshotPrecondition-CBwnVz6r.mjs +18 -0
  16. package/dist/packem_shared/{local-store-BveBeFEo.mjs → local-store-DtcIW4c0.mjs} +1 -1
  17. package/dist/packem_shared/{lunora-client.d-BYkEjCEJ.d.mts → lunora-client.d-pw-9sLl0.d.mts} +183 -1
  18. package/dist/packem_shared/{lunora-client.d-BYkEjCEJ.d.ts → lunora-client.d-pw-9sLl0.d.ts} +183 -1
  19. package/dist/packem_shared/{offline-queue-B9vfdSqp.mjs → offline-queue-CF4_Co5k.mjs} +29 -0
  20. package/dist/packem_shared/{preload.d-DrfuisCE.d.mts → preload.d-6ME5ubgq.d.mts} +1 -1
  21. package/dist/packem_shared/{preload.d-B-vyHnml.d.ts → preload.d-BkQr-3Vh.d.ts} +1 -1
  22. package/dist/packem_shared/{subscription-BjynOXCU.mjs → stable-key-wv6eP48B.mjs} +1 -29
  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 +1 -1
  28. package/package.json +1 -1
  29. package/dist/packem_shared/SubscriptionRegistry-DjGKZsqq.mjs +0 -1
  30. package/dist/packem_shared/createLocalStore-jRoqmazl.mjs +0 -2
@@ -0,0 +1,71 @@
1
+ class ClientQueryStore {
2
+ /** Current values, keyed by the ref's stable key. Absent = never set. */
3
+ values = /* @__PURE__ */ new Map();
4
+ /** Subscribers keyed by ref key — notified on every set. */
5
+ subscribers = /* @__PURE__ */ new Map();
6
+ /**
7
+ * Return the current value for `ref`, or `ref.defaultValue` if none has
8
+ * been set explicitly. Returns `ref.defaultValue` when the slot has been
9
+ * set to `undefined` (which is distinct from "never set").
10
+ */
11
+ get(ref) {
12
+ if (this.values.has(ref.key)) {
13
+ return this.values.get(ref.key);
14
+ }
15
+ return ref.defaultValue;
16
+ }
17
+ /**
18
+ * Set a new value for `ref` and notify every subscriber. Pass `undefined`
19
+ * to reset the slot to `ref.defaultValue`.
20
+ */
21
+ set(ref, value) {
22
+ this.values.set(ref.key, value);
23
+ this.notify(ref.key);
24
+ }
25
+ /**
26
+ * Delete the stored value for `ref`, resetting to `ref.defaultValue` and
27
+ * notifying subscribers.
28
+ */
29
+ reset(ref) {
30
+ this.values.delete(ref.key);
31
+ this.notify(ref.key);
32
+ }
33
+ /**
34
+ * Subscribe to changes for `ref`. The callback is NOT invoked on
35
+ * registration — callers should read the current value via
36
+ * {@link get} first. Returns an unsubscribe function.
37
+ */
38
+ subscribe(ref, callback) {
39
+ let subs = this.subscribers.get(ref.key);
40
+ if (!subs) {
41
+ subs = /* @__PURE__ */ new Set();
42
+ this.subscribers.set(ref.key, subs);
43
+ }
44
+ subs.add(callback);
45
+ return () => {
46
+ subs.delete(callback);
47
+ if (subs.size === 0) {
48
+ this.subscribers.delete(ref.key);
49
+ }
50
+ };
51
+ }
52
+ /** Notify every subscriber of a value change for the given key. */
53
+ notify(key) {
54
+ const subs = this.subscribers.get(key);
55
+ if (!subs) {
56
+ return;
57
+ }
58
+ const value = this.values.get(key);
59
+ for (const callback of subs) {
60
+ try {
61
+ callback(value);
62
+ } catch {
63
+ }
64
+ }
65
+ }
66
+ }
67
+ const createClientQuery = (key, defaultValue) => {
68
+ return { defaultValue, key };
69
+ };
70
+
71
+ export { ClientQueryStore, createClientQuery };
@@ -0,0 +1,2 @@
1
+ export { c as createLocalStore } from './local-store-DtcIW4c0.mjs';
2
+ import './SubscriptionRegistry-D4jfIzZu.mjs';
@@ -0,0 +1,36 @@
1
+ const sendToSw = (sw, message, expectResponse = false) => new Promise((resolve, reject) => {
2
+ if (!sw) {
3
+ reject(new Error("No active service worker"));
4
+ return;
5
+ }
6
+ const id = message.correlationId ?? crypto.randomUUID();
7
+ const outgoingMessage = { ...message, correlationId: id };
8
+ if (expectResponse) {
9
+ let timer;
10
+ const handler = (event) => {
11
+ if (event.data.correlationId === id) {
12
+ clearTimeout(timer);
13
+ navigator.serviceWorker.removeEventListener("message", handler);
14
+ resolve(event.data.payload);
15
+ }
16
+ };
17
+ timer = setTimeout(() => {
18
+ navigator.serviceWorker.removeEventListener("message", handler);
19
+ reject(new Error(`SW message ${id} timed out`));
20
+ }, 3e4);
21
+ navigator.serviceWorker.addEventListener("message", handler);
22
+ sw.postMessage(outgoingMessage);
23
+ } else {
24
+ sw.postMessage(outgoingMessage);
25
+ resolve(void 0);
26
+ }
27
+ });
28
+ const createReply = (original, payload) => {
29
+ return {
30
+ type: `${original.type}:reply`,
31
+ payload,
32
+ correlationId: original.correlationId
33
+ };
34
+ };
35
+
36
+ export { createReply, sendToSw };
@@ -1,4 +1,4 @@
1
- import { LunoraClient } from './LunoraClient-kXpHNyaE.mjs';
1
+ import { LunoraClient } from './LunoraClient-Clb118SU.mjs';
2
2
 
3
3
  const createServerClient = (options) => {
4
4
  const client = new LunoraClient({ fetch: options.fetch, url: options.url });
@@ -0,0 +1,18 @@
1
+ import { s as stableStringify } from './stable-key-wv6eP48B.mjs';
2
+
3
+ const createSnapshotPrecondition = (client, functionRef, args, shardKey) => {
4
+ const snapshot = client.peekActiveQueryValue(functionRef.__lunoraRef, args, shardKey);
5
+ const snapshotKey = snapshot === void 0 ? void 0 : stableStringify(snapshot);
6
+ return () => {
7
+ const current = client.peekActiveQueryValue(functionRef.__lunoraRef, args, shardKey);
8
+ if (snapshotKey === void 0 && current === void 0) {
9
+ return true;
10
+ }
11
+ if (snapshotKey === void 0 || current === void 0) {
12
+ return false;
13
+ }
14
+ return stableStringify(current) === snapshotKey;
15
+ };
16
+ };
17
+
18
+ export { createSnapshotPrecondition as default };
@@ -1,4 +1,4 @@
1
- import { S as SubscriptionRegistry } from './subscription-BjynOXCU.mjs';
1
+ import { SubscriptionRegistry } from './SubscriptionRegistry-D4jfIzZu.mjs';
2
2
 
3
3
  const foldOptimistic = (base, layers) => {
4
4
  let value = base;
@@ -1,5 +1,42 @@
1
1
  import { CronJobInfo, VectorIndexSummary, VectorQueryMatch, KvNamespaceSummary, KvKeyListResult, KvValueResult, AuthUser, AuthPage, AuthImpersonation, AuthCapabilities, AuthConfigInfo, AuthSession } from '@lunora/runtime';
2
2
  /**
3
+ * Reactive key-value store for local-only client state.
4
+ *
5
+ * Unlike a server {@link SubscriptionState} (which tracks a live WS connection,
6
+ * an `acked` flag, `serverBase`, optimistic layers, and the full subscription
7
+ * machinery), a `ClientQueryRef` is purely local — no server round-trip, no
8
+ * WebSocket, no persistence. It exists so framework adapters can offer a
9
+ * `useClientQuery` hook whose values survive component remounts and are shared
10
+ * across every consumer of the same ref, with none of the ceremony or coupling
11
+ * of a dedicated context provider.
12
+ *
13
+ * The store lives inside `LunoraClient` (a private field) and is surfaced through
14
+ * `client.getClientQuery(ref)` / `setClientQuery(ref, value)` /
15
+ * `subscribeClientQuery(ref, callback)`.
16
+ */
17
+ /** Opaque handle for a typed client-local query slot. */
18
+ interface ClientQueryRef<T = unknown> {
19
+ /** Default value when no value has been set explicitly. */
20
+ readonly defaultValue: T;
21
+ /** Stable identity for the slot. Must be unique within a client instance. */
22
+ readonly key: string;
23
+ }
24
+ /** A subscriber callback for value changes to a {@link ClientQueryRef}. */
25
+
26
+ /**
27
+ * Create a typed {@link ClientQueryRef}. Call once per slot at module scope
28
+ * (or inside a component module) — the ref object is the stable identity.
29
+ * @example
30
+ * ```ts
31
+ * // lunora/client-queries.ts
32
+ * import { createClientQuery } from "@lunora/client";
33
+ *
34
+ * export const sidebarOpen = createClientQuery("sidebarOpen", true);
35
+ * export const selectedMessageId = createClientQuery("selectedMessageId", undefined as string | undefined);
36
+ * ```
37
+ */
38
+ declare const createClientQuery: <T>(key: string, defaultValue: T) => ClientQueryRef<T>;
39
+ /**
3
40
  * The machine-readable error codes a client can observe on a failed
4
41
  * RPC/batch/subscription. Mirrors the server's `CODE_STATUS` keys
5
42
  * (`@lunora/server`'s `error.ts`) by hand — the client is framework-neutral and
@@ -308,6 +345,16 @@ interface LunoraClientOptions {
308
345
  * Defaults to 10000 (10s); set to `0` (or negative) to disable.
309
346
  */
310
347
  connectTimeoutMs?: number;
348
+ /**
349
+ * When `true`, tabs sharing the same origin coordinate via BroadcastChannel
350
+ * so only one tab (the "leader") opens WebSocket connections to the server.
351
+ * Follower tabs receive subscription data through the channel instead.
352
+ *
353
+ * Reduces simultaneous WS connections, bandwidth, and cross-tab state drift.
354
+ * Requires `BroadcastChannel` (browser-only); silently ignored otherwise.
355
+ * Defaults to `false`.
356
+ */
357
+ crossTabSync?: boolean;
311
358
  fetch?: typeof fetch;
312
359
  /**
313
360
  * Interval (ms) between keepalive pings sent on each open subscription
@@ -317,6 +364,15 @@ interface LunoraClientOptions {
317
364
  * `0` (or a negative value) to disable the heartbeat entirely.
318
365
  */
319
366
  heartbeatIntervalMs?: number;
367
+ /**
368
+ * When `true` and a `queryCache` is active, framework hooks (React, Vue, …)
369
+ * wait for the durable cache to finish hydrating before their first render
370
+ * with an enabled subscription, so users see cached data instead of an
371
+ * undefined flash before the socket round-trip. Defaults to `false`.
372
+ *
373
+ * Requires `queryCache` to be set (not `false`); silently ignored otherwise.
374
+ */
375
+ hydrateOnStart?: boolean;
320
376
  offlineQueue?: OfflineQueueOptions;
321
377
  /**
322
378
  * Durable outbox seam for offline writes. When supplied (the `@lunora/db`
@@ -1178,6 +1234,14 @@ interface MutationCallOptions<TCurrent = unknown, TValue = unknown, TArgs = unkn
1178
1234
  * once; every write is rolled back atomically if the mutation fails.
1179
1235
  */
1180
1236
  optimisticUpdate?: OptimisticUpdate<TArgs>;
1237
+ /**
1238
+ * Sync predicate evaluated just before the offline queue replays this
1239
+ * write on reconnect. When it returns `false` the mutation is dropped
1240
+ * instead of replayed — use it to guard against replaying writes whose
1241
+ * assumptions are no longer valid (e.g. the document it referred to was
1242
+ * deleted by another client while this tab was offline).
1243
+ */
1244
+ precondition?: () => boolean;
1181
1245
  shardKey?: string;
1182
1246
  }
1183
1247
  /** Callback a shape subscription invokes with its materialized rowset on every applied poke. */
@@ -1226,8 +1290,19 @@ type BatchSlot = {
1226
1290
  declare class LunoraClient {
1227
1291
  /** 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. */
1228
1292
  private static readonly MAX_POKE_BUFFERS;
1293
+ /**
1294
+ * Create a typed {@link ClientQueryRef}. Convenience wrapper around
1295
+ * {@link createClientQuery} so you don't need a separate import.
1296
+ * @example
1297
+ * ```ts
1298
+ * const sidebarOpen = LunoraClient.createClientQuery("sidebarOpen", true);
1299
+ * ```
1300
+ */
1301
+ static createClientQuery<T>(key: string, defaultValue: T): ClientQueryRef<T>;
1229
1302
  readonly url: string;
1230
1303
  readonly wsUrl: string;
1304
+ /** Local reactive store for {@link ClientQueryRef} values — no server round-trip. Private; reach it via `getClientQuery` / `setClientQuery` / `subscribeClientQuery`. */
1305
+ private readonly clientQueryStore;
1231
1306
  private wsToken;
1232
1307
  /** Better-auth base path (trailing slash stripped) for the `get-session` lookup. */
1233
1308
  private readonly authBasePath;
@@ -1249,6 +1324,20 @@ declare class LunoraClient {
1249
1324
  /** Stable per-client id stamped onto every `OutboxMutation` (custom-mutator watermark). */
1250
1325
  private readonly clientId;
1251
1326
  /**
1327
+ * `true` when the constructor's hydration microtask has finished loading the
1328
+ * durable read cache (Pillar 2) into `hydratedQueryCache`. Signals that
1329
+ * the cache is ready for synchronous `peekHydratedQuery` reads.
1330
+ */
1331
+ private readyResolved;
1332
+ /** Resolvers for `whenReady()` — called once hydration completes. */
1333
+ private readyResolve;
1334
+ /**
1335
+ * Promise that resolves once the durable read cache has been loaded. When
1336
+ * `hydrateOnStart` is not set or no query cache is configured, resolves
1337
+ * immediately (the constructor creates an already-resolved promise).
1338
+ */
1339
+ private readonly readyPromise;
1340
+ /**
1252
1341
  * Highest custom-mutator watermark the server has echoed for this client,
1253
1342
  * keyed by shard bucket (`shardKey ?? ""`) since the DO tracks one
1254
1343
  * `__client_watermark` per shard. `callMutator` bumps it from every
@@ -1281,6 +1370,12 @@ declare class LunoraClient {
1281
1370
  private readonly pendingCacheWrites;
1282
1371
  private cacheFlushTimer;
1283
1372
  private readonly subscriptions;
1373
+ /**
1374
+ * Cross-tab coordinator; created only when `crossTabSync: true`. When the
1375
+ * client is not the elected leader, all WebSocket operations are skipped.
1376
+ * Not `readonly` — `close()` clears it (mirrors `outboxLeaderRelease`).
1377
+ */
1378
+ private tabCoordinator;
1284
1379
  /** One {@link ShardConnection} per shard key (keyed by `shardKey ?? ""`). */
1285
1380
  private readonly connections;
1286
1381
  /** Default `connect`-envelope context applied to a shard with no explicit override. */
@@ -1559,6 +1654,93 @@ declare class LunoraClient {
1559
1654
  * unsubscribe function. See {@link MutationSettledEvent}.
1560
1655
  */
1561
1656
  onMutationSettled(listener: (event: MutationSettledEvent) => void): Unsubscribe;
1657
+ /**
1658
+ * Read the current value for a {@link ClientQueryRef}. Returns
1659
+ * `ref.defaultValue` when no value has been explicitly set.
1660
+ */
1661
+ getClientQuery<T>(ref: ClientQueryRef<T>): T;
1662
+ /**
1663
+ * Set a new value for `ref` and notify every subscriber. Pass `undefined`
1664
+ * to reset the slot to `ref.defaultValue`.
1665
+ */
1666
+ setClientQuery<T>(ref: ClientQueryRef<T>, value: T): void;
1667
+ /**
1668
+ * Subscribe to changes for `ref`. The callback is NOT invoked on
1669
+ * registration — call {@link getClientQuery} for the current value.
1670
+ * Returns an unsubscribe function.
1671
+ */
1672
+ subscribeClientQuery(ref: ClientQueryRef, callback: (value: unknown) => void): Unsubscribe;
1673
+ /**
1674
+ * Reset a {@link ClientQueryRef} to its default value, notifying every
1675
+ * subscriber. Equivalent to `setClientQuery(ref, ref.defaultValue)` but
1676
+ * removes the stored entry so a future {@link getClientQuery} returns
1677
+ * the default rather than an explicitly-set value.
1678
+ */
1679
+ resetClientQuery(ref: ClientQueryRef): void;
1680
+ /**
1681
+ * Capture a snapshot of the current live query value at call time and
1682
+ * produce a `() => boolean` precondition that compares it against the
1683
+ * value at replay time (on queue drain / reconnect).
1684
+ *
1685
+ * When the precondition is checked it re-reads the query's current value
1686
+ * via `peekActiveQueryValue`. If the value differs from what was
1687
+ * captured at call time the precondition returns `false` and the offline
1688
+ * mutation is dropped as stale.
1689
+ *
1690
+ * This is a method wrapper around `createSnapshotPrecondition` that
1691
+ * binds the client instance for you — no need to pass `client` explicitly.
1692
+ * @example
1693
+ * ```ts
1694
+ * client.mutation(api.todos.update, { id, text }, {
1695
+ * precondition: client.snapshotPrecondition(api.todos.list, { userId }),
1696
+ * });
1697
+ * ```
1698
+ */
1699
+ snapshotPrecondition(functionRef: FunctionReference, args: Record<string, unknown>, shardKey?: string): () => boolean;
1700
+ /**
1701
+ * Resolves once the durable read cache has been loaded into memory. When
1702
+ * `hydrateOnStart` is not configured or no query cache adapter is active,
1703
+ * returns an already-resolved promise so callers can always await it
1704
+ * unconditionally.
1705
+ *
1706
+ * Framework adapters (React, Vue, etc.) use this to gate the first
1707
+ * (enabled) render of a live query behind hydration, so the user sees
1708
+ * cached data instead of an undefined flash before the socket round-trip.
1709
+ */
1710
+ whenReady(): Promise<void>;
1711
+ /**
1712
+ * Synchronously reports whether {@link whenReady} has already resolved (the
1713
+ * durable read cache is loaded, or none is configured). Framework adapters
1714
+ * read this to seed the hydration-gate state on the first render without
1715
+ * awaiting, then subscribe via {@link whenReady} for the pending case.
1716
+ */
1717
+ get isReady(): boolean;
1718
+ /**
1719
+ * Synchronously peek at a value the durable read cache loaded for the given
1720
+ * function path + args + shard key. Returns `undefined` when:
1721
+ *
1722
+ * - No query cache adapter is configured.
1723
+ * - Hydration hasn't completed yet (race — await {@link whenReady} first).
1724
+ * - The cached value's identity fingerprint doesn't match the current auth.
1725
+ *
1726
+ * Unlike the internal {@link takeHydratedCache}, this is a READ-ONLY peek:
1727
+ * the cached entry stays in `hydratedQueryCache` so the subscription created
1728
+ * later by {@link subscribe} consumes it normally.
1729
+ */
1730
+ peekHydratedQuery(functionPath: string, args: Record<string, unknown>, shardKey?: string): unknown;
1731
+ /**
1732
+ * Peek at the **current live value** of an active subscription, if one
1733
+ * exists. Returns the subscription's `lastValue` (which includes any
1734
+ * optimistic overlay) or `undefined` if no subscription is active for the
1735
+ * given `(functionPath, args, shardKey)`.
1736
+ *
1737
+ * Unlike {@link peekHydratedQuery} (which reads from the durable read cache
1738
+ * and is independent of active subscriptions), this method reflects the
1739
+ * current in-memory state of an already-opened subscription — useful for
1740
+ * offline mutation preconditions that need to snapshot the value at call time
1741
+ * and compare it at replay time.
1742
+ */
1743
+ peekActiveQueryValue(functionPath: string, args: Record<string, unknown>, shardKey?: string): unknown;
1562
1744
  query<F extends FunctionReference>(function_: F, args: ArgsOf<F>, options?: {
1563
1745
  shardKey?: string;
1564
1746
  }): Promise<ReturnOf<F>>;
@@ -2537,4 +2719,4 @@ declare class LunoraClient {
2537
2719
  */
2538
2720
  private settleReplayBatchSlots;
2539
2721
  }
2540
- export { StreamHandle as $, ArgsOf as A, BookmarkStorage as B, CONFLICT_ERROR_CODE as C, DEFAULT_MAX_BUFFER as D, RpcEnvelope as E, FunctionReference as F, GlobalFacetResult as G, RpcResponseBody as H, ScheduleRecord as I, SchedulerPoolStatus as J, SchedulerStatus as K, LunoraClient as L, MutationCallOptions as M, ServerMessage as N, OfflineQueueOptions as O, Preloaded as P, QueryCacheAdapter as Q, ReturnOf as R, SubscriptionError as S, ServerPokeEndMessage as T, User as U, ServerPokePartMessage as V, ServerPokeStartMessage as W, ShardTrafficEntry as X, ShardTrafficResult as Y, StorageListPage as Z, StorageObject as _, Unsubscribe as a, StreamIterable as a0, SubscriptionCallback as a1, SubscriptionRegistry as a2, SubscriptionState as a3, SyncWatermark as a4, WorkflowInstanceAction as a5, WorkflowInstanceDetail as a6, WorkflowInstancePage as a7, WorkflowInstanceStatus as a8, WorkflowInstanceSummary as a9, WorkflowStepDetail as aa, createLocalStore as ab, createStream as ac, getErrorCode as ad, getRetryAfterMs as ae, isConflictError as af, isForbiddenError as ag, isRateLimitedError as ah, isUnauthorizedError as ai, SubscriptionErrorCallback as b, PersistenceAdapter as c, ReconnectOptions as d, BatchSlot as e, CachedQuery as f, ClientMessage as g, ClientShapeSubscribeMessage as h, ClientShapeUnsubscribeMessage as i, ConnectionStatus as j, FunctionArgumentDescriptor as k, FunctionDescriptor as l, GlobalFacetValue as m, GlobalFilterClause as n, GlobalTableInfo as o, GlobalTablePage as p, LunoraClientError as q, LunoraClientOptions as r, LunoraErrorCode as s, MutationSettledEvent as t, OptimisticLocalStore as u, OptimisticUpdate as v, OutboxMutation as w, OutboxSink as x, PersistedMutation as y, RowOp as z };
2722
+ export { StorageObject as $, ArgsOf as A, BookmarkStorage as B, CONFLICT_ERROR_CODE as C, DEFAULT_MAX_BUFFER as D, RowOp as E, FunctionReference as F, GlobalFacetResult as G, RpcEnvelope as H, RpcResponseBody as I, ScheduleRecord as J, SchedulerPoolStatus as K, LunoraClient as L, MutationCallOptions as M, SchedulerStatus as N, OfflineQueueOptions as O, Preloaded as P, QueryCacheAdapter as Q, ReturnOf as R, SubscriptionError as S, ServerMessage as T, User as U, ServerPokeEndMessage as V, ServerPokePartMessage as W, ServerPokeStartMessage as X, ShardTrafficEntry as Y, ShardTrafficResult as Z, StorageListPage as _, Unsubscribe as a, StreamHandle as a0, StreamIterable as a1, SubscriptionCallback as a2, SubscriptionRegistry as a3, SubscriptionState as a4, SyncWatermark as a5, WorkflowInstanceAction as a6, WorkflowInstanceDetail as a7, WorkflowInstancePage as a8, WorkflowInstanceStatus as a9, WorkflowInstanceSummary as aa, WorkflowStepDetail as ab, createClientQuery as ac, createLocalStore as ad, createStream as ae, getErrorCode as af, getRetryAfterMs as ag, isConflictError as ah, isForbiddenError as ai, isRateLimitedError as aj, isUnauthorizedError as ak, SubscriptionErrorCallback as b, PersistenceAdapter as c, ReconnectOptions as d, BatchSlot as e, CachedQuery as f, ClientMessage as g, ClientQueryRef as h, ClientShapeSubscribeMessage as i, ClientShapeUnsubscribeMessage as j, ConnectionStatus as k, FunctionArgumentDescriptor as l, FunctionDescriptor as m, GlobalFacetValue as n, GlobalFilterClause as o, GlobalTableInfo as p, GlobalTablePage as q, LunoraClientError as r, LunoraClientOptions as s, LunoraErrorCode as t, MutationSettledEvent as u, OptimisticLocalStore as v, OptimisticUpdate as w, OutboxMutation as x, OutboxSink as y, PersistedMutation as z };
@@ -1,5 +1,42 @@
1
1
  import { CronJobInfo, VectorIndexSummary, VectorQueryMatch, KvNamespaceSummary, KvKeyListResult, KvValueResult, AuthUser, AuthPage, AuthImpersonation, AuthCapabilities, AuthConfigInfo, AuthSession } from '@lunora/runtime';
2
2
  /**
3
+ * Reactive key-value store for local-only client state.
4
+ *
5
+ * Unlike a server {@link SubscriptionState} (which tracks a live WS connection,
6
+ * an `acked` flag, `serverBase`, optimistic layers, and the full subscription
7
+ * machinery), a `ClientQueryRef` is purely local — no server round-trip, no
8
+ * WebSocket, no persistence. It exists so framework adapters can offer a
9
+ * `useClientQuery` hook whose values survive component remounts and are shared
10
+ * across every consumer of the same ref, with none of the ceremony or coupling
11
+ * of a dedicated context provider.
12
+ *
13
+ * The store lives inside `LunoraClient` (a private field) and is surfaced through
14
+ * `client.getClientQuery(ref)` / `setClientQuery(ref, value)` /
15
+ * `subscribeClientQuery(ref, callback)`.
16
+ */
17
+ /** Opaque handle for a typed client-local query slot. */
18
+ interface ClientQueryRef<T = unknown> {
19
+ /** Default value when no value has been set explicitly. */
20
+ readonly defaultValue: T;
21
+ /** Stable identity for the slot. Must be unique within a client instance. */
22
+ readonly key: string;
23
+ }
24
+ /** A subscriber callback for value changes to a {@link ClientQueryRef}. */
25
+
26
+ /**
27
+ * Create a typed {@link ClientQueryRef}. Call once per slot at module scope
28
+ * (or inside a component module) — the ref object is the stable identity.
29
+ * @example
30
+ * ```ts
31
+ * // lunora/client-queries.ts
32
+ * import { createClientQuery } from "@lunora/client";
33
+ *
34
+ * export const sidebarOpen = createClientQuery("sidebarOpen", true);
35
+ * export const selectedMessageId = createClientQuery("selectedMessageId", undefined as string | undefined);
36
+ * ```
37
+ */
38
+ declare const createClientQuery: <T>(key: string, defaultValue: T) => ClientQueryRef<T>;
39
+ /**
3
40
  * The machine-readable error codes a client can observe on a failed
4
41
  * RPC/batch/subscription. Mirrors the server's `CODE_STATUS` keys
5
42
  * (`@lunora/server`'s `error.ts`) by hand — the client is framework-neutral and
@@ -308,6 +345,16 @@ interface LunoraClientOptions {
308
345
  * Defaults to 10000 (10s); set to `0` (or negative) to disable.
309
346
  */
310
347
  connectTimeoutMs?: number;
348
+ /**
349
+ * When `true`, tabs sharing the same origin coordinate via BroadcastChannel
350
+ * so only one tab (the "leader") opens WebSocket connections to the server.
351
+ * Follower tabs receive subscription data through the channel instead.
352
+ *
353
+ * Reduces simultaneous WS connections, bandwidth, and cross-tab state drift.
354
+ * Requires `BroadcastChannel` (browser-only); silently ignored otherwise.
355
+ * Defaults to `false`.
356
+ */
357
+ crossTabSync?: boolean;
311
358
  fetch?: typeof fetch;
312
359
  /**
313
360
  * Interval (ms) between keepalive pings sent on each open subscription
@@ -317,6 +364,15 @@ interface LunoraClientOptions {
317
364
  * `0` (or a negative value) to disable the heartbeat entirely.
318
365
  */
319
366
  heartbeatIntervalMs?: number;
367
+ /**
368
+ * When `true` and a `queryCache` is active, framework hooks (React, Vue, …)
369
+ * wait for the durable cache to finish hydrating before their first render
370
+ * with an enabled subscription, so users see cached data instead of an
371
+ * undefined flash before the socket round-trip. Defaults to `false`.
372
+ *
373
+ * Requires `queryCache` to be set (not `false`); silently ignored otherwise.
374
+ */
375
+ hydrateOnStart?: boolean;
320
376
  offlineQueue?: OfflineQueueOptions;
321
377
  /**
322
378
  * Durable outbox seam for offline writes. When supplied (the `@lunora/db`
@@ -1178,6 +1234,14 @@ interface MutationCallOptions<TCurrent = unknown, TValue = unknown, TArgs = unkn
1178
1234
  * once; every write is rolled back atomically if the mutation fails.
1179
1235
  */
1180
1236
  optimisticUpdate?: OptimisticUpdate<TArgs>;
1237
+ /**
1238
+ * Sync predicate evaluated just before the offline queue replays this
1239
+ * write on reconnect. When it returns `false` the mutation is dropped
1240
+ * instead of replayed — use it to guard against replaying writes whose
1241
+ * assumptions are no longer valid (e.g. the document it referred to was
1242
+ * deleted by another client while this tab was offline).
1243
+ */
1244
+ precondition?: () => boolean;
1181
1245
  shardKey?: string;
1182
1246
  }
1183
1247
  /** Callback a shape subscription invokes with its materialized rowset on every applied poke. */
@@ -1226,8 +1290,19 @@ type BatchSlot = {
1226
1290
  declare class LunoraClient {
1227
1291
  /** 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. */
1228
1292
  private static readonly MAX_POKE_BUFFERS;
1293
+ /**
1294
+ * Create a typed {@link ClientQueryRef}. Convenience wrapper around
1295
+ * {@link createClientQuery} so you don't need a separate import.
1296
+ * @example
1297
+ * ```ts
1298
+ * const sidebarOpen = LunoraClient.createClientQuery("sidebarOpen", true);
1299
+ * ```
1300
+ */
1301
+ static createClientQuery<T>(key: string, defaultValue: T): ClientQueryRef<T>;
1229
1302
  readonly url: string;
1230
1303
  readonly wsUrl: string;
1304
+ /** Local reactive store for {@link ClientQueryRef} values — no server round-trip. Private; reach it via `getClientQuery` / `setClientQuery` / `subscribeClientQuery`. */
1305
+ private readonly clientQueryStore;
1231
1306
  private wsToken;
1232
1307
  /** Better-auth base path (trailing slash stripped) for the `get-session` lookup. */
1233
1308
  private readonly authBasePath;
@@ -1249,6 +1324,20 @@ declare class LunoraClient {
1249
1324
  /** Stable per-client id stamped onto every `OutboxMutation` (custom-mutator watermark). */
1250
1325
  private readonly clientId;
1251
1326
  /**
1327
+ * `true` when the constructor's hydration microtask has finished loading the
1328
+ * durable read cache (Pillar 2) into `hydratedQueryCache`. Signals that
1329
+ * the cache is ready for synchronous `peekHydratedQuery` reads.
1330
+ */
1331
+ private readyResolved;
1332
+ /** Resolvers for `whenReady()` — called once hydration completes. */
1333
+ private readyResolve;
1334
+ /**
1335
+ * Promise that resolves once the durable read cache has been loaded. When
1336
+ * `hydrateOnStart` is not set or no query cache is configured, resolves
1337
+ * immediately (the constructor creates an already-resolved promise).
1338
+ */
1339
+ private readonly readyPromise;
1340
+ /**
1252
1341
  * Highest custom-mutator watermark the server has echoed for this client,
1253
1342
  * keyed by shard bucket (`shardKey ?? ""`) since the DO tracks one
1254
1343
  * `__client_watermark` per shard. `callMutator` bumps it from every
@@ -1281,6 +1370,12 @@ declare class LunoraClient {
1281
1370
  private readonly pendingCacheWrites;
1282
1371
  private cacheFlushTimer;
1283
1372
  private readonly subscriptions;
1373
+ /**
1374
+ * Cross-tab coordinator; created only when `crossTabSync: true`. When the
1375
+ * client is not the elected leader, all WebSocket operations are skipped.
1376
+ * Not `readonly` — `close()` clears it (mirrors `outboxLeaderRelease`).
1377
+ */
1378
+ private tabCoordinator;
1284
1379
  /** One {@link ShardConnection} per shard key (keyed by `shardKey ?? ""`). */
1285
1380
  private readonly connections;
1286
1381
  /** Default `connect`-envelope context applied to a shard with no explicit override. */
@@ -1559,6 +1654,93 @@ declare class LunoraClient {
1559
1654
  * unsubscribe function. See {@link MutationSettledEvent}.
1560
1655
  */
1561
1656
  onMutationSettled(listener: (event: MutationSettledEvent) => void): Unsubscribe;
1657
+ /**
1658
+ * Read the current value for a {@link ClientQueryRef}. Returns
1659
+ * `ref.defaultValue` when no value has been explicitly set.
1660
+ */
1661
+ getClientQuery<T>(ref: ClientQueryRef<T>): T;
1662
+ /**
1663
+ * Set a new value for `ref` and notify every subscriber. Pass `undefined`
1664
+ * to reset the slot to `ref.defaultValue`.
1665
+ */
1666
+ setClientQuery<T>(ref: ClientQueryRef<T>, value: T): void;
1667
+ /**
1668
+ * Subscribe to changes for `ref`. The callback is NOT invoked on
1669
+ * registration — call {@link getClientQuery} for the current value.
1670
+ * Returns an unsubscribe function.
1671
+ */
1672
+ subscribeClientQuery(ref: ClientQueryRef, callback: (value: unknown) => void): Unsubscribe;
1673
+ /**
1674
+ * Reset a {@link ClientQueryRef} to its default value, notifying every
1675
+ * subscriber. Equivalent to `setClientQuery(ref, ref.defaultValue)` but
1676
+ * removes the stored entry so a future {@link getClientQuery} returns
1677
+ * the default rather than an explicitly-set value.
1678
+ */
1679
+ resetClientQuery(ref: ClientQueryRef): void;
1680
+ /**
1681
+ * Capture a snapshot of the current live query value at call time and
1682
+ * produce a `() => boolean` precondition that compares it against the
1683
+ * value at replay time (on queue drain / reconnect).
1684
+ *
1685
+ * When the precondition is checked it re-reads the query's current value
1686
+ * via `peekActiveQueryValue`. If the value differs from what was
1687
+ * captured at call time the precondition returns `false` and the offline
1688
+ * mutation is dropped as stale.
1689
+ *
1690
+ * This is a method wrapper around `createSnapshotPrecondition` that
1691
+ * binds the client instance for you — no need to pass `client` explicitly.
1692
+ * @example
1693
+ * ```ts
1694
+ * client.mutation(api.todos.update, { id, text }, {
1695
+ * precondition: client.snapshotPrecondition(api.todos.list, { userId }),
1696
+ * });
1697
+ * ```
1698
+ */
1699
+ snapshotPrecondition(functionRef: FunctionReference, args: Record<string, unknown>, shardKey?: string): () => boolean;
1700
+ /**
1701
+ * Resolves once the durable read cache has been loaded into memory. When
1702
+ * `hydrateOnStart` is not configured or no query cache adapter is active,
1703
+ * returns an already-resolved promise so callers can always await it
1704
+ * unconditionally.
1705
+ *
1706
+ * Framework adapters (React, Vue, etc.) use this to gate the first
1707
+ * (enabled) render of a live query behind hydration, so the user sees
1708
+ * cached data instead of an undefined flash before the socket round-trip.
1709
+ */
1710
+ whenReady(): Promise<void>;
1711
+ /**
1712
+ * Synchronously reports whether {@link whenReady} has already resolved (the
1713
+ * durable read cache is loaded, or none is configured). Framework adapters
1714
+ * read this to seed the hydration-gate state on the first render without
1715
+ * awaiting, then subscribe via {@link whenReady} for the pending case.
1716
+ */
1717
+ get isReady(): boolean;
1718
+ /**
1719
+ * Synchronously peek at a value the durable read cache loaded for the given
1720
+ * function path + args + shard key. Returns `undefined` when:
1721
+ *
1722
+ * - No query cache adapter is configured.
1723
+ * - Hydration hasn't completed yet (race — await {@link whenReady} first).
1724
+ * - The cached value's identity fingerprint doesn't match the current auth.
1725
+ *
1726
+ * Unlike the internal {@link takeHydratedCache}, this is a READ-ONLY peek:
1727
+ * the cached entry stays in `hydratedQueryCache` so the subscription created
1728
+ * later by {@link subscribe} consumes it normally.
1729
+ */
1730
+ peekHydratedQuery(functionPath: string, args: Record<string, unknown>, shardKey?: string): unknown;
1731
+ /**
1732
+ * Peek at the **current live value** of an active subscription, if one
1733
+ * exists. Returns the subscription's `lastValue` (which includes any
1734
+ * optimistic overlay) or `undefined` if no subscription is active for the
1735
+ * given `(functionPath, args, shardKey)`.
1736
+ *
1737
+ * Unlike {@link peekHydratedQuery} (which reads from the durable read cache
1738
+ * and is independent of active subscriptions), this method reflects the
1739
+ * current in-memory state of an already-opened subscription — useful for
1740
+ * offline mutation preconditions that need to snapshot the value at call time
1741
+ * and compare it at replay time.
1742
+ */
1743
+ peekActiveQueryValue(functionPath: string, args: Record<string, unknown>, shardKey?: string): unknown;
1562
1744
  query<F extends FunctionReference>(function_: F, args: ArgsOf<F>, options?: {
1563
1745
  shardKey?: string;
1564
1746
  }): Promise<ReturnOf<F>>;
@@ -2537,4 +2719,4 @@ declare class LunoraClient {
2537
2719
  */
2538
2720
  private settleReplayBatchSlots;
2539
2721
  }
2540
- export { StreamHandle as $, ArgsOf as A, BookmarkStorage as B, CONFLICT_ERROR_CODE as C, DEFAULT_MAX_BUFFER as D, RpcEnvelope as E, FunctionReference as F, GlobalFacetResult as G, RpcResponseBody as H, ScheduleRecord as I, SchedulerPoolStatus as J, SchedulerStatus as K, LunoraClient as L, MutationCallOptions as M, ServerMessage as N, OfflineQueueOptions as O, Preloaded as P, QueryCacheAdapter as Q, ReturnOf as R, SubscriptionError as S, ServerPokeEndMessage as T, User as U, ServerPokePartMessage as V, ServerPokeStartMessage as W, ShardTrafficEntry as X, ShardTrafficResult as Y, StorageListPage as Z, StorageObject as _, Unsubscribe as a, StreamIterable as a0, SubscriptionCallback as a1, SubscriptionRegistry as a2, SubscriptionState as a3, SyncWatermark as a4, WorkflowInstanceAction as a5, WorkflowInstanceDetail as a6, WorkflowInstancePage as a7, WorkflowInstanceStatus as a8, WorkflowInstanceSummary as a9, WorkflowStepDetail as aa, createLocalStore as ab, createStream as ac, getErrorCode as ad, getRetryAfterMs as ae, isConflictError as af, isForbiddenError as ag, isRateLimitedError as ah, isUnauthorizedError as ai, SubscriptionErrorCallback as b, PersistenceAdapter as c, ReconnectOptions as d, BatchSlot as e, CachedQuery as f, ClientMessage as g, ClientShapeSubscribeMessage as h, ClientShapeUnsubscribeMessage as i, ConnectionStatus as j, FunctionArgumentDescriptor as k, FunctionDescriptor as l, GlobalFacetValue as m, GlobalFilterClause as n, GlobalTableInfo as o, GlobalTablePage as p, LunoraClientError as q, LunoraClientOptions as r, LunoraErrorCode as s, MutationSettledEvent as t, OptimisticLocalStore as u, OptimisticUpdate as v, OutboxMutation as w, OutboxSink as x, PersistedMutation as y, RowOp as z };
2722
+ export { StorageObject as $, ArgsOf as A, BookmarkStorage as B, CONFLICT_ERROR_CODE as C, DEFAULT_MAX_BUFFER as D, RowOp as E, FunctionReference as F, GlobalFacetResult as G, RpcEnvelope as H, RpcResponseBody as I, ScheduleRecord as J, SchedulerPoolStatus as K, LunoraClient as L, MutationCallOptions as M, SchedulerStatus as N, OfflineQueueOptions as O, Preloaded as P, QueryCacheAdapter as Q, ReturnOf as R, SubscriptionError as S, ServerMessage as T, User as U, ServerPokeEndMessage as V, ServerPokePartMessage as W, ServerPokeStartMessage as X, ShardTrafficEntry as Y, ShardTrafficResult as Z, StorageListPage as _, Unsubscribe as a, StreamHandle as a0, StreamIterable as a1, SubscriptionCallback as a2, SubscriptionRegistry as a3, SubscriptionState as a4, SyncWatermark as a5, WorkflowInstanceAction as a6, WorkflowInstanceDetail as a7, WorkflowInstancePage as a8, WorkflowInstanceStatus as a9, WorkflowInstanceSummary as aa, WorkflowStepDetail as ab, createClientQuery as ac, createLocalStore as ad, createStream as ae, getErrorCode as af, getRetryAfterMs as ag, isConflictError as ah, isForbiddenError as ai, isRateLimitedError as aj, isUnauthorizedError as ak, SubscriptionErrorCallback as b, PersistenceAdapter as c, ReconnectOptions as d, BatchSlot as e, CachedQuery as f, ClientMessage as g, ClientQueryRef as h, ClientShapeSubscribeMessage as i, ClientShapeUnsubscribeMessage as j, ConnectionStatus as k, FunctionArgumentDescriptor as l, FunctionDescriptor as m, GlobalFacetValue as n, GlobalFilterClause as o, GlobalTableInfo as p, GlobalTablePage as q, LunoraClientError as r, LunoraClientOptions as s, LunoraErrorCode as t, MutationSettledEvent as u, OptimisticLocalStore as v, OptimisticUpdate as w, OutboxMutation as x, OutboxSink as y, PersistedMutation as z };