@lunora/client 1.0.0-alpha.8 → 1.0.0-alpha.9

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.
@@ -1,4 +1,4 @@
1
- import { U as User, L as LunoraClient } from "../packem_shared/lunora-client.d-CeUZ8lE1.mjs";
1
+ import { U as User, L as LunoraClient } from "../packem_shared/lunora-client.d-CfwxuJo8.mjs";
2
2
  import '@lunora/runtime';
3
3
  interface IdentityStore {
4
4
  /** Read the current resolved user, or `null` when signed out / not yet resolved. */
@@ -1,4 +1,4 @@
1
- import { U as User, L as LunoraClient } from "../packem_shared/lunora-client.d-CeUZ8lE1.js";
1
+ import { U as User, L as LunoraClient } from "../packem_shared/lunora-client.d-CfwxuJo8.js";
2
2
  import '@lunora/runtime';
3
3
  interface IdentityStore {
4
4
  /** Read the current resolved user, or `null` when signed out / not yet resolved. */
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
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-CeUZ8lE1.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-CeUZ8lE1.mjs";
3
- export { p as preloadQuery, a as preloadedQueryResult } from "./packem_shared/preload.d-DipqU0PI.mjs";
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-CfwxuJo8.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 MutationSettledEvent, type q as OptimisticLocalStore, type r as OptimisticUpdate, type s as OutboxMutation, type t as OutboxSink, type u as PersistedMutation, type P as Preloaded, type v as RowOp, type w as RpcEnvelope, type x as RpcResponseBody, type y as ScheduleRecord, type z as SchedulerPoolStatus, type E as SchedulerStatus, type H as ServerMessage, type I as ServerPokeEndMessage, type J as ServerPokePartMessage, type K as ServerPokeStartMessage, type N as ShardTrafficEntry, type T as ShardTrafficResult, type V as StorageListPage, type W as StorageObject, type X as StreamHandle, type Y as StreamIterable, type Z as SubscriptionCallback, type S as SubscriptionError, type b as SubscriptionErrorCallback, _ as SubscriptionRegistry, type $ as SubscriptionState, type a0 as SyncWatermark, type a as Unsubscribe, type U as User, type a1 as WorkflowInstanceAction, type a2 as WorkflowInstanceDetail, type a3 as WorkflowInstancePage, type a4 as WorkflowInstanceStatus, type a5 as WorkflowInstanceSummary, type a6 as WorkflowStepDetail, a7 as createLocalStore, a8 as createStream } from "./packem_shared/lunora-client.d-CfwxuJo8.mjs";
3
+ export { p as preloadQuery, a as preloadedQueryResult } from "./packem_shared/preload.d-Bbn8plix.mjs";
4
4
  export type { AuthCapabilities, AuthImpersonation, AuthPage, AuthSession, AuthUser, CronJobInfo, VectorIndexSummary, VectorQueryMatch } from '@lunora/runtime';
5
5
  /**
6
6
  * The slice of React Native's `AsyncStorage` (or any async key/value store —
@@ -199,6 +199,20 @@ interface QueuedMutation<T = unknown> {
199
199
  * signed out). Absent on hydrated legacy records, which replay ambiently.
200
200
  */
201
201
  readonly identity?: string | null;
202
+ /**
203
+ * `true` when a live caller is still awaiting this write's `mutation()`
204
+ * Promise; `false`/absent for a write restored from durable storage after a
205
+ * reload (its original awaiter is gone). Carried so terminal-verdict
206
+ * observers can distinguish "the caller already saw this" from "nothing else
207
+ * will report this". Maps to the public `MutationSettledEvent.hadAwaiter`.
208
+ */
209
+ liveAwaiter?: boolean;
210
+ /**
211
+ * Invoked on a successful replay with the server's echoed commit CDC cursor,
212
+ * so a live per-call optimistic layer drops gaplessly once a frame reaches it.
213
+ * Absent on hydrated records (the optimistic write lived in a prior session).
214
+ */
215
+ readonly onCommit?: (commitCursor: number | undefined) => void;
202
216
  /** Rejects if the mutation can no longer be replayed. */
203
217
  readonly reject: (error: unknown) => void;
204
218
  /** Resolves once the mutation has been replayed against the server. */
@@ -206,6 +220,15 @@ interface QueuedMutation<T = unknown> {
206
220
  readonly shardKey?: string;
207
221
  }
208
222
  /**
223
+ * Invoked when the queue itself discards an entry on overflow (capacity
224
+ * eviction), so the client can surface the dropped write on its
225
+ * terminal-verdict observer even when the entry has no live awaiter (a hydrated
226
+ * record). The `error` carries the `OFFLINE_QUEUE_OVERFLOW` code.
227
+ */
228
+ type EvictHandler = (entry: QueuedMutation, error: Error & {
229
+ code?: string;
230
+ }) => void;
231
+ /**
209
232
  * A process-unique id, used both per-mutation and as the fallback `clientId`. It
210
233
  * MUST be globally unique: the server scopes a custom mutator's replay watermark
211
234
  * by `(verifiedIdentity, clientId)`, and an anonymous push has no verified
@@ -215,6 +238,7 @@ interface QueuedMutation<T = unknown> {
215
238
  * crypto-quality (or `Math.random`) entropy with the timestamp + counter so it
216
239
  * can't collide across two clients started in the same millisecond.
217
240
  */
241
+
218
242
  /**
219
243
  * Bounded FIFO queue. Mutations issued while the client is offline are
220
244
  * enqueued and replayed in the order they were submitted once the WS
@@ -233,8 +257,9 @@ declare class OfflineQueue {
233
257
  private readonly maxItems;
234
258
  private readonly onPersistenceError;
235
259
  private readonly persistence;
260
+ private readonly onEvict;
236
261
  private readonly items;
237
- constructor(options?: OfflineQueueOptions, persistence?: PersistenceAdapter);
262
+ constructor(options?: OfflineQueueOptions, persistence?: PersistenceAdapter, onEvict?: EvictHandler);
238
263
  get size(): number;
239
264
  enqueue<T>(entry: QueuedMutation<T>): void;
240
265
  /**
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
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-CeUZ8lE1.js";
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-CeUZ8lE1.js";
3
- export { p as preloadQuery, a as preloadedQueryResult } from "./packem_shared/preload.d-CzHLgVfw.js";
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-CfwxuJo8.js";
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 MutationSettledEvent, type q as OptimisticLocalStore, type r as OptimisticUpdate, type s as OutboxMutation, type t as OutboxSink, type u as PersistedMutation, type P as Preloaded, type v as RowOp, type w as RpcEnvelope, type x as RpcResponseBody, type y as ScheduleRecord, type z as SchedulerPoolStatus, type E as SchedulerStatus, type H as ServerMessage, type I as ServerPokeEndMessage, type J as ServerPokePartMessage, type K as ServerPokeStartMessage, type N as ShardTrafficEntry, type T as ShardTrafficResult, type V as StorageListPage, type W as StorageObject, type X as StreamHandle, type Y as StreamIterable, type Z as SubscriptionCallback, type S as SubscriptionError, type b as SubscriptionErrorCallback, _ as SubscriptionRegistry, type $ as SubscriptionState, type a0 as SyncWatermark, type a as Unsubscribe, type U as User, type a1 as WorkflowInstanceAction, type a2 as WorkflowInstanceDetail, type a3 as WorkflowInstancePage, type a4 as WorkflowInstanceStatus, type a5 as WorkflowInstanceSummary, type a6 as WorkflowStepDetail, a7 as createLocalStore, a8 as createStream } from "./packem_shared/lunora-client.d-CfwxuJo8.js";
3
+ export { p as preloadQuery, a as preloadedQueryResult } from "./packem_shared/preload.d-DuMetDdU.js";
4
4
  export type { AuthCapabilities, AuthImpersonation, AuthPage, AuthSession, AuthUser, CronJobInfo, VectorIndexSummary, VectorQueryMatch } from '@lunora/runtime';
5
5
  /**
6
6
  * The slice of React Native's `AsyncStorage` (or any async key/value store —
@@ -199,6 +199,20 @@ interface QueuedMutation<T = unknown> {
199
199
  * signed out). Absent on hydrated legacy records, which replay ambiently.
200
200
  */
201
201
  readonly identity?: string | null;
202
+ /**
203
+ * `true` when a live caller is still awaiting this write's `mutation()`
204
+ * Promise; `false`/absent for a write restored from durable storage after a
205
+ * reload (its original awaiter is gone). Carried so terminal-verdict
206
+ * observers can distinguish "the caller already saw this" from "nothing else
207
+ * will report this". Maps to the public `MutationSettledEvent.hadAwaiter`.
208
+ */
209
+ liveAwaiter?: boolean;
210
+ /**
211
+ * Invoked on a successful replay with the server's echoed commit CDC cursor,
212
+ * so a live per-call optimistic layer drops gaplessly once a frame reaches it.
213
+ * Absent on hydrated records (the optimistic write lived in a prior session).
214
+ */
215
+ readonly onCommit?: (commitCursor: number | undefined) => void;
202
216
  /** Rejects if the mutation can no longer be replayed. */
203
217
  readonly reject: (error: unknown) => void;
204
218
  /** Resolves once the mutation has been replayed against the server. */
@@ -206,6 +220,15 @@ interface QueuedMutation<T = unknown> {
206
220
  readonly shardKey?: string;
207
221
  }
208
222
  /**
223
+ * Invoked when the queue itself discards an entry on overflow (capacity
224
+ * eviction), so the client can surface the dropped write on its
225
+ * terminal-verdict observer even when the entry has no live awaiter (a hydrated
226
+ * record). The `error` carries the `OFFLINE_QUEUE_OVERFLOW` code.
227
+ */
228
+ type EvictHandler = (entry: QueuedMutation, error: Error & {
229
+ code?: string;
230
+ }) => void;
231
+ /**
209
232
  * A process-unique id, used both per-mutation and as the fallback `clientId`. It
210
233
  * MUST be globally unique: the server scopes a custom mutator's replay watermark
211
234
  * by `(verifiedIdentity, clientId)`, and an anonymous push has no verified
@@ -215,6 +238,7 @@ interface QueuedMutation<T = unknown> {
215
238
  * crypto-quality (or `Math.random`) entropy with the timestamp + counter so it
216
239
  * can't collide across two clients started in the same millisecond.
217
240
  */
241
+
218
242
  /**
219
243
  * Bounded FIFO queue. Mutations issued while the client is offline are
220
244
  * enqueued and replayed in the order they were submitted once the WS
@@ -233,8 +257,9 @@ declare class OfflineQueue {
233
257
  private readonly maxItems;
234
258
  private readonly onPersistenceError;
235
259
  private readonly persistence;
260
+ private readonly onEvict;
236
261
  private readonly items;
237
- constructor(options?: OfflineQueueOptions, persistence?: PersistenceAdapter);
262
+ constructor(options?: OfflineQueueOptions, persistence?: PersistenceAdapter, onEvict?: EvictHandler);
238
263
  get size(): number;
239
264
  enqueue<T>(entry: QueuedMutation<T>): void;
240
265
  /**
package/dist/index.mjs CHANGED
@@ -2,11 +2,11 @@ export { createAsyncStoragePersistence } from './packem_shared/createAsyncStorag
2
2
  export { default as createInMemoryBookmarkStorage } from './packem_shared/createInMemoryBookmarkStorage-BoN7a7TH.mjs';
3
3
  export { applyDelta, isMutationDelta } from './packem_shared/applyDelta-4jFGTPA3.mjs';
4
4
  export { CONFLICT_ERROR_CODE, isConflictError } from './packem_shared/CONFLICT_ERROR_CODE-aUdVbEDw.mjs';
5
- export { createLocalStore } from './packem_shared/createLocalStore-DSUfoLqY.mjs';
6
- export { LunoraClient } from './packem_shared/LunoraClient-Bsv8immC.mjs';
5
+ export { c as createLocalStore } from './packem_shared/local-store-BNgN3Dw3.mjs';
6
+ export { LunoraClient } from './packem_shared/LunoraClient-C4eFp92b.mjs';
7
7
  export { createMutationRunner } from './packem_shared/createMutationRunner-BqsavzvG.mjs';
8
8
  export { createMutatorRunner } from './packem_shared/createMutatorRunner-BETvCd0p.mjs';
9
- export { OfflineQueue } from './packem_shared/OfflineQueue-D-ASeqL7.mjs';
9
+ export { OfflineQueue } from './packem_shared/OfflineQueue-eJBiWPfg.mjs';
10
10
  export { createInMemoryPersistence, createIndexedDbPersistence } from './packem_shared/createInMemoryPersistence-CW82inU5.mjs';
11
11
  export { preloadQuery, preloadedQueryResult } from './packem_shared/preloadQuery-lobFkD2Z.mjs';
12
12
  export { createInMemoryQueryCache, createIndexedDbQueryCache, queryCacheKey } from './packem_shared/createInMemoryQueryCache-B1PQ9Twl.mjs';
@@ -1,12 +1,37 @@
1
1
  import { S as SubscriptionRegistry, s as stableStringify } from './subscription-C1Jy7HiF.mjs';
2
2
  import createInMemoryBookmarkStorage from './createInMemoryBookmarkStorage-BoN7a7TH.mjs';
3
3
  import { isMutationDelta, applyDelta } from './applyDelta-4jFGTPA3.mjs';
4
- import { createLocalStore } from './createLocalStore-DSUfoLqY.mjs';
5
- import { OfflineQueue, nextId, reportPersistenceError } from './OfflineQueue-D-ASeqL7.mjs';
4
+ import { a as applyOptimisticLayer, d as dropConfirmedLayers, n as notifySubscription, f as foldOptimistic, c as createLocalStore } from './local-store-BNgN3Dw3.mjs';
5
+ import { OfflineQueue, nextId, reportPersistenceError } from './OfflineQueue-eJBiWPfg.mjs';
6
6
  import { queryCacheKey } from './createInMemoryQueryCache-B1PQ9Twl.mjs';
7
7
  import { createReconnect } from './createReconnect-Di_-oHH7.mjs';
8
8
  import { createStream } from './DEFAULT_MAX_BUFFER-BDkqO5PW.mjs';
9
9
 
10
+ class Listeners {
11
+ listeners = /* @__PURE__ */ new Set();
12
+ add(listener) {
13
+ this.listeners.add(listener);
14
+ return () => {
15
+ this.listeners.delete(listener);
16
+ };
17
+ }
18
+ // The conditional rest tuple makes `emit()` argument-free for a
19
+ // `Listeners<void>` and one-argument for every other payload.
20
+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- `[T] extends [void]` is the discriminant for the payload-free overload, not a value-position void
21
+ emit(...args) {
22
+ const [value] = args;
23
+ for (const listener of this.listeners) {
24
+ try {
25
+ listener(value);
26
+ } catch {
27
+ }
28
+ }
29
+ }
30
+ clear() {
31
+ this.listeners.clear();
32
+ }
33
+ }
34
+
10
35
  const RPC_PATH = "/_lunora/rpc";
11
36
  const WS_PATH = "/_lunora/ws";
12
37
  const bucketQuery = (bucket) => bucket === void 0 || bucket === "" ? "" : `&bucket=${encodeURIComponent(bucket)}`;
@@ -102,41 +127,6 @@ const withQuery = (path, params) => {
102
127
  return query === "" ? path : `${path}?${query}`;
103
128
  };
104
129
  const connectionKey = (shardKey) => shardKey ?? "";
105
- const writeOptimisticToState = (state, next) => {
106
- const previous = state.lastValue;
107
- const versionAtApply = state.serverVersion;
108
- state.lastValue = next;
109
- for (const callback of state.callbacks) {
110
- try {
111
- callback(next);
112
- } catch {
113
- }
114
- }
115
- return () => {
116
- if (state.serverVersion > versionAtApply) {
117
- return;
118
- }
119
- if (state.lastValue !== next) {
120
- return;
121
- }
122
- state.lastValue = previous;
123
- for (const callback of state.callbacks) {
124
- try {
125
- callback(previous);
126
- } catch {
127
- }
128
- }
129
- };
130
- };
131
- const applyOptimisticToState = (state, optimistic) => {
132
- let next;
133
- try {
134
- next = optimistic(state.lastValue);
135
- } catch {
136
- return void 0;
137
- }
138
- return writeOptimisticToState(state, next);
139
- };
140
130
  const buildStreamError = (message) => {
141
131
  const errorEnvelope = message.error;
142
132
  const code = typeof errorEnvelope?.code === "string" ? errorEnvelope.code : void 0;
@@ -273,11 +263,13 @@ class LunoraClient {
273
263
  queuedIdentities = /* @__PURE__ */ new Map();
274
264
  closed = false;
275
265
  /** Subscribers to auth-token changes (see `onAuthTokenChange`). */
276
- authTokenListeners = /* @__PURE__ */ new Set();
266
+ authTokenListeners = new Listeners();
277
267
  /** Subscribers to aggregate connection-status changes (see `onConnectionStatus`). */
278
- statusListeners = /* @__PURE__ */ new Set();
268
+ statusListeners = new Listeners();
279
269
  /** Subscribers notified when the server drops a socket for an expired token (see `onTokenExpired`). */
280
- tokenExpiredListeners = /* @__PURE__ */ new Set();
270
+ tokenExpiredListeners = new Listeners();
271
+ /** Subscribers to offline-queued mutation verdicts (see `onMutationSettled`). */
272
+ mutationSettledListeners = new Listeners();
281
273
  /**
282
274
  * Whisper-topic handlers, keyed by `connectionKey(shardKey)` → topic → set
283
275
  * of callbacks. Membership doubles as the resubscribe set replayed on every
@@ -316,7 +308,9 @@ class LunoraClient {
316
308
  this.persistence = options.persistence;
317
309
  this.queryCache = options.queryCache === false ? void 0 : options.queryCache;
318
310
  this.onPersistenceError = options.offlineQueue?.onPersistenceError;
319
- this.offlineQueue = new OfflineQueue(options.offlineQueue, options.persistence);
311
+ this.offlineQueue = new OfflineQueue(options.offlineQueue, options.persistence, (entry, error) => {
312
+ this.emitItemSettled(entry, "rejected", error);
313
+ });
320
314
  this.outbox = options.outbox;
321
315
  this.clientId = options.clientId ?? `client-${nextId()}`;
322
316
  if (this.persistence) {
@@ -347,12 +341,7 @@ class LunoraClient {
347
341
  }
348
342
  this.authToken = token;
349
343
  this.rejectQueuedForIdentityChange();
350
- for (const listener of this.authTokenListeners) {
351
- try {
352
- listener(token);
353
- } catch {
354
- }
355
- }
344
+ this.authTokenListeners.emit(token);
356
345
  }
357
346
  getAuthToken() {
358
347
  return this.authToken;
@@ -424,10 +413,7 @@ class LunoraClient {
424
413
  * the current value.
425
414
  */
426
415
  onAuthTokenChange(listener) {
427
- this.authTokenListeners.add(listener);
428
- return () => {
429
- this.authTokenListeners.delete(listener);
430
- };
416
+ return this.authTokenListeners.add(listener);
431
417
  }
432
418
  /**
433
419
  * Fetch the currently authenticated user from better-auth's `get-session`
@@ -630,10 +616,7 @@ class LunoraClient {
630
616
  * with a freshly minted one. Returns an unsubscribe function.
631
617
  */
632
618
  onTokenExpired(listener) {
633
- this.tokenExpiredListeners.add(listener);
634
- return () => {
635
- this.tokenExpiredListeners.delete(listener);
636
- };
619
+ return this.tokenExpiredListeners.add(listener);
637
620
  }
638
621
  // --- Connection status --------------------------------------------------
639
622
  /**
@@ -649,11 +632,23 @@ class LunoraClient {
649
632
  * unsubscribe function.
650
633
  */
651
634
  onConnectionStatus(listener) {
652
- this.statusListeners.add(listener);
635
+ const unsubscribe = this.statusListeners.add(listener);
653
636
  listener(this.computeStatus());
654
- return () => {
655
- this.statusListeners.delete(listener);
656
- };
637
+ return unsubscribe;
638
+ }
639
+ /**
640
+ * Subscribe to terminal verdicts for offline-queued mutations. The listener
641
+ * fires once per queued write that commits or is rejected — including a write
642
+ * restored from durable storage after a reload, whose original `mutation()`
643
+ * Promise no longer exists (`hadAwaiter: false`), and a write the queue
644
+ * evicts on overflow or discards on an identity change. This is the durable
645
+ * channel for surfacing a rolled-back optimistic write to the UI; an online
646
+ * mutation that never queued still surfaces through the Promise `mutation()`
647
+ * returns. The listener is NOT invoked on registration. Returns an
648
+ * unsubscribe function. See {@link MutationSettledEvent}.
649
+ */
650
+ onMutationSettled(listener) {
651
+ return this.mutationSettledListeners.add(listener);
657
652
  }
658
653
  // --- RPC ---------------------------------------------------------------
659
654
  async query(function_, args, options = {}) {
@@ -678,9 +673,14 @@ class LunoraClient {
678
673
  }
679
674
  const argsRecord = args;
680
675
  const mutationId = options.mutationId ?? nextId();
681
- const optimisticRollbacks = this.applyOptimisticUpdates(function_.__lunoraRef, argsRecord, options.shardKey, options.optimistic);
676
+ const { confirms: optimisticConfirms, rollbacks: optimisticRollbacks } = this.applyOptimisticUpdates(
677
+ function_.__lunoraRef,
678
+ argsRecord,
679
+ options.shardKey,
680
+ options.optimistic
681
+ );
682
682
  if (options.optimisticUpdate) {
683
- this.applyOptimisticUpdate(options.optimisticUpdate, args, options.shardKey, optimisticRollbacks);
683
+ this.applyOptimisticUpdate(options.optimisticUpdate, args, options.shardKey, optimisticRollbacks, optimisticConfirms);
684
684
  }
685
685
  const conn = this.getConnection(options.shardKey);
686
686
  const wsState = conn?.wsState ?? "idle";
@@ -691,10 +691,21 @@ class LunoraClient {
691
691
  const shouldQueueOffline = this.WebSocketImpl !== void 0 && connectedGate;
692
692
  const midReconnect = wsState === "connecting" && connectedGate;
693
693
  if (wsState !== "open" && !hasSocket && shouldQueueOffline || midReconnect) {
694
- return this.enqueueOfflineMutation(function_, argsRecord, options.shardKey, mutationId, optimisticRollbacks);
694
+ return this.enqueueOfflineMutation(function_, argsRecord, options.shardKey, mutationId, optimisticRollbacks, optimisticConfirms);
695
695
  }
696
696
  try {
697
- return await this.rpc(function_.__lunoraRef, argsRecord, options.shardKey, { captureBookmark: true, mutationId });
697
+ let commitCursor;
698
+ const result = await this.rpc(function_.__lunoraRef, argsRecord, options.shardKey, {
699
+ captureBookmark: true,
700
+ mutationId,
701
+ onCommitCursor: (cursor) => {
702
+ commitCursor = cursor;
703
+ }
704
+ });
705
+ for (const confirm of optimisticConfirms) {
706
+ confirm(commitCursor);
707
+ }
708
+ return result;
698
709
  } catch (error) {
699
710
  rollbackOptimistic(optimisticRollbacks);
700
711
  throw error;
@@ -1346,8 +1357,9 @@ class LunoraClient {
1346
1357
  fn: function_,
1347
1358
  id,
1348
1359
  lastValue: cached?.value,
1360
+ optimisticLayers: [],
1361
+ serverBase: cached?.value,
1349
1362
  serverCursor: cached?.serverCursor,
1350
- serverVersion: 0,
1351
1363
  shardKey: options.shardKey,
1352
1364
  ...cached?.serverEpoch === void 0 ? {} : { serverEpoch: cached.serverEpoch }
1353
1365
  };
@@ -1529,6 +1541,7 @@ class LunoraClient {
1529
1541
  this.authTokenListeners.clear();
1530
1542
  this.statusListeners.clear();
1531
1543
  this.tokenExpiredListeners.clear();
1544
+ this.mutationSettledListeners.clear();
1532
1545
  this.whisperHandlers.clear();
1533
1546
  this.shapeSubscriptions.clear();
1534
1547
  this.pokeBuffers.clear();
@@ -1545,7 +1558,7 @@ class LunoraClient {
1545
1558
  * optimistically (confirmation rides the synced view). Otherwise the
1546
1559
  * built-in `OfflineQueue` resolves/rejects the returned promise on replay.
1547
1560
  */
1548
- async enqueueOfflineMutation(function_, argsRecord, shardKey, mutationId, optimisticRollbacks) {
1561
+ async enqueueOfflineMutation(function_, argsRecord, shardKey, mutationId, optimisticRollbacks, optimisticConfirms) {
1549
1562
  const issuingIdentity = this.identityFingerprint();
1550
1563
  if (this.outbox) {
1551
1564
  this.outboxMutationCounter += 1;
@@ -1564,18 +1577,32 @@ class LunoraClient {
1564
1577
  rollbackOptimistic(optimisticRollbacks);
1565
1578
  throw error instanceof Error ? error : new Error(String(error));
1566
1579
  }
1580
+ for (const confirm of optimisticConfirms) {
1581
+ confirm(void 0);
1582
+ }
1567
1583
  return void 0;
1568
1584
  }
1569
1585
  return new Promise((resolve, reject) => {
1570
1586
  const entry = {
1571
1587
  args: argsRecord,
1572
1588
  functionPath: function_.__lunoraRef,
1589
+ // A live caller is awaiting this Promise, so a terminal verdict
1590
+ // reaches them directly; the observer event carries
1591
+ // `hadAwaiter: true`. Hydrated replays leave this unset.
1592
+ liveAwaiter: true,
1573
1593
  // Reuse the call's idempotency key as the queue id so the replay
1574
1594
  // carries the same `x-lunora-mutation-id` the server dedups on.
1575
1595
  id: mutationId,
1576
1596
  // Persist the stamp alongside the record so a hydrated write can
1577
1597
  // only replay under the identity that queued it.
1578
1598
  identity: issuingIdentity,
1599
+ // Confirm the per-call optimistic layer(s) against the commit cursor
1600
+ // the flush replay echoes (see flushOfflineQueue).
1601
+ onCommit: (commitCursor) => {
1602
+ for (const confirm of optimisticConfirms) {
1603
+ confirm(commitCursor);
1604
+ }
1605
+ },
1579
1606
  reject: (error) => {
1580
1607
  this.queuedIdentities.delete(mutationId);
1581
1608
  rollbackOptimistic(optimisticRollbacks);
@@ -1646,7 +1673,8 @@ class LunoraClient {
1646
1673
  * (nothing to render offline).
1647
1674
  */
1648
1675
  persistQueryValue(state) {
1649
- if (!this.queryCache || state.lastValue === void 0) {
1676
+ const authoritative = state.serverBase;
1677
+ if (!this.queryCache || authoritative === void 0) {
1650
1678
  return;
1651
1679
  }
1652
1680
  const key = queryCacheKey(state.fn.__lunoraRef, state.argsKey, state.shardKey);
@@ -1654,7 +1682,7 @@ class LunoraClient {
1654
1682
  identity: this.identityFingerprint(),
1655
1683
  serverCursor: state.serverCursor,
1656
1684
  ts: Date.now(),
1657
- value: state.lastValue,
1685
+ value: authoritative,
1658
1686
  ...state.serverEpoch === void 0 ? {} : { serverEpoch: state.serverEpoch }
1659
1687
  });
1660
1688
  this.cacheFlushTimer ??= setTimeout(() => {
@@ -1694,12 +1722,25 @@ class LunoraClient {
1694
1722
  return;
1695
1723
  }
1696
1724
  this.lastStatus = next;
1697
- for (const listener of this.statusListeners) {
1698
- try {
1699
- listener(next);
1700
- } catch {
1701
- }
1702
- }
1725
+ this.statusListeners.emit(next);
1726
+ }
1727
+ /**
1728
+ * Build a {@link MutationSettledEvent} from a queued entry and emit it on the
1729
+ * {@link onMutationSettled} channel. `item.id` is always assigned by the time
1730
+ * a write settles (`enqueue`/`hydrate` guarantee it), so the `?? ""` fallback
1731
+ * is unreachable — present only to satisfy the optional queue-id type.
1732
+ */
1733
+ emitItemSettled(item, status, error) {
1734
+ this.mutationSettledListeners.emit({
1735
+ args: item.args,
1736
+ code: error === void 0 ? void 0 : error.code,
1737
+ error,
1738
+ functionPath: item.functionPath,
1739
+ hadAwaiter: item.liveAwaiter ?? false,
1740
+ id: item.id ?? "",
1741
+ shardKey: item.shardKey,
1742
+ status
1743
+ });
1703
1744
  }
1704
1745
  /**
1705
1746
  * Apply an optimistic update to the subscription that matches the mutation's
@@ -1716,31 +1757,35 @@ class LunoraClient {
1716
1757
  * of whether the caller passed `undefined` or omitted the field.
1717
1758
  */
1718
1759
  applyOptimisticUpdates(functionRef, argsRecord, mutationShardKey, optimistic) {
1719
- const optimisticRollbacks = [];
1760
+ const confirms = [];
1761
+ const rollbacks = [];
1720
1762
  if (!optimistic) {
1721
- return optimisticRollbacks;
1763
+ return { confirms, rollbacks };
1722
1764
  }
1723
1765
  const matchKey = SubscriptionRegistry.key(functionRef, argsRecord, mutationShardKey);
1724
1766
  const state = this.subscriptions.get(matchKey);
1725
1767
  if (state) {
1726
- const rollback = applyOptimisticToState(state, optimistic);
1727
- if (rollback) {
1728
- optimisticRollbacks.push(rollback);
1768
+ const handle = applyOptimisticLayer(state, optimistic);
1769
+ if (handle) {
1770
+ confirms.push(handle.confirm);
1771
+ rollbacks.push(handle.rollback);
1729
1772
  }
1730
1773
  }
1731
- return optimisticRollbacks;
1774
+ return { confirms, rollbacks };
1732
1775
  }
1733
1776
  /**
1734
1777
  * Run a Convex-parity `optimisticUpdate` callback against a localStore bound
1735
- * to the live subscription registry, appending each `setQuery` write's
1736
- * rollback to `optimisticRollbacks` (the same LIFO list the legacy path uses,
1737
- * unwound on settle/error). A throwing callback unwinds its own partial
1738
- * writes LIFO over just the rollbacks it producedand is swallowed, so a
1739
- * buggy optimistic update can never fail the mutation or leave a partial
1740
- * patch live, mirroring the legacy transform's throw handling.
1741
- */
1742
- applyOptimisticUpdate(optimisticUpdate, args, shardKey, optimisticRollbacks) {
1743
- const { rollbacks, store } = createLocalStore(this.subscriptions, shardKey, writeOptimisticToState, stableStringify);
1778
+ * to the live subscription registry. Each `setQuery` registers a constant
1779
+ * optimistic LAYER on its target subscription (via the same engine the
1780
+ * per-call `optimistic` path uses), so the multi-query patch rebases onto
1781
+ * incoming deltas and drops gaplessly on its commit cursorits `confirm` /
1782
+ * `rollback` closures are appended to the mutation's settle lists. A throwing
1783
+ * callback unwinds its own partial writes LIFO over just the rollbacks it
1784
+ * produced — and is swallowed, so a buggy optimistic update can never fail the
1785
+ * mutation or leave a partial patch live.
1786
+ */
1787
+ applyOptimisticUpdate(optimisticUpdate, args, shardKey, optimisticRollbacks, optimisticConfirms) {
1788
+ const { confirms, rollbacks, store } = createLocalStore(this.subscriptions, shardKey, stableStringify);
1744
1789
  try {
1745
1790
  optimisticUpdate(store, args);
1746
1791
  } catch {
@@ -1750,6 +1795,7 @@ class LunoraClient {
1750
1795
  return;
1751
1796
  }
1752
1797
  optimisticRollbacks.push(...rollbacks);
1798
+ optimisticConfirms.push(...confirms);
1753
1799
  }
1754
1800
  getConnection(shardKey) {
1755
1801
  return this.connections.get(connectionKey(shardKey));
@@ -1850,6 +1896,7 @@ class LunoraClient {
1850
1896
  throw new Error(`LunoraClient: request failed (status ${response.status.toString()}${statusText})`);
1851
1897
  }
1852
1898
  flags.onMutationAck?.(body.lastMutationId);
1899
+ flags.onCommitCursor?.(body.commitCursor);
1853
1900
  return body.result;
1854
1901
  }
1855
1902
  /**
@@ -2312,8 +2359,7 @@ class LunoraClient {
2312
2359
  return;
2313
2360
  }
2314
2361
  const payload = this.resolveDataPayload(message, state);
2315
- state.lastValue = payload;
2316
- state.serverVersion += 1;
2362
+ state.serverBase = payload;
2317
2363
  if (message.cursor !== void 0) {
2318
2364
  state.serverCursor = message.cursor;
2319
2365
  }
@@ -2321,12 +2367,8 @@ class LunoraClient {
2321
2367
  state.serverEpoch = message.epoch;
2322
2368
  }
2323
2369
  this.persistQueryValue(state);
2324
- for (const callback of state.callbacks) {
2325
- try {
2326
- callback(payload);
2327
- } catch {
2328
- }
2329
- }
2370
+ dropConfirmedLayers(state, state.serverCursor);
2371
+ notifySubscription(state, state.optimisticLayers.length === 0 ? payload : foldOptimistic(payload, state.optimisticLayers));
2330
2372
  }
2331
2373
  /**
2332
2374
  * Handle a `resume` frame (Pillar 1b): the server proved nothing the
@@ -2383,6 +2425,9 @@ class LunoraClient {
2383
2425
  state.serverEpoch = epoch;
2384
2426
  }
2385
2427
  this.persistQueryValue(state);
2428
+ if (dropConfirmedLayers(state, state.serverCursor)) {
2429
+ notifySubscription(state, foldOptimistic(state.serverBase, state.optimisticLayers));
2430
+ }
2386
2431
  }
2387
2432
  }
2388
2433
  /**
@@ -2403,8 +2448,8 @@ class LunoraClient {
2403
2448
  return message.data;
2404
2449
  }
2405
2450
  const { delta } = message;
2406
- if (isMutationDelta(delta) && state.lastValue !== void 0) {
2407
- const merged = applyDelta(state.lastValue, delta);
2451
+ if (isMutationDelta(delta) && state.serverBase !== void 0) {
2452
+ const merged = applyDelta(state.serverBase, delta);
2408
2453
  if (merged !== void 0) {
2409
2454
  return merged;
2410
2455
  }
@@ -2426,12 +2471,7 @@ class LunoraClient {
2426
2471
  }
2427
2472
  /** Notify every {@link onTokenExpired} listener (best-effort, listener throws swallowed). */
2428
2473
  notifyTokenExpired() {
2429
- for (const listener of this.tokenExpiredListeners) {
2430
- try {
2431
- listener();
2432
- } catch {
2433
- }
2434
- }
2474
+ this.tokenExpiredListeners.emit();
2435
2475
  }
2436
2476
  handleCompleteMessage(id) {
2437
2477
  const stream = this.streams.get(id);
@@ -2488,6 +2528,7 @@ class LunoraClient {
2488
2528
  const error = new Error("offline mutation discarded: auth identity changed before replay");
2489
2529
  error.code = "OFFLINE_IDENTITY_CHANGED";
2490
2530
  item.reject(error);
2531
+ this.emitItemSettled(item, "rejected", error);
2491
2532
  }
2492
2533
  this.clearQueryCacheForIdentityChange();
2493
2534
  }
@@ -2523,17 +2564,28 @@ class LunoraClient {
2523
2564
  const error = new Error("offline mutation skipped: auth identity changed before replay");
2524
2565
  error.code = "OFFLINE_IDENTITY_CHANGED";
2525
2566
  item.reject(error);
2567
+ this.emitItemSettled(item, "rejected", error);
2526
2568
  continue;
2527
2569
  }
2528
2570
  this.queuedIdentities.delete(item.id ?? "");
2529
2571
  try {
2530
- const value = await this.rpc(item.functionPath, item.args, item.shardKey, { captureBookmark: true, mutationId: item.id });
2572
+ let commitCursor;
2573
+ const value = await this.rpc(item.functionPath, item.args, item.shardKey, {
2574
+ captureBookmark: true,
2575
+ mutationId: item.id,
2576
+ onCommitCursor: (cursor) => {
2577
+ commitCursor = cursor;
2578
+ }
2579
+ });
2531
2580
  this.unpersist(item.id);
2581
+ item.onCommit?.(commitCursor);
2532
2582
  item.resolve(value);
2583
+ this.emitItemSettled(item, "committed");
2533
2584
  } catch (error) {
2534
2585
  if (error.code !== void 0) {
2535
2586
  this.unpersist(item.id);
2536
2587
  item.reject(error);
2588
+ this.emitItemSettled(item, "rejected", error);
2537
2589
  continue;
2538
2590
  }
2539
2591
  this.offlineQueue.requeue(drained.slice(index));