@lunora/client 1.0.0-alpha.3 → 1.0.0-alpha.31

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