@lunora/client 1.0.0-alpha.5 → 1.0.0-alpha.50

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 (86) hide show
  1. package/LICENSE.md +6 -0
  2. package/dist/auth/index.d.mts +11 -10
  3. package/dist/auth/index.d.ts +11 -10
  4. package/dist/auth/index.mjs +1 -60
  5. package/dist/index.d.mts +696 -216
  6. package/dist/index.d.ts +696 -216
  7. package/dist/index.mjs +1 -15
  8. package/dist/packem_shared/CONFLICT_ERROR_CODE-LjU7z0mB.mjs +1 -0
  9. package/dist/packem_shared/ClientServiceWorker-DXnBXeWK.mjs +1 -0
  10. package/dist/packem_shared/DEFAULT_MAX_BUFFER-D3QH2iaq.mjs +1 -0
  11. package/dist/packem_shared/LunoraClient-CWDiiVcr.mjs +1 -0
  12. package/dist/packem_shared/OfflineQueue-CoRNJFKm.mjs +1 -0
  13. package/dist/packem_shared/RETIRE_AFTER_DURABLE_SEQ_ADVANCE-DoEHtuR8.mjs +1 -0
  14. package/dist/packem_shared/SKIP-d7LeP-sY.mjs +1 -0
  15. package/dist/packem_shared/SubscriptionRegistry-D5Xu-a39.mjs +1 -0
  16. package/dist/packem_shared/TabCoordinator-DVKG2RlZ.mjs +1 -0
  17. package/dist/packem_shared/anyApi-CBOws2ZA.mjs +1 -0
  18. package/dist/packem_shared/applyDelta-DGqMpi3N.mjs +1 -0
  19. package/dist/packem_shared/createAsyncStoragePersistence-yBu2KWAp.mjs +1 -0
  20. package/dist/packem_shared/createClientQuery-TKD_52cT.mjs +1 -0
  21. package/dist/packem_shared/createInMemoryBookmarkStorage-BooZhW0n.mjs +1 -0
  22. package/dist/packem_shared/createInMemoryPersistence-BPdQiujT.mjs +1 -0
  23. package/dist/packem_shared/createInMemoryQueryCache-HMIq2XtS.mjs +1 -0
  24. package/dist/packem_shared/createLocalStore-C_UKH_Ok.mjs +1 -0
  25. package/dist/packem_shared/createMutationRunner-C0i1P5RS.mjs +1 -0
  26. package/dist/packem_shared/createMutatorRunner-CuMRJpUx.mjs +1 -0
  27. package/dist/packem_shared/createReconnect-CjTmjJDH.mjs +1 -0
  28. package/dist/packem_shared/createReply-CwFABEJs.mjs +1 -0
  29. package/dist/packem_shared/createServerClient-BxPCzuUt.mjs +1 -0
  30. package/dist/packem_shared/createSnapshotPrecondition-CP3e1HVS.mjs +1 -0
  31. package/dist/packem_shared/delta-merge-BoVuM-rE.mjs +1 -0
  32. package/dist/packem_shared/deserializePreloaded-CHfOFpka.mjs +1 -0
  33. package/dist/packem_shared/getServerSession-BaOoo1p6.mjs +1 -0
  34. package/dist/packem_shared/httpStream-x6q-x4yK.mjs +11 -0
  35. package/dist/packem_shared/idb-utility-C8WS390w.mjs +1 -0
  36. package/dist/packem_shared/local-store-BD0RFD7A.mjs +1 -0
  37. package/dist/packem_shared/lunora-client.d-BOmxvSrG.d.mts +2251 -0
  38. package/dist/packem_shared/lunora-client.d-K16SL8Tj.d.ts +2251 -0
  39. package/dist/packem_shared/offline-queue-BdQFXDz_.mjs +1 -0
  40. package/dist/packem_shared/preload.d-BprjXhJi.d.mts +21 -0
  41. package/dist/packem_shared/preload.d-CM8-Mg2A.d.ts +21 -0
  42. package/dist/packem_shared/preloadQuery-uFy24PCR.mjs +1 -0
  43. package/dist/packem_shared/types.d-CsuRAQvb.d.mts +976 -0
  44. package/dist/packem_shared/types.d-CsuRAQvb.d.ts +976 -0
  45. package/dist/packem_shared/wire-codec-D4iww4NV.mjs +1 -0
  46. package/dist/packem_shared/wire-key-BEmRM5ls.mjs +1 -0
  47. package/dist/pagination/index.d.mts +42 -42
  48. package/dist/pagination/index.d.ts +42 -42
  49. package/dist/pagination/index.mjs +1 -61
  50. package/dist/query/index.d.mts +110 -43
  51. package/dist/query/index.d.ts +110 -43
  52. package/dist/query/index.mjs +1 -1
  53. package/dist/service.d.mts +49 -0
  54. package/dist/service.d.ts +49 -0
  55. package/dist/service.mjs +1 -0
  56. package/dist/ssr/index.d.mts +100 -79
  57. package/dist/ssr/index.d.ts +100 -79
  58. package/dist/ssr/index.mjs +1 -4
  59. package/dist/upload.d.mts +35 -0
  60. package/dist/upload.d.ts +35 -0
  61. package/dist/upload.mjs +1 -0
  62. package/package.json +13 -1
  63. package/dist/packem_shared/CONFLICT_ERROR_CODE-aUdVbEDw.mjs +0 -4
  64. package/dist/packem_shared/DEFAULT_MAX_BUFFER-BDkqO5PW.mjs +0 -107
  65. package/dist/packem_shared/LunoraClient-BPQx0T7W.mjs +0 -2497
  66. package/dist/packem_shared/OfflineQueue-D-ASeqL7.mjs +0 -131
  67. package/dist/packem_shared/SKIP-vItZChkw.mjs +0 -50
  68. package/dist/packem_shared/SubscriptionRegistry-Dn-7k7eo.mjs +0 -1
  69. package/dist/packem_shared/applyDelta-4jFGTPA3.mjs +0 -61
  70. package/dist/packem_shared/createAsyncStoragePersistence-1Z5BZ8RC.mjs +0 -45
  71. package/dist/packem_shared/createInMemoryBookmarkStorage-BoN7a7TH.mjs +0 -11
  72. package/dist/packem_shared/createInMemoryPersistence-CW82inU5.mjs +0 -105
  73. package/dist/packem_shared/createInMemoryQueryCache-B1PQ9Twl.mjs +0 -138
  74. package/dist/packem_shared/createLocalStore-DSUfoLqY.mjs +0 -36
  75. package/dist/packem_shared/createMutationRunner-BqsavzvG.mjs +0 -21
  76. package/dist/packem_shared/createMutatorRunner-BETvCd0p.mjs +0 -31
  77. package/dist/packem_shared/createReconnect-Di_-oHH7.mjs +0 -22
  78. package/dist/packem_shared/createServerClient-BgzEOQ46.mjs +0 -11
  79. package/dist/packem_shared/deserializePreloaded-C0eJTY_W.mjs +0 -4
  80. package/dist/packem_shared/getServerSession-8jXewqxd.mjs +0 -13
  81. package/dist/packem_shared/lunora-client.d-CL9inoxa.d.mts +0 -1941
  82. package/dist/packem_shared/lunora-client.d-CL9inoxa.d.ts +0 -1941
  83. package/dist/packem_shared/preload.d-B0O7Zm9z.d.ts +0 -20
  84. package/dist/packem_shared/preload.d-Da_yjk6L.d.mts +0 -20
  85. package/dist/packem_shared/preloadQuery-lobFkD2Z.mjs +0 -13
  86. package/dist/packem_shared/subscription-C1Jy7HiF.mjs +0 -55
package/dist/index.d.mts CHANGED
@@ -1,13 +1,104 @@
1
- import { c as PersistenceAdapter, B as BookmarkStorage, F as FunctionReference, A as ArgsOf, M as MutationCallOptions, R as ReturnOf, O as OfflineQueueOptions, Q as QueryCacheAdapter, d as ReconnectOptions } from "./packem_shared/lunora-client.d-CL9inoxa.mjs";
2
- export { type C as CachedQuery, type e as ClientMessage, type f as ClientShapeSubscribeMessage, type g as ClientShapeUnsubscribeMessage, type h as ConnectionStatus, D as DEFAULT_MAX_BUFFER, type i as FunctionArgumentDescriptor, type j as FunctionDescriptor, type G as GlobalFacetResult, type k as GlobalFacetValue, type l as GlobalFilterClause, type m as GlobalTableInfo, type n as GlobalTablePage, L as LunoraClient, type o as LunoraClientOptions, type p as OptimisticLocalStore, type q as OptimisticUpdate, type r as OutboxMutation, type s as OutboxSink, type t as PersistedMutation, type P as Preloaded, type u as RowOp, type v as RpcEnvelope, type w as RpcResponseBody, type x as ScheduleRecord, type y as SchedulerPoolStatus, type z as SchedulerStatus, type E as ServerMessage, type H as ServerPokeEndMessage, type I as ServerPokePartMessage, type J as ServerPokeStartMessage, type K as ShardTrafficEntry, type N as ShardTrafficResult, type T as StorageListPage, type V as StorageObject, type W as StreamHandle, type X as StreamIterable, type Y as SubscriptionCallback, type S as SubscriptionError, type b as SubscriptionErrorCallback, Z as SubscriptionRegistry, type _ as SubscriptionState, type $ as SyncWatermark, type a as Unsubscribe, type U as User, type a0 as WorkflowInstanceAction, type a1 as WorkflowInstanceDetail, type a2 as WorkflowInstancePage, type a3 as WorkflowInstanceStatus, type a4 as WorkflowInstanceSummary, type a5 as WorkflowStepDetail, a6 as createLocalStore, a7 as createStream } from "./packem_shared/lunora-client.d-CL9inoxa.mjs";
3
- export { p as preloadQuery, a as preloadedQueryResult } from "./packem_shared/preload.d-Da_yjk6L.mjs";
4
- export type { AuthCapabilities, AuthImpersonation, AuthPage, AuthSession, AuthUser, CronJobInfo, VectorIndexSummary, VectorQueryMatch } from '@lunora/runtime';
5
- /**
6
- * The slice of React Native's `AsyncStorage` (or any async key/value store
7
- * Expo `SecureStore`, a wrapped `localForage`, an in-memory map in tests) this
8
- * adapter needs. Matches `@react-native-async-storage/async-storage`'s core
9
- * surface, so you can pass the module straight in.
10
- */
1
+ import { o as PersistenceAdapter, B as BookmarkStorage, H as HttpStreamRef, m as HttpStreamArgsOf, n as HttpStreamChunkOf, F as FunctionReference, A as ArgsOf, R as ReturnOf, O as OfflineQueueOptions, Q as QueryCacheAdapter, p as ReconnectOptions } from "./packem_shared/types.d-CsuRAQvb.mjs";
2
+ export type { C as CachedQuery, q as ClientMessage, r as ClientShapeSubscribeMessage, s as ClientShapeUnsubscribeMessage, t as FunctionArgumentDescriptor, h as FunctionDescriptor, l as GlobalFacetResult, u as GlobalFacetValue, j as GlobalFilterClause, G as GlobalTableInfo, k as GlobalTablePage, v as HttpStreamCallArgs, L as LunoraClientOptions, w as OutboxMutation, x as OutboxSink, y as PersistedMutation, P as Preloaded, z as RowOp, D as RpcEnvelope, E as RpcResponseBody, b as ScheduleRecord, I as SchedulerPoolStatus, c as SchedulerStatus, J as ServerMessage, K as ServerPokeEndMessage, M as ServerPokePartMessage, N as ServerPokeStartMessage, T as ShardTrafficEntry, S as ShardTrafficResult, i as StorageListPage, V as StorageObject, a as Unsubscribe, U as User, g as WorkflowInstanceAction, f as WorkflowInstanceDetail, e as WorkflowInstancePage, d as WorkflowInstanceStatus, X as WorkflowInstanceSummary, Y as WorkflowStepDetail, W as WsTokenProvider } from "./packem_shared/types.d-CsuRAQvb.mjs";
3
+ import { C as ConnectionStatus, S as SubscriptionError, b as StreamIterable, M as MutationCallOptions, L as LunoraClient } from "./packem_shared/lunora-client.d-BOmxvSrG.mjs";
4
+ export { type B as BatchSlot, c as CONFLICT_ERROR_CODE, type d as ClientDebugShard, type e as ClientDebugSnapshot, type f as ClientDebugSubscription, type g as ClientQueryRef, D as DEFAULT_MAX_BUFFER, type h as LunoraClientError, type i as LunoraErrorCode, type j as MutationSettledEvent, type O as OptimisticLocalStore, type k as OptimisticUpdate, type l as StreamHandle, type m as SubscriptionCallback, type a as SubscriptionErrorCallback, n as SubscriptionRegistry, type o as SubscriptionState, type p as SyncWatermark, q as createClientQuery, r as createLocalStore, s as createStream, t as getErrorCode, u as getRetryAfterMs, v as isConflictError, w as isForbiddenError, x as isRateLimitedError, y as isUnauthorizedError } from "./packem_shared/lunora-client.d-BOmxvSrG.mjs";
5
+ export { p as preloadQuery, a as preloadedQueryResult } from "./packem_shared/preload.d-BprjXhJi.mjs";
6
+ export type { AuthCapabilities, AuthConfigInfo, AuthImpersonation, AuthPage, AuthSession, AuthUser, AuthUserFieldSpec, CronJobInfo, KvKeyEntry, KvKeyListResult, KvNamespaceSummary, KvValueResult, PipelineLogColumnMap, PipelineLogCursor, PipelineLogField, PipelineLogPage, PipelineLogQuery, PipelineLogRow, VectorIndexSummary, VectorQueryMatch } from '@lunora/runtime';
7
+ declare const anyApi: Record<string, Record<string, unknown>>;
8
+ /**
9
+ * Optimistic-reconcile logic shared by every `@lunora/*` framework adapter's
10
+ * agent-chat surface (`@lunora/react` `useAgentChat`, plus the `@lunora/vue`,
11
+ * `@lunora/solid`, `@lunora/svelte`, and `@lunora/angular` counterparts). The
12
+ * adapters keep only their framework-specific state plumbing
13
+ * (`useState` / `.value` / `.set()` / a store) and delegate the pure merge decision
14
+ * here, so the heuristic lives — and is tested — in exactly one place.
15
+ */
16
+ /**
17
+ * The durable-message shape the reconcile reads: a structural subset of each
18
+ * adapter's `AgentChatMessage` (itself a client-safe mirror of `@lunora/agent`'s
19
+ * `AgentMessageRow`). Only `content`, `role`, and the globally-monotonic `seq` are
20
+ * consulted; adapters pass their fuller row type, which is assignable to this.
21
+ */
22
+ interface ReconcileDurableMessage {
23
+ content: string;
24
+ role: "assistant" | "system" | "tool" | "user";
25
+ seq: number;
26
+ }
27
+ /**
28
+ * A local optimistic user turn awaiting server acknowledgement. `id` is a
29
+ * client-generated handle the adapter uses to roll the row back on a failed send;
30
+ * the reconcile itself reads only `content` and `maxDurableSeqAtSend`.
31
+ */
32
+ interface OptimisticMessage {
33
+ content: string;
34
+ id: number;
35
+ /**
36
+ * The highest durable `seq` present when this row was sent. The reconcile
37
+ * retires the row when a durable `user` row with matching `content` lands at a
38
+ * STRICTLY GREATER `seq` (i.e. after the send) — window-independent because
39
+ * `seq` is globally monotonic, not a positional count. Also the age baseline for
40
+ * the {@link RETIRE_AFTER_DURABLE_SEQ_ADVANCE} fallback.
41
+ */
42
+ maxDurableSeqAtSend: number;
43
+ }
44
+ /**
45
+ * The highest `seq` across `messages`, or `-1` when empty. Used both to capture an
46
+ * optimistic row's `maxDurableSeqAtSend` at send time and to base the synthetic
47
+ * seqs of rendered optimistic rows above the highest real durable seq (not just
48
+ * `messages.length`, which under-counts when durable rows have gaps), so a
49
+ * placeholder seq never collides with a real one.
50
+ */
51
+ declare const maxSeq: (messages: ReadonlyArray<{
52
+ seq: number;
53
+ }>) => number;
54
+ /**
55
+ * Secondary, windowed fallback: retire a pending optimistic row once the durable
56
+ * history's highest `seq` has advanced at least this far past the value seen at
57
+ * send, even though no matching-content user row is currently visible to claim it.
58
+ * This covers the pathological "identical content already present at send time"
59
+ * case, where no user row with a STRICTLY GREATER `seq` than the send-time max will
60
+ * ever appear for the primary content match to consume (e.g. the acknowledging row
61
+ * was evicted by a bounded `limit` before reconcile could see it). `seq` is globally
62
+ * monotonic, so it keeps climbing even when the visible user-row COUNT does not.
63
+ *
64
+ * The `2` is a heuristic threshold, NOT an invariant about turn shape. It is
65
+ * tempting to read it as "one turn == a user row and an assistant row (+2)", but
66
+ * `@lunora/agent`'s tool-loop turns can persist MANY rows, and an ERRORED turn
67
+ * persists only the user row (+1). Those non-(+2) turns are retired by the PRIMARY
68
+ * seq-based content match below (which sees the user row land at a greater `seq`),
69
+ * never by this count-based fallback. The fully robust fix is a server-echoed
70
+ * correlation id on each persisted user row (deferred — see plan 188).
71
+ *
72
+ * KNOWN LIMITATION (inherent to a client-only heuristic): on an ownerless /
73
+ * `instanceId`-less thread a FOREIGN writer that advances `seq` by >= 2 between this
74
+ * row's send and its own acknowledgement can trip this fallback and retire the row a
75
+ * beat early. The correlation id closes that gap; until then it is the accepted
76
+ * residual edge.
77
+ */
78
+ declare const RETIRE_AFTER_DURABLE_SEQ_ADVANCE = 2;
79
+ /**
80
+ * Drop the optimistic user turns the durable history has now caught up on. Pure —
81
+ * it reads only the two arrays plus values captured on each pending row at send
82
+ * time (no clock, no state mutation), so it is safe to run in render.
83
+ *
84
+ * The primary retire condition: a durable `user` row with the same `content` exists
85
+ * whose `seq` is STRICTLY GREATER than the row's `maxDurableSeqAtSend` — i.e. a user
86
+ * row that landed AFTER the send. This is window-independent (it matches on monotonic
87
+ * `seq`, not a positional count), so it retires the normal turn AND an errored
88
+ * single-row (+1) turn even under a saturated, sliding `limit`. Each durable row is
89
+ * consumed at most once (the `consumed` set), so repeated identical prompts sent
90
+ * back-to-back each wait for their OWN durable row instead of collapsing onto one.
91
+ *
92
+ * Failing that, the {@link RETIRE_AFTER_DURABLE_SEQ_ADVANCE} windowed fallback fires
93
+ * (see its doc) for the "identical content already present" pathological case.
94
+ */
95
+ declare const reconcileOptimistic: (optimistic: ReadonlyArray<OptimisticMessage>, durable: ReadonlyArray<ReconcileDurableMessage>) => OptimisticMessage[];
96
+ /**
97
+ * The slice of React Native's `AsyncStorage` (or any async key/value store —
98
+ * Expo `SecureStore`, a wrapped `localForage`, an in-memory map in tests) this
99
+ * adapter needs. Matches `@react-native-async-storage/async-storage`'s core
100
+ * surface, so you can pass the module straight in.
101
+ */
11
102
  interface AsyncStorageLike {
12
103
  getItem: (key: string) => Promise<string | null>;
13
104
  removeItem: (key: string) => Promise<void>;
@@ -20,46 +111,238 @@ interface AsyncStoragePersistenceOptions {
20
111
  storage: AsyncStorageLike;
21
112
  }
22
113
  /**
23
- * Builds a {@link PersistenceAdapter} over an async key/value store — the React
24
- * Native / Expo counterpart to the IndexedDB adapter (`createIndexedDbPersistence`).
25
- * The whole FIFO mutation log is serialized to JSON under a single key (`key`),
26
- * so enqueue order is preserved and `load()` returns freshly-parsed records that
27
- * callers can't alias.
28
- *
29
- * AsyncStorage has no transactions, so every read-modify-write is funnelled
30
- * through a single promise chain — concurrent `append`/`remove` calls run one at
31
- * a time and can't clobber each other's writes.
32
- */
114
+ * Builds a {@link PersistenceAdapter} over an async key/value store — the React
115
+ * Native / Expo counterpart to the IndexedDB adapter (`createIndexedDbPersistence`).
116
+ * The whole FIFO mutation log is serialized to JSON under a single key (`key`),
117
+ * so enqueue order is preserved and `load()` returns freshly-parsed records that
118
+ * callers can't alias.
119
+ *
120
+ * AsyncStorage has no transactions, so every read-modify-write is funnelled
121
+ * through a single promise chain — concurrent `append`/`remove` calls run one at
122
+ * a time and can't clobber each other's writes.
123
+ */
33
124
  declare const createAsyncStoragePersistence: (options: AsyncStoragePersistenceOptions) => PersistenceAdapter;
34
125
  /** Default in-memory bookmark store. Survives the lifetime of the client. */
35
126
  declare const createInMemoryBookmarkStorage: () => BookmarkStorage;
127
+ interface TabCoordinatorOptions {
128
+ /**
129
+ * BroadcastChannel name. Defaults to `"lunora-bridge"`.
130
+ */
131
+ channelName?: string;
132
+ /**
133
+ * Interval (ms) between leader heartbeats. Defaults to 1000.
134
+ */
135
+ heartbeatInterval?: number;
136
+ /**
137
+ * Milliseconds without a heartbeat to consider the leader dead. Must be
138
+ * larger than `heartbeatInterval`. Defaults to 3000.
139
+ */
140
+ leaderTimeout?: number;
141
+ /**
142
+ * Called when this tab becomes the leader (should open WS connections).
143
+ */
144
+ onBecomeLeader?: () => void;
145
+ /**
146
+ * Called when the leader broadcasts its aggregate `ConnectionStatus`
147
+ * (see `LunoraClient.emitConnectionStatus`) — a follower owns no socket
148
+ * of its own, so this is its only truthful signal for a status indicator
149
+ * or the offline-queue gate. Sent on every leader-side status change and
150
+ * once more right after a new leader takes over, so a follower already
151
+ * mid-mirroring isn't stuck on a stale value from the PREVIOUS leader.
152
+ *
153
+ * `identity` is the leader's own `identityFingerprint()` (`null` = signed
154
+ * out) — the channel-name scoping (see `LunoraClient.createTabCoordinator`)
155
+ * is the primary defence against a stale frame from a since-changed
156
+ * identity, but `setAuthToken` in one tab can move it to a new channel
157
+ * while this frame is already queued in another tab's message task queue.
158
+ * A follower drops the frame when `identity` is present and doesn't match
159
+ * its own; an **absent** `identity` (mixed-version leader) is accepted —
160
+ * today's behavior, and the channel split already separates version groups.
161
+ */
162
+ onConnectionStatus?: (status: ConnectionStatus, identity?: string | null) => void;
163
+ /**
164
+ * Called when this tab loses leadership (should close WS connections).
165
+ */
166
+ onStopBeingLeader?: () => void;
167
+ /**
168
+ * Called when the leader broadcasts subscription data. `cursor`/`epoch`
169
+ * ride along when the leader is CLIENT-01-aware, so the follower can drop
170
+ * its own confirmed optimistic layers instead of just displaying the raw
171
+ * value; both are absent on a mixed-version leader that hasn't shipped the
172
+ * cursor yet (the follower falls back to its historical behavior — see
173
+ * `lunora-client.ts`'s `onSubscriptionData` wiring).
174
+ *
175
+ * `identity` is the leader's `identityFingerprint()` (see
176
+ * `onConnectionStatus`'s docblock for the full rationale and the
177
+ * absent-field mixed-version rule — identical here).
178
+ */
179
+ onSubscriptionData?: (key: string, data: unknown, cursor?: number, epoch?: string, identity?: string | null) => void;
180
+ /**
181
+ * Called when the leader broadcasts a subscription error.
182
+ */
183
+ onSubscriptionError?: (key: string, error: SubscriptionError) => void;
184
+ /**
185
+ * Called when the leader broadcasts a `settled` frame's checkpoint advance
186
+ * (no value change, but the resume cursor moved). A follower needs this
187
+ * too — otherwise a `setQuery`/per-call optimistic overlay that a
188
+ * byte-identical write just confirmed stays masked on follower tabs until
189
+ * the next VISIBLE data frame arrives, even though the leader already
190
+ * dropped it.
191
+ *
192
+ * `lastMutationId` is the LEADER's own per-client `__client_watermark` —
193
+ * scoped server-side to the socket's announced `clientId` — so it only
194
+ * means anything to a follower whose `clientId` matches the leader's
195
+ * (`clientId` rides along for exactly that comparison; see
196
+ * `LunoraClient`'s wiring). An **absent** `clientId` (a mixed-version
197
+ * leader that hasn't shipped this field yet) also skips the
198
+ * `mutationId` half — safe, because the follower's own gates still
199
+ * resolve via its own RPC-ack watermark path and the `CheckpointRegistry`
200
+ * bounded fallback. The checkpoint (cursor) half of this callback fires
201
+ * unconditionally regardless of `clientId` — only the `mutationId` half
202
+ * is scoped.
203
+ *
204
+ * `identity` is the leader's `identityFingerprint()` — see
205
+ * `onConnectionStatus`'s docblock for the drop rule (identical here).
206
+ */
207
+ onSubscriptionSettled?: (key: string, cursor?: number, epoch?: string, lastMutationId?: number, clientId?: string, identity?: string | null) => void;
208
+ }
209
+ declare class TabCoordinator {
210
+ private readonly bc;
211
+ private readonly tabId;
212
+ private readonly heartbeatInterval;
213
+ private readonly leaderTimeout;
214
+ /** The tab id of the current known leader, or `undefined` if no leader. */
215
+ private knownLeader;
216
+ /** `true` when this tab believes it is the leader. */
217
+ private leader;
218
+ /** `true` once `start()` has been called. */
219
+ private running;
220
+ /**
221
+ * `true` while a `claimAndPromote()` claim window is open. Guards the yield
222
+ * handler and `checkLeaderHealth`'s belt-and-braces else-branch so the two
223
+ * promotion paths can't both arm a `becomeLeader` timeout for the same gap.
224
+ */
225
+ private promotionPending;
226
+ /** Timestamp of the most recent leader heartbeat. */
227
+ private lastHeartbeat;
228
+ private heartbeatTimer;
229
+ private leaderCheckTimer;
230
+ /** Callbacks set via constructor options. */
231
+ private readonly onBecomeLeader;
232
+ private readonly onStopBeingLeader;
233
+ private readonly onConnectionStatus;
234
+ private readonly onSubscriptionData;
235
+ private readonly onSubscriptionError;
236
+ private readonly onSubscriptionSettled;
237
+ constructor(options?: TabCoordinatorOptions);
238
+ /**
239
+ * Start the coordinator: attempt to claim leadership and begin the
240
+ * heartbeat/leader-check cycle. Safe to call multiple times.
241
+ */
242
+ start(): void;
243
+ /**
244
+ * Stop the coordinator: yield leadership (if held), close the channel, and
245
+ * clear all timers. Safe to call multiple times.
246
+ */
247
+ stop(): void;
248
+ /** `true` when this tab is the current WebSocket leader. */
249
+ isLeader(): boolean;
250
+ /**
251
+ * Force this (freshly `start()`-ed) tab to become leader immediately,
252
+ * skipping the normal claim-then-`leaderTimeout` dance. Safe to call
253
+ * right after `start()` when the caller already knows this tab was the
254
+ * SOLE leader of the group it's replacing (e.g. `LunoraClient`'s
255
+ * identity-change coordinator restart — waiting out the full
256
+ * `leaderTimeout` there would freeze every live query for no reason,
257
+ * since this tab is overwhelmingly likely to be alone on the freshly
258
+ * derived channel). A sibling tab going through the normal `start()`
259
+ * dance for the SAME transition observes this tab's `becomeLeader`
260
+ * heartbeat and defers before its own claim-timeout fires; if another
261
+ * tab ALSO force-promotes at the same moment (e.g. two tabs both
262
+ * transitioning to the same new identity), the existing
263
+ * `resolveLeaderVsLeaderTieBreak` resolves the rare double-promotion
264
+ * the same way it resolves any other split-brain.
265
+ */
266
+ promoteImmediately(): void;
267
+ /** The tab id of the current leader, or `undefined` if unknown / no leader. */
268
+ get leaderTabId(): string | undefined;
269
+ /** The id of this tab. */
270
+ get id(): string;
271
+ /** `true` when the coordinator has been started and is not yet stopped. */
272
+ get isRunning(): boolean;
273
+ /**
274
+ * Broadcast subscription data to all follower tabs. Only the leader should
275
+ * call this. `cursor`/`epoch` are omitted from the wire frame when
276
+ * `undefined` (e.g. a CDC-off shard) — a follower on ANY version treats a
277
+ * missing `cursor` as "no confirmed-layer drop this frame", so omitting it
278
+ * here is equivalent to sending it as `undefined`. `identity` is this
279
+ * (leader) tab's own `identityFingerprint()` (`null` = signed out) —
280
+ * stamped so a follower can drop a frame from a since-changed identity
281
+ * (see the `onSubscriptionData` docblock); spread-included whenever
282
+ * supplied, since a fixed leader always knows its own identity (never
283
+ * omits it) and `null` must round-trip distinctly from "field absent".
284
+ */
285
+ broadcastSubscriptionData(key: string, data: unknown, cursor?: number, epoch?: string, identity?: string | null): void;
286
+ /**
287
+ * Broadcast a subscription error to all follower tabs. Only the leader
288
+ * should call this.
289
+ */
290
+ broadcastSubscriptionError(key: string, error: SubscriptionError): void;
291
+ /**
292
+ * Broadcast a `settled` frame's checkpoint advance to all follower tabs
293
+ * (no value change, but the resume cursor/epoch moved — see
294
+ * `LunoraClient.handleSettledMessage`). `clientId` is this (leader) tab's
295
+ * own client id, stamped so a follower can tell whether the echoed
296
+ * `lastMutationId` watermark is genuinely its own (see the
297
+ * `onSubscriptionSettled` docblock). `identity` is this tab's own
298
+ * `identityFingerprint()` — see `broadcastSubscriptionData`'s docblock for
299
+ * the same round-tripping rule. Only the leader should call this.
300
+ */
301
+ broadcastSubscriptionSettled(key: string, cursor?: number, epoch?: string, lastMutationId?: number, clientId?: string, identity?: string | null): void;
302
+ /**
303
+ * Broadcast this tab's aggregate `ConnectionStatus` to follower tabs, so
304
+ * they can mirror a truthful status without a socket of their own (see
305
+ * `LunoraClient.computeStatus`/`emitConnectionStatus`). `identity` is this
306
+ * tab's own `identityFingerprint()` — see `broadcastSubscriptionData`'s
307
+ * docblock for the same round-tripping rule. Only the leader should call
308
+ * this.
309
+ */
310
+ broadcastConnectionStatus(status: ConnectionStatus, identity?: string | null): void;
311
+ private broadcast;
312
+ private handleMessage;
313
+ private handleClaimLeadership;
314
+ private handleHeartbeat;
315
+ /**
316
+ * Resolve two leaders existing at once — e.g. this tab was backgrounded
317
+ * and its heartbeat/health timers were throttled while a foreground
318
+ * follower's were not, so the follower's `checkLeaderHealth` timed out
319
+ * the (still-alive) leader and self-promoted. `BroadcastChannel` message
320
+ * delivery isn't subject to the same timer-throttling clamp, so even a
321
+ * backgrounded leader eventually observes the pretender's heartbeat here
322
+ * — resolve the split-brain deterministically with the same
323
+ * lexicographically-smaller-tabId rule used at claim-adoption. If the
324
+ * other tab wins, step down; if we win, reassert immediately so the
325
+ * pretender demotes itself the moment it processes our heartbeat.
326
+ */
327
+ private resolveLeaderVsLeaderTieBreak;
328
+ private becomeLeader;
329
+ private sendHeartbeat;
330
+ private checkLeaderHealth;
331
+ /**
332
+ * Broadcast a leadership claim and arm a `becomeLeader` fallback: if no
333
+ * other tab has asserted itself as leader by the time the window elapses,
334
+ * self-promote. Shared by `checkLeaderHealth`'s stale-leader path and the
335
+ * `yield-leadership` handler so both promotion triggers use one claim
336
+ * window instead of each arming its own timeout.
337
+ */
338
+ private claimAndPromote;
339
+ }
36
340
  /**
37
- * Client-side incremental merging of structured mutation deltas.
38
- *
39
- * Lunora's live-query fan-out has two server paths:
40
- *
41
- * 1. Server re-execution (subscriptions carrying a `functionPath`) pushes a
42
- * full `data` snapshot whenever a write touches a table the query reads. These
43
- * already carry the authoritative result and are applied wholesale.
44
- * 2. Legacy delta fan-out (`broadcastDelta`) pushes a structured `MutationDelta`
45
- * as a `delta` frame to subscribers matched by table + args. The delta describes
46
- * a single row change (`insert` / `update` / `delete`) keyed by row id, so the
47
- * client can splice it into the cached list result without a full re-send.
48
- *
49
- * Historically the client treated the `delta` field as an opaque blob and
50
- * replaced the whole cached value with it on every message — which only made
51
- * sense for the rare delta payloads that already carried the full result. This
52
- * module lets the client recognise a structured delta and merge it into the
53
- * existing array (preserving order, no dup/loss), falling back to full
54
- * replacement when the payload isn't a recognisable row delta or can't be
55
- * applied cleanly against the current cached shape.
56
- */
57
- /**
58
- * One row change as emitted by `@lunora/do`'s `broadcastDelta`. Mirrors
59
- * `MutationDelta` in `@lunora/do` structurally so the client carries no
60
- * dependency on it. `row` is absent on `delete` events (and may be absent on
61
- * older servers for any op).
62
- */
341
+ * One row change as emitted by `@lunora/do`'s `broadcastDelta`. Mirrors
342
+ * `MutationDelta` in `@lunora/do` structurally so the client carries no
343
+ * dependency on it. `row` is absent on `delete` events (and may be absent on
344
+ * older servers for any op).
345
+ */
63
346
  interface MutationDelta {
64
347
  /** Row id (`_id`) the change applies to. */
65
348
  key: string;
@@ -68,54 +351,74 @@ interface MutationDelta {
68
351
  table: string;
69
352
  }
70
353
  /**
71
- * Structural guard: is `value` a `MutationDelta` the client knows how to merge?
72
- * We require `op`, `table`, and a string `key` so opaque payloads that merely
73
- * happen to be objects (e.g. an aggregate `{ count: 1 }` a query returns
74
- * verbatim) are never mistaken for a row delta and keep replacing the cached
75
- * value wholesale.
76
- */
354
+ * Structural guard: is `value` a `MutationDelta` the client knows how to merge?
355
+ * We require `op`, `table`, and a string `key` so opaque payloads that merely
356
+ * happen to be objects (e.g. an aggregate `{ count: 1 }` a query returns
357
+ * verbatim) are never mistaken for a row delta and keep replacing the cached
358
+ * value wholesale.
359
+ */
77
360
  declare const isMutationDelta: (value: unknown) => value is MutationDelta;
78
361
  /**
79
- * Apply a structured `MutationDelta` to a cached array result, returning a new
80
- * array (never mutating the input). Returns `undefined` when the delta can't be
81
- * applied cleanly — the caller should then fall back to the existing
82
- * full-replacement behaviour (or trust the next snapshot to reconcile).
83
- *
84
- * Mergeable shape: a plain array of id-bearing row objects, e.g. the result of
85
- * `db.query().collect()`.
86
- *
87
- * Insert / update / delete are matched by row `_id`:
88
- * - `insert`: appended (or placed by `_creationTime` order) if absent; treated
89
- * as an update if a row with the same id already exists (idempotent — guards
90
- * against a delta replayed after a snapshot already included it).
91
- * - `update`: replaces the matching row in place, preserving its position.
92
- * - `delete`: removes the matching row.
93
- *
94
- * Returns `undefined` when `current` isn't an array of id-keyable objects, or
95
- * when an `insert`/`update` delta carries no `row` to splice in.
96
- */
97
- declare const applyDelta: (current: unknown, delta: MutationDelta) => undefined | unknown[];
98
- /** Error code the server uses for optimistic-concurrency conflicts (HTTP 409). */
99
- declare const CONFLICT_ERROR_CODE = "CONFLICT";
100
- /**
101
- * Whether an unknown rejection is an optimistic-concurrency conflict — the
102
- * server lost a write race and the caller should refetch and retry (or surface
103
- * the conflict). Structural check on the `code` property the client attaches
104
- * when decoding the worker's `{ error: { code, message } }` envelope.
105
- */
106
- declare const isConflictError: (error: unknown) => error is Error & {
107
- code: "CONFLICT";
108
- };
362
+ * Apply a structured `MutationDelta` to a cached list result, returning a new
363
+ * value (never mutating the input). Returns `undefined` when the delta can't be
364
+ * applied cleanly — the caller should then fall back to the existing
365
+ * full-replacement behaviour (or trust the next snapshot to reconcile).
366
+ *
367
+ * Mergeable shapes: an array of id-bearing row objects, or a `.paginate()`
368
+ * result wrapping one in `page` (see `rowListOf`). A paginated value keeps its
369
+ * other fields and comes back as a new object with a new `page` — the server
370
+ * only sends row deltas for that shape when everything outside the page is
371
+ * unchanged, so the merged value matches the snapshot it chose not to send.
372
+ * @returns the merged value, or `undefined` when the delta cannot be applied
373
+ */
374
+ declare const applyDelta: (current: unknown, delta: MutationDelta) => Record<string, unknown> | undefined | unknown[];
375
+ /**
376
+ * Options accepted by {@link httpStream}.
377
+ * @experimental Part of the HTTP-SSE stream surface.
378
+ */
379
+ interface HttpStreamOptions {
380
+ /**
381
+ * Origin (or origin + prefix) the route path is appended to, e.g.
382
+ * `https://my-app.example.com`. Defaults to `""` — a relative URL, which
383
+ * resolves against the page origin in a browser.
384
+ */
385
+ baseUrl?: string;
386
+ /** `fetch` implementation override; defaults to the global `fetch`. */
387
+ fetch?: typeof fetch;
388
+ /** Extra request headers (e.g. `authorization`). `accept: text/event-stream` is always sent. */
389
+ headers?: Record<string, string>;
390
+ /** Caps the in-flight chunk buffer (see `createStream`); exceeding it fails the stream. */
391
+ maxBuffer?: number;
392
+ /** External abort signal — aborting it cancels the fetch (→ the server handler's `signal`). */
393
+ signal?: AbortSignal;
394
+ }
395
+ /**
396
+ * Open a typed HTTP-SSE stream route and iterate its chunks:
397
+ *
398
+ * ```ts
399
+ * const stream = httpStream(httpStreams.http.tokens, { searchParams: { prompt } }, { baseUrl });
400
+ * for await (const token of stream) {
401
+ * render(token); // typed as the route handler's yielded chunk
402
+ * }
403
+ * ```
404
+ *
405
+ * The returned iterable terminates when the server writes `event: complete`;
406
+ * an `event: error` frame (or a transport failure) surfaces as a coded
407
+ * rejection on the next `next()`. `.cancel()` — or aborting `options.signal` —
408
+ * aborts the underlying fetch, which the server observes via `request.signal`.
409
+ * @experimental Reconnect/POST-body/wire-fidelity design questions are still open, so the shape may change.
410
+ */
411
+ declare const httpStream: <Ref extends HttpStreamRef>(route: Ref, args?: HttpStreamArgsOf<Ref>, options?: HttpStreamOptions) => StreamIterable<HttpStreamChunkOf<Ref>>;
109
412
  /** The single transport method a mutation runner needs — narrowed so adapters can test against a stub. */
110
413
  interface MutationCapableClient<F extends FunctionReference> {
111
414
  mutation: (function_: F, args: ArgsOf<F>, options?: MutationCallOptions<unknown, unknown, ArgsOf<F>>) => Promise<ReturnOf<F>>;
112
415
  }
113
416
  /**
114
- * Reactive sinks an adapter binds to its own primitive's setters (a Solid
115
- * signal, a Vue ref, a Svelte store). The runner pushes into them; how they
116
- * store the value is the adapter's concern (e.g. Solid wraps function-valued
117
- * results in a thunk).
118
- */
417
+ * Reactive sinks an adapter binds to its own primitive's setters (a Solid
418
+ * signal, a Vue ref, a Svelte store). The runner pushes into them; how they
419
+ * store the value is the adapter's concern (e.g. Solid wraps function-valued
420
+ * results in a thunk).
421
+ */
119
422
  interface MutationRunnerSinks<R> {
120
423
  /** Receives the normalized {@link Error} when an invocation rejects. */
121
424
  setError: (error: Error) => void;
@@ -125,41 +428,41 @@ interface MutationRunnerSinks<R> {
125
428
  setResult: (result: R) => void;
126
429
  }
127
430
  /**
128
- * Build the framework-neutral `mutate` half of an adapter's mutation hook.
129
- *
130
- * Owns the orchestration every adapter otherwise copy-pastes: ref-counts
131
- * overlapping invocations into `setPending` (so it only clears once the last
132
- * settles), normalizes a thrown non-`Error`, and routes success/failure to
133
- * `setResult`/`setError` before re-throwing. Each adapter (`@lunora/react`,
134
- * `/solid`, `/svelte`, `/vue`) binds the three sinks to its own reactive
135
- * setters, so this logic lives in exactly one place. Optimistic-update options
136
- * pass straight through to `client.mutation`.
137
- */
431
+ * Build the framework-neutral `mutate` half of an adapter's mutation hook.
432
+ *
433
+ * Owns the orchestration every adapter otherwise copy-pastes: ref-counts
434
+ * overlapping invocations into `setPending` (so it only clears once the last
435
+ * settles), normalizes a thrown non-`Error`, and routes success/failure to
436
+ * `setResult`/`setError` before re-throwing. Each adapter (`@lunora/react`,
437
+ * `/solid`, `/svelte`, `/vue`) binds the three sinks to its own reactive
438
+ * setters, so this logic lives in exactly one place. Optimistic-update options
439
+ * pass straight through to `client.mutation`.
440
+ */
138
441
  declare const createMutationRunner: <F extends FunctionReference>(client: MutationCapableClient<F>, function_: F, sinks: MutationRunnerSinks<ReturnOf<F>>) => ((args: ArgsOf<F>, options?: MutationCallOptions<unknown, unknown, ArgsOf<F>>) => Promise<ReturnOf<F>>);
139
442
  /**
140
- * The structural surface of a TanStack `Transaction` a bound custom mutator
141
- * returns — its `isPersisted.promise` resolves once the write is persisted and
142
- * rejects on failure. Typed structurally so the framework adapters need not
143
- * depend on `@tanstack/db` or `@lunora/db` (the handle is created app-side by
144
- * `bindMutators`).
145
- */
443
+ * The structural surface of a TanStack `Transaction` a bound custom mutator
444
+ * returns — its `isPersisted.promise` resolves once the write is persisted and
445
+ * rejects on failure. Typed structurally so the framework adapters need not
446
+ * depend on `@tanstack/db` or `@lunora/db` (the handle is created app-side by
447
+ * `bindMutators`).
448
+ */
146
449
  interface MutatorTransaction {
147
450
  isPersisted: {
148
451
  promise: Promise<unknown>;
149
452
  };
150
453
  }
151
454
  /**
152
- * A bound custom-mutator handle produced by `bindMutators(client, ctx, mutators)`
153
- * in `@lunora/db`. Calling it applies the optimistic overlay to the local
154
- * collections and pushes the authoritative server write; it returns the TanStack
155
- * transaction whose `isPersisted` promise tracks completion.
156
- */
455
+ * A bound custom-mutator handle produced by `bindMutators(client, ctx, mutators)`
456
+ * in `@lunora/db`. Calling it applies the optimistic overlay to the local
457
+ * collections and pushes the authoritative server write; it returns the TanStack
458
+ * transaction whose `isPersisted` promise tracks completion.
459
+ */
157
460
  type MutatorHandle<TArgs> = (args: TArgs) => MutatorTransaction;
158
461
  /**
159
- * Reactive sinks an adapter binds to its own primitive's setters (a React
160
- * `useState`, a Solid signal, a Vue ref, a Svelte store). The runner pushes into
161
- * them; how they store the value is the adapter's concern.
162
- */
462
+ * Reactive sinks an adapter binds to its own primitive's setters (a React
463
+ * `useState`, a Solid signal, a Vue ref, a Svelte store). The runner pushes into
464
+ * them; how they store the value is the adapter's concern.
465
+ */
163
466
  interface MutatorRunnerSinks {
164
467
  /** Receives the normalized {@link Error} when an invocation rejects, or `undefined` on success / reset. */
165
468
  setError: (error: Error | undefined) => void;
@@ -167,38 +470,66 @@ interface MutatorRunnerSinks {
167
470
  setPending: (pending: boolean) => void;
168
471
  }
169
472
  /**
170
- * Build the framework-neutral `mutate` / `reset` pair of an adapter's
171
- * custom-mutator hook (`useMutator` / `createMutator` / `mutator`).
172
- *
173
- * Owns the orchestration every adapter otherwise copy-pastes: ref-counts
174
- * overlapping invocations into `setPending` (so it only clears once the last
175
- * settles), awaits the bound handle's `isPersisted` promise, normalizes a thrown
176
- * non-`Error`, and routes failure to `setError` (clearing it on success) before
177
- * re-throwing. Each adapter (`@lunora/react`, `/solid`, `/svelte`, `/vue`) binds
178
- * the two sinks to its own reactive setters, so this logic lives in exactly one
179
- * place. The optimistic overlay + server push are owned by the bound handle.
180
- *
181
- * `error` tracks the LATEST invocation, not the last to settle: overlapping
182
- * calls can resolve out of order, so an earlier call that finishes later must
183
- * not clobber a newer call's outcome. Each invocation takes a monotonic token
184
- * and only writes `setError` while it is still the most recent one — otherwise
185
- * `error`/`isError` could surface a stale success or failure (the documented
186
- * "latest invocation's error" contract every adapter advertises).
187
- */
473
+ * Build the framework-neutral `mutate` / `reset` pair of an adapter's
474
+ * custom-mutator hook (`useMutator` / `createMutator` / `mutator`).
475
+ *
476
+ * Owns the orchestration every adapter otherwise copy-pastes: ref-counts
477
+ * overlapping invocations into `setPending` (so it only clears once the last
478
+ * settles), awaits the bound handle's `isPersisted` promise, normalizes a thrown
479
+ * non-`Error`, and routes failure to `setError` (clearing it on success) before
480
+ * re-throwing. Each adapter (`@lunora/react`, `/solid`, `/svelte`, `/vue`) binds
481
+ * the two sinks to its own reactive setters, so this logic lives in exactly one
482
+ * place. The optimistic overlay + server push are owned by the bound handle.
483
+ *
484
+ * `error` tracks the LATEST invocation, not the last to settle: overlapping
485
+ * calls can resolve out of order, so an earlier call that finishes later must
486
+ * not clobber a newer call's outcome. Each invocation takes a monotonic token
487
+ * and only writes `setError` while it is still the most recent one — otherwise
488
+ * `error`/`isError` could surface a stale success or failure (the documented
489
+ * "latest invocation's error" contract every adapter advertises).
490
+ */
188
491
  declare const createMutatorRunner: <TArgs>(handle: MutatorHandle<TArgs>, sinks: MutatorRunnerSinks) => {
189
492
  mutate: (args: TArgs) => Promise<void>;
190
493
  reset: () => void;
191
494
  };
192
495
  interface QueuedMutation<T = unknown> {
193
496
  readonly args: Record<string, unknown>;
497
+ /**
498
+ * The client id that queued this write (see {@link PersistedMutation.clientId}).
499
+ * Persisted and restored, so a replay namespaces by the id that issued the
500
+ * write rather than whatever the current session minted.
501
+ */
502
+ clientId?: string;
194
503
  readonly functionPath: string;
195
504
  /** Stable id used to remove the entry from durable storage once replayed; assigned by the queue when absent. */
196
505
  id?: string;
197
506
  /**
198
- * Issuing identity fingerprint carried through to durable storage (`null` =
199
- * signed out). Absent on hydrated legacy records, which replay ambiently.
200
- */
507
+ * Issuing identity fingerprint carried through to durable storage (`null` =
508
+ * signed out). Absent on hydrated legacy records, which replay ambiently.
509
+ */
201
510
  readonly identity?: string | null;
511
+ /**
512
+ * `true` when a live caller is still awaiting this write's `mutation()`
513
+ * Promise; `false`/absent for a write restored from durable storage after a
514
+ * reload (its original awaiter is gone). Carried so terminal-verdict
515
+ * observers can distinguish "the caller already saw this" from "nothing else
516
+ * will report this". Maps to the public `MutationSettledEvent.hadAwaiter`.
517
+ */
518
+ liveAwaiter?: boolean;
519
+ /**
520
+ * Invoked on a successful replay with the server's echoed commit CDC cursor,
521
+ * so a live per-call optimistic layer drops gaplessly once a frame reaches it.
522
+ * Absent on hydrated records (the optimistic write lived in a prior session).
523
+ */
524
+ readonly onCommit?: (commitCursor: number | undefined) => void;
525
+ /**
526
+ * Optional sync predicate evaluated just before replay. When it returns
527
+ * `false` the write is dropped instead of replaying, handling the case
528
+ * where the mutation's preconditions are no longer valid (e.g. the
529
+ * document it referred to was deleted while offline). Absent or `true`
530
+ * means "ok to replay".
531
+ */
532
+ readonly precondition?: () => boolean;
202
533
  /** Rejects if the mutation can no longer be replayed. */
203
534
  readonly reject: (error: unknown) => void;
204
535
  /** Resolves once the mutation has been replayed against the server. */
@@ -206,71 +537,116 @@ interface QueuedMutation<T = unknown> {
206
537
  readonly shardKey?: string;
207
538
  }
208
539
  /**
209
- * A process-unique id, used both per-mutation and as the fallback `clientId`. It
210
- * MUST be globally unique: the server scopes a custom mutator's replay watermark
211
- * by `(verifiedIdentity, clientId)`, and an anonymous push has no verified
212
- * identity so two anonymous clients that collide on `clientId` would share one
213
- * watermark namespace, letting one stall/suppress the other's ordered mutations.
214
- * `crypto.randomUUID` covers every modern runtime; the fallback still mixes
215
- * crypto-quality (or `Math.random`) entropy with the timestamp + counter so it
216
- * can't collide across two clients started in the same millisecond.
217
- */
218
- /**
219
- * Bounded FIFO queue. Mutations issued while the client is offline are
220
- * enqueued and replayed in the order they were submitted once the WS
221
- * reconnects and identifies. If the queue exceeds `maxItems` the oldest
222
- * entry is rejected with `OFFLINE_QUEUE_OVERFLOW`.
223
- *
224
- * When a {@link PersistenceAdapter} is supplied, enqueued mutations are mirrored
225
- * to durable storage so they survive a reload {@link OfflineQueue.hydrate} restores them on
226
- * the next startup and the client replays them on reconnect. Durable removal is
227
- * the caller's responsibility *after* a successful replay (see `LunoraClient`);
228
- * the queue only persists on enqueue and un-persists on overflow.
229
- */
540
+ * Invoked when the queue itself discards an entry on overflow (capacity
541
+ * eviction), so the client can surface the dropped write on its
542
+ * terminal-verdict observer even when the entry has no live awaiter (a hydrated
543
+ * record). The `error` carries the `OFFLINE_QUEUE_OVERFLOW` code.
544
+ */
545
+ type EvictHandler = (entry: QueuedMutation, error: Error & {
546
+ code?: string;
547
+ }) => void;
548
+ /** Injected dependencies for {@link OfflineQueue} (kept off the user-facing {@link OfflineQueueOptions}). */
549
+ interface OfflineQueueDeps {
550
+ /** Invoked when an entry is discarded on capacity overflow (carries `OFFLINE_QUEUE_OVERFLOW`). */
551
+ onEvict?: EvictHandler;
552
+ /** Invoked with the new depth after any size change (drives the client's pending-sync count). */
553
+ onSizeChange?: (size: number) => void;
554
+ /** Durable store; when present, writes are mirrored and restored across reloads. */
555
+ persistence?: PersistenceAdapter;
556
+ /** App/schema version stamped on persisted writes; mismatched records are purged on hydrate. */
557
+ version?: string;
558
+ }
559
+ /**
560
+ * Bounded FIFO queue. Mutations issued while the client is offline are
561
+ * enqueued and replayed in the order they were submitted once the WS
562
+ * reconnects and identifies. If the queue exceeds `maxItems` the oldest
563
+ * entry is rejected with `OFFLINE_QUEUE_OVERFLOW`.
564
+ *
565
+ * When a {@link PersistenceAdapter} is supplied, enqueued mutations are mirrored
566
+ * to durable storage so they survive a reload — {@link OfflineQueue.hydrate} restores them on
567
+ * the next startup and the client replays them on reconnect. Durable removal is
568
+ * the caller's responsibility *after* a successful replay (see `LunoraClient`);
569
+ * the queue only persists on enqueue and un-persists on overflow.
570
+ */
230
571
  declare class OfflineQueue {
231
572
  /** Opt-in to queueing mutations before the targeted shard's first connect. */
232
573
  readonly queueBeforeFirstConnect: boolean;
233
574
  private readonly maxItems;
234
575
  private readonly onPersistenceError;
235
576
  private readonly persistence;
577
+ private readonly onEvict;
578
+ private readonly onSizeChange;
579
+ /** App/schema version stamped on persisted writes; mismatched records are purged on hydrate. */
580
+ private readonly version;
236
581
  private readonly items;
237
- constructor(options?: OfflineQueueOptions, persistence?: PersistenceAdapter);
582
+ constructor(options?: OfflineQueueOptions, deps?: OfflineQueueDeps);
238
583
  get size(): number;
239
584
  enqueue<T>(entry: QueuedMutation<T>): void;
240
585
  /**
241
- * Restore mutations persisted in a prior session and re-queue them in FIFO
242
- * order. Restored entries already live in durable storage, so they are not
243
- * re-appended; they carry no-op `resolve`/`reject` (the original awaiter is
244
- * gone after a reload). No-op when no persistence adapter is configured.
245
- * Returns the distinct shard keys of the restored writes so the caller can
246
- * open their sockets to trigger a flush.
247
- */
586
+ * Restore mutations persisted in a prior session and re-queue them in FIFO
587
+ * order. Restored entries already live in durable storage, so they are not
588
+ * re-appended; they carry no-op `resolve`/`reject` (the original awaiter is
589
+ * gone after a reload). No-op when no persistence adapter is configured.
590
+ * Returns the distinct shard keys of the restored writes so the caller can
591
+ * open their sockets to trigger a flush.
592
+ *
593
+ * `hydrate()` runs post-construction (the caller awaits an async durable-store
594
+ * load), so a mutation issued while offline during that boot window is
595
+ * enqueued into `items` *before* this method's `await` resolves. Restored
596
+ * records are therefore `unshift`-ed ahead of whatever is already queued
597
+ * rather than `push`-ed to the end: the durable store's persist order is
598
+ * authoritative (a prior-session write is always older than anything from
599
+ * this session), so replaying a same-session boot-time write before an
600
+ * older restored write on the same document would let last-writer-wins
601
+ * silently clobber the newer data with the stale one.
602
+ */
248
603
  hydrate(): Promise<(string | undefined)[]>;
249
604
  /**
250
- * Remove and return queued mutations. With no `predicate`, drains the whole
251
- * queue. With one, drains only matching entries (preserving FIFO order) and
252
- * leaves the rest queued — used to flush a single shard's writes when its
253
- * socket reconnects while other shards are still down.
254
- */
605
+ * Remove and return queued mutations. With no `predicate`, drains the whole
606
+ * queue. With one, drains only matching entries (preserving FIFO order) and
607
+ * leaves the rest queued — used to flush a single shard's writes when its
608
+ * socket reconnects while other shards are still down.
609
+ */
255
610
  drain(predicate?: (item: QueuedMutation) => boolean): QueuedMutation[];
256
611
  /**
257
- * Return previously-drained mutations to the front of the queue, preserving
258
- * their FIFO order, without re-persisting them — they were never unpersisted,
259
- * so durable storage still holds them. Used when a flush aborts on a transient
260
- * transport failure: the unreplayed writes stay queued for the next reconnect.
261
- */
612
+ * Return previously-drained mutations to the front of the queue, preserving
613
+ * their FIFO order, without re-persisting them — they were never unpersisted,
614
+ * so durable storage still holds them. Used when a flush aborts on a transient
615
+ * transport failure: the unreplayed writes stay queued for the next reconnect.
616
+ */
262
617
  requeue(items: QueuedMutation[]): void;
618
+ /**
619
+ * Remove mutations whose precondition evaluates to `false` (stale/dirty
620
+ * writes that should not replay) and reject each with an
621
+ * `OFFLINE_PRECONDITION_FAILED` error. The valid (admitted) mutations stay
622
+ * queued in FIFO order. Returns the drained stale entries.
623
+ *
624
+ * Called during reconnect before the flush cycle to weed out writes whose
625
+ * assumptions no longer hold (e.g. a document was deleted by another client).
626
+ */
627
+ drainConflict(): QueuedMutation[];
263
628
  clear(): void;
629
+ /**
630
+ * Evict entries from the FRONT of `items` (the oldest — FIFO order) until
631
+ * the queue is at or under `maxItems`, rejecting each with
632
+ * `OFFLINE_QUEUE_OVERFLOW`, un-persisting it, and firing `onEvict`. Shared
633
+ * by `enqueue` (a live write pushes past capacity) and `hydrate` (a durable
634
+ * store restored more than `maxItems` records — CLIENT-03) so an overflow
635
+ * always drops the same way regardless of which caller triggered it.
636
+ */
637
+ private evictOverflow;
638
+ /** Notify the size observer (the client's pending-sync count) after any change. */
639
+ private notifySize;
264
640
  }
265
641
  /**
266
- * In-memory {@link PersistenceAdapter}. Doesn't survive a reload — it exists so
267
- * the persistence wiring can be exercised without IndexedDB (tests, SSR, or as
268
- * a deliberate "no durable store" choice that still satisfies the interface).
269
- * Preserves enqueue order; `clone` keeps callers from mutating stored args.
270
- */
642
+ * In-memory {@link PersistenceAdapter}. Doesn't survive a reload — it exists so
643
+ * the persistence wiring can be exercised without IndexedDB (tests, SSR, or as
644
+ * a deliberate "no durable store" choice that still satisfies the interface).
645
+ * Preserves enqueue order; `clone` keeps callers from mutating stored args.
646
+ */
271
647
  declare const createInMemoryPersistence: () => PersistenceAdapter;
272
648
  interface IndexedDbPersistenceOptions {
273
- /** Database name; defaults to `"lunora"`. */
649
+ /** Database name; defaults to `"lunora-outbox"` (its own DB, separate from the read cache). */
274
650
  databaseName?: string;
275
651
  /** Injectable `IDBFactory` (e.g. `fake-indexeddb` in tests); defaults to the global `indexedDB`. */
276
652
  indexedDB?: IDBFactory;
@@ -278,34 +654,34 @@ interface IndexedDbPersistenceOptions {
278
654
  storeName?: string;
279
655
  }
280
656
  /**
281
- * IndexedDB-backed {@link PersistenceAdapter}. Each mutation is stored under an
282
- * autoincrementing key (so `load()` returns them in enqueue order regardless of
283
- * the string ids) with a unique secondary index on `id` for `remove()`.
284
- *
285
- * The store handle is opened lazily and the open promise is cached, so repeated
286
- * ops reuse one connection. Throws eagerly if no `IDBFactory` is available —
287
- * callers in non-browser environments should use {@link createInMemoryPersistence}.
288
- */
657
+ * IndexedDB-backed {@link PersistenceAdapter}. Each mutation is stored under an
658
+ * autoincrementing key (so `load()` returns them in enqueue order regardless of
659
+ * the string ids) with a unique secondary index on `id` for `remove()`.
660
+ *
661
+ * The store handle is opened lazily and the open promise is cached, so repeated
662
+ * ops reuse one connection. Throws eagerly if no `IDBFactory` is available —
663
+ * callers in non-browser environments should use {@link createInMemoryPersistence}.
664
+ */
289
665
  declare const createIndexedDbPersistence: (options?: IndexedDbPersistenceOptions) => PersistenceAdapter;
290
666
  /**
291
- * Compose the read-cache key for a subscription. Mirrors how
292
- * `SubscriptionRegistry` keys live subscriptions so a hydrated value lines up
293
- * with the subscription that will consume it. `shardKey` defaults to `""` (the
294
- * root shard) exactly as the registry does.
295
- */
667
+ * Compose the read-cache key for a subscription. Mirrors how
668
+ * `SubscriptionRegistry` keys live subscriptions so a hydrated value lines up
669
+ * with the subscription that will consume it. `shardKey` defaults to `""` (the
670
+ * root shard) exactly as the registry does.
671
+ */
296
672
  declare const queryCacheKey: (functionPath: string, argsKey: string, shardKey?: string) => string;
297
673
  /**
298
- * In-memory {@link QueryCacheAdapter}. Doesn't survive a reload — it exists so
299
- * the read-cache wiring can be exercised without IndexedDB (tests, SSR, or as a
300
- * deliberate "no durable store" choice that still satisfies the interface).
301
- * Enforces the same LRU row cap as the IndexedDB adapter; `clone` keeps callers
302
- * from mutating stored values.
303
- */
674
+ * In-memory {@link QueryCacheAdapter}. Doesn't survive a reload — it exists so
675
+ * the read-cache wiring can be exercised without IndexedDB (tests, SSR, or as a
676
+ * deliberate "no durable store" choice that still satisfies the interface).
677
+ * Enforces the same LRU row cap as the IndexedDB adapter; `clone` keeps callers
678
+ * from mutating stored values.
679
+ */
304
680
  declare const createInMemoryQueryCache: (options?: {
305
681
  maxEntries?: number;
306
682
  }) => QueryCacheAdapter;
307
683
  interface IndexedDbQueryCacheOptions {
308
- /** Database name; defaults to `"lunora"` (shared with the offline-mutation store). */
684
+ /** Database name; defaults to `"lunora-query-cache"` (its own DB, separate from the offline outbox). */
309
685
  databaseName?: string;
310
686
  /** Injectable `IDBFactory` (e.g. `fake-indexeddb` in tests); defaults to the global `indexedDB`. */
311
687
  indexedDB?: IDBFactory;
@@ -315,25 +691,25 @@ interface IndexedDbQueryCacheOptions {
315
691
  storeName?: string;
316
692
  }
317
693
  /**
318
- * IndexedDB-backed {@link QueryCacheAdapter}. Each query is stored under its
319
- * composite key (`functionPath::argsKey::shardKey`) with a `ts` index driving
320
- * LRU eviction. The store handle is opened lazily and cached, so repeated ops
321
- * reuse one connection.
322
- *
323
- * The store lives in the same `lunora` database as the offline-mutation queue
324
- * (bumped to schema v2). Opening it upgrades a v1 database in place, adding the
325
- * `query-cache` store without touching `offline-mutations`. Throws eagerly if no
326
- * `IDBFactory` is available — callers in non-browser environments should use
327
- * {@link createInMemoryQueryCache}.
328
- */
694
+ * IndexedDB-backed {@link QueryCacheAdapter}. Each query is stored under its
695
+ * composite key (`functionPath::argsKey::shardKey`) with a `ts` index driving
696
+ * LRU eviction. The store handle is opened lazily and cached, so repeated ops
697
+ * reuse one connection.
698
+ *
699
+ * The store lives in its own `lunora-query-cache` database deliberately
700
+ * separate from the offline-mutation outbox's `lunora-outbox` database so the two
701
+ * independently-toggleable adapters never share (and drift on) a schema version.
702
+ * Throws eagerly if no `IDBFactory` is available — callers in non-browser
703
+ * environments should use {@link createInMemoryQueryCache}.
704
+ */
329
705
  declare const createIndexedDbQueryCache: (options?: IndexedDbQueryCacheOptions) => QueryCacheAdapter;
330
706
  /**
331
- * Exponential backoff calculator with optional jitter.
332
- *
333
- * `next()` doubles the delay each call up to `maxDelayMs`. When `jitter` is
334
- * enabled the returned value is randomized in `[delay/2, delay]` so a fleet
335
- * of clients reconnecting at the same time spread out their retries.
336
- */
707
+ * Exponential backoff calculator with optional jitter.
708
+ *
709
+ * `next()` doubles the delay each call up to `maxDelayMs`. When `jitter` is
710
+ * enabled the returned value is randomized in `[delay/2, delay]` so a fleet
711
+ * of clients reconnecting at the same time spread out their retries.
712
+ */
337
713
  interface ReconnectCalculator {
338
714
  /** Returns the delay to wait before the next reconnect attempt. */
339
715
  next: () => number;
@@ -341,4 +717,108 @@ interface ReconnectCalculator {
341
717
  reset: () => void;
342
718
  }
343
719
  declare const createReconnect: (options?: ReconnectOptions, random?: () => number) => ReconnectCalculator;
344
- export { type ArgsOf, type AsyncStorageLike, type AsyncStoragePersistenceOptions, type BookmarkStorage, CONFLICT_ERROR_CODE, type FunctionReference, type IndexedDbPersistenceOptions, type IndexedDbQueryCacheOptions, type MutationCallOptions, type MutationDelta, type MutationRunnerSinks, type MutatorHandle, type MutatorRunnerSinks, type MutatorTransaction, OfflineQueue, type OfflineQueueOptions, type PersistenceAdapter, type QueryCacheAdapter, type QueuedMutation, type ReconnectCalculator, type ReconnectOptions, type ReturnOf, applyDelta, createAsyncStoragePersistence, createInMemoryBookmarkStorage, createInMemoryPersistence, createInMemoryQueryCache, createIndexedDbPersistence, createIndexedDbQueryCache, createMutationRunner, createMutatorRunner, createReconnect, isConflictError, isMutationDelta, queryCacheKey };
720
+ /**
721
+ * Capture a snapshot of the current live query value at call time and produce a
722
+ * `() => boolean` precondition that compares it against the value at replay time.
723
+ *
724
+ * When the precondition is checked (on queue drain / reconnect) it re-reads the
725
+ * query's current value via {@link LunoraClient.peekActiveQueryValue}. If the
726
+ * value differs from what was captured at call time the precondition returns
727
+ * `false` and the offline mutation is dropped as stale.
728
+ * @example
729
+ * ```ts
730
+ * client.mutation(api.todos.update, { id, text }, {
731
+ * precondition: createSnapshotPrecondition(client, api.todos.list, { userId }),
732
+ * });
733
+ * ```
734
+ */
735
+ declare const createSnapshotPrecondition: (client: LunoraClient, functionRef: FunctionReference, args: Record<string, unknown>, shardKey?: string) => (() => boolean);
736
+ /**
737
+ * Client-side service-worker registration and lifecycle management.
738
+ *
739
+ * Usage:
740
+ * ```ts
741
+ * const sw = new ClientServiceWorker({ swUrl: "/sw.js" });
742
+ * await sw.register();
743
+ *
744
+ * if (sw.active) {
745
+ * sw.postMessage({ type: "sync" });
746
+ * }
747
+ * ```
748
+ */
749
+ type ServiceWorkerStatus = "unsupported" | "unregistered" | "registering" | "active" | "error";
750
+ interface ClientSwOptions {
751
+ /** Called when the status changes. */
752
+ onStatusChange?: (status: ServiceWorkerStatus) => void;
753
+ /** Optional registration scope. Defaults to `/`. */
754
+ scope?: string;
755
+ /** URL of the service worker script (relative to origin). */
756
+ swUrl: string;
757
+ }
758
+ /**
759
+ * Manages service-worker registration and provides a simple API for
760
+ * sending messages and listening for responses.
761
+ */
762
+ declare class ClientServiceWorker {
763
+ #private;
764
+ readonly swUrl: string;
765
+ readonly scope: string;
766
+ constructor(options: ClientSwOptions);
767
+ /** Current registration status. */
768
+ get status(): ServiceWorkerStatus;
769
+ /** The underlying `ServiceWorkerRegistration`, if registered. */
770
+ get registration(): ServiceWorkerRegistration | undefined;
771
+ /** Whether the SW is currently controlling this page. */
772
+ get active(): boolean;
773
+ /**
774
+ * Register the service worker.
775
+ *
776
+ * Returns `false` when the browser does not support service workers.
777
+ */
778
+ register(): Promise<boolean>;
779
+ /**
780
+ * Remove the active service worker registration and clear listeners.
781
+ *
782
+ * Returns `false` when no registration is currently held.
783
+ */
784
+ unregister(): Promise<boolean>;
785
+ /**
786
+ * Send a message to the active service worker.
787
+ */
788
+ postMessage(message: unknown): void;
789
+ /**
790
+ * Register a handler for messages **from** the service worker.
791
+ * @returns Unsubscribe function.
792
+ */
793
+ onMessage(handler: (event: MessageEvent) => void): () => void;
794
+ }
795
+ /**
796
+ * Outbound message from the client to the SW.
797
+ */
798
+ interface ClientToSwMessage {
799
+ /** Opaque correlation ID for request/response patterns. */
800
+ correlationId?: string;
801
+ payload?: unknown;
802
+ type: string;
803
+ }
804
+ /**
805
+ * Inbound message from the SW to the client.
806
+ */
807
+ interface SwToClientMessage {
808
+ /** Echoes the correlation ID from the client request, if any. */
809
+ correlationId?: string;
810
+ payload?: unknown;
811
+ type: string;
812
+ }
813
+ /**
814
+ * Send a typed message to the service worker and optionally await a
815
+ * matching response.
816
+ * @returns A promise that resolves when the SW sends a reply with the
817
+ * same `correlationId` (if `expectResponse` is true).
818
+ */
819
+ declare const sendToSw: (sw: ServiceWorker | null, message: ClientToSwMessage, expectResponse?: boolean) => Promise<unknown>;
820
+ /**
821
+ * Create a reply for a client message (call from inside the SW).
822
+ */
823
+ declare const createReply: (original: ClientToSwMessage, payload?: unknown) => SwToClientMessage;
824
+ export { type ArgsOf, type AsyncStorageLike, type AsyncStoragePersistenceOptions, type BookmarkStorage, ClientServiceWorker, type ClientSwOptions, type ClientToSwMessage, type ConnectionStatus, type FunctionReference, type HttpStreamArgsOf, type HttpStreamChunkOf, type HttpStreamOptions, type HttpStreamRef, type IndexedDbPersistenceOptions, type IndexedDbQueryCacheOptions, LunoraClient, type MutationCallOptions, type MutationDelta, type MutationRunnerSinks, type MutatorHandle, type MutatorRunnerSinks, type MutatorTransaction, OfflineQueue, type OfflineQueueOptions, type OptimisticMessage, type PersistenceAdapter, type QueryCacheAdapter, type QueuedMutation, RETIRE_AFTER_DURABLE_SEQ_ADVANCE, type ReconcileDurableMessage, type ReconnectCalculator, type ReconnectOptions, type ReturnOf, type ServiceWorkerStatus, type StreamIterable, type SubscriptionError, type SwToClientMessage, TabCoordinator, anyApi, applyDelta, createAsyncStoragePersistence, createInMemoryBookmarkStorage, createInMemoryPersistence, createInMemoryQueryCache, createIndexedDbPersistence, createIndexedDbQueryCache, createMutationRunner, createMutatorRunner, createReconnect, createReply, createSnapshotPrecondition, httpStream, isMutationDelta, maxSeq, queryCacheKey, reconcileOptimistic, sendToSw };