@lunora/client 1.0.0-alpha.4 → 1.0.0-alpha.6

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.
@@ -2,7 +2,7 @@ import { S as SubscriptionRegistry, s as stableStringify } from './subscription-
2
2
  import createInMemoryBookmarkStorage from './createInMemoryBookmarkStorage-BoN7a7TH.mjs';
3
3
  import { isMutationDelta, applyDelta } from './applyDelta-4jFGTPA3.mjs';
4
4
  import { createLocalStore } from './createLocalStore-DSUfoLqY.mjs';
5
- import { OfflineQueue, nextId, reportPersistenceError } from './OfflineQueue-D5p_QgF_.mjs';
5
+ import { OfflineQueue, nextId, reportPersistenceError } from './OfflineQueue-D-ASeqL7.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';
@@ -10,6 +10,20 @@ import { createStream } from './DEFAULT_MAX_BUFFER-BDkqO5PW.mjs';
10
10
  const RPC_PATH = "/_lunora/rpc";
11
11
  const WS_PATH = "/_lunora/ws";
12
12
  const bucketQuery = (bucket) => bucket === void 0 || bucket === "" ? "" : `&bucket=${encodeURIComponent(bucket)}`;
13
+ const rollbackOptimistic = (optimisticRollbacks) => {
14
+ for (let index = optimisticRollbacks.length - 1; index >= 0; index -= 1) {
15
+ optimisticRollbacks[index]?.();
16
+ }
17
+ };
18
+ const applyRowOpsToView = (rows, ops) => {
19
+ for (const op of ops) {
20
+ if (op.op === "delete") {
21
+ rows.delete(op.key);
22
+ } else if (op.value !== void 0) {
23
+ rows.set(op.key, op.value);
24
+ }
25
+ }
26
+ };
13
27
  const WS_KEEPALIVE_PING = "lunora-ping";
14
28
  const DEFAULT_HEARTBEAT_INTERVAL_MS = 3e4;
15
29
  const DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
@@ -137,6 +151,14 @@ const buildSubscriptionError = (message) => {
137
151
  const messageText = (typeof message.message === "string" ? message.message : void 0) ?? nestedMessage ?? "subscription error";
138
152
  return { message: messageText, ...code === void 0 ? {} : { code } };
139
153
  };
154
+ const fanSubscriptionError = (callbacks, error) => {
155
+ for (const errorCallback of callbacks) {
156
+ try {
157
+ errorCallback(error);
158
+ } catch {
159
+ }
160
+ }
161
+ };
140
162
  const sharedDecoder = new TextDecoder();
141
163
  const decodeServerFrame = (raw) => {
142
164
  if (typeof raw === "string") {
@@ -159,6 +181,8 @@ const sendOn = (conn, message) => {
159
181
  }
160
182
  };
161
183
  class LunoraClient {
184
+ /** Hard cap on concurrently-buffered pokes — a backstop that reclaims buffers abandoned by a mid-poke disconnect (no `pokeEnd`). Far above any real concurrent-in-flight count. */
185
+ static MAX_POKE_BUFFERS = 256;
162
186
  url;
163
187
  wsUrl;
164
188
  wsToken;
@@ -173,6 +197,25 @@ class LunoraClient {
173
197
  /** Keepalive cadence (ms); `0` disables the heartbeat. See {@link LunoraClientOptions.heartbeatIntervalMs}. */
174
198
  heartbeatIntervalMs;
175
199
  offlineQueue;
200
+ /**
201
+ * Durable outbox seam (the `@lunora/db` `createExecutorOutboxSink`). When
202
+ * set, offline writes are delegated here and the built-in {@link OfflineQueue}
203
+ * is bypassed, so a db app has exactly one durable write path.
204
+ */
205
+ outbox;
206
+ /** Stable per-client id stamped onto every `OutboxMutation` (custom-mutator watermark). */
207
+ clientId;
208
+ /**
209
+ * Highest custom-mutator watermark the server has echoed for this client,
210
+ * keyed by shard bucket (`shardKey ?? ""`) since the DO tracks one
211
+ * `__client_watermark` per shard. `callMutator` bumps it from every
212
+ * ack; the `@lunora/db` mutator runtime seeds its `clientSeq` generator from
213
+ * it so a reload (which resets the in-memory counter) never reissues a stale
214
+ * sequence the server would silently swallow as a replay.
215
+ */
216
+ clientWatermarks = /* @__PURE__ */ new Map();
217
+ /** Monotonic per-client mutation counter backing the server `__client_watermark`. */
218
+ outboxMutationCounter = 0;
176
219
  onPersistenceError;
177
220
  persistence;
178
221
  /** Durable read cache (Pillar 2); `undefined` when `queryCache` is omitted or `false`. */
@@ -252,6 +295,11 @@ class LunoraClient {
252
295
  * calls `.cancel()` or the iterator is garbage-collected.
253
296
  */
254
297
  streams = /* @__PURE__ */ new Map();
298
+ /** Live shape subscriptions (partial replication), keyed by their wire id. */
299
+ shapeSubscriptions = /* @__PURE__ */ new Map();
300
+ /** In-flight pokes being assembled between `pokeStart` and `pokeEnd`, keyed by `pokeId`. */
301
+ pokeBuffers = /* @__PURE__ */ new Map();
302
+ nextShapeId = 0;
255
303
  constructor(options) {
256
304
  this.url = options.url;
257
305
  this.wsUrl = options.wsUrl ?? joinUrl(deriveWsUrl(options.url), WS_PATH);
@@ -269,6 +317,8 @@ class LunoraClient {
269
317
  this.queryCache = options.queryCache === false ? void 0 : options.queryCache;
270
318
  this.onPersistenceError = options.offlineQueue?.onPersistenceError;
271
319
  this.offlineQueue = new OfflineQueue(options.offlineQueue, options.persistence);
320
+ this.outbox = options.outbox;
321
+ this.clientId = options.clientId ?? `client-${nextId()}`;
272
322
  if (this.persistence) {
273
323
  queueMicrotask(() => {
274
324
  this.hydratePersistedQueue().catch(() => void 0);
@@ -307,6 +357,67 @@ class LunoraClient {
307
357
  getAuthToken() {
308
358
  return this.authToken;
309
359
  }
360
+ /**
361
+ * The current identity fingerprint (the same stamp queued offline writes
362
+ * carry). Exposed so a durable {@link OutboxSink}'s replay handler — which
363
+ * owns its own at-least-once replay outside the built-in `OfflineQueue` —
364
+ * can drop a persisted write whose captured `identity` no longer matches the
365
+ * signed-in user, the guard the queue path applies in `flushOfflineQueue`.
366
+ */
367
+ currentIdentity() {
368
+ return this.identityFingerprint();
369
+ }
370
+ /** This client's stable identifier — the watermark key the server's custom-mutator protocol advances per `clientSeq`. */
371
+ clientIdentifier() {
372
+ return this.clientId;
373
+ }
374
+ /**
375
+ * The highest custom-mutator watermark the server has echoed for this client
376
+ * on the given shard (0 if none yet). The `@lunora/db` mutator runtime seeds
377
+ * its `clientSeq` generator from this so a reload never reissues a sequence
378
+ * the server has already applied (which it would swallow as a replay, silently
379
+ * dropping the write).
380
+ */
381
+ confirmedMutationWatermark(shardKey) {
382
+ return this.clientWatermarks.get(shardKey ?? "") ?? 0;
383
+ }
384
+ /**
385
+ * Push a custom mutator to its authoritative server impl over the watermark
386
+ * protocol (Phase 4): the request carries `x-lunora-client-id` + a monotonic
387
+ * `x-lunora-client-seq`, so the DO runs it exactly once and advances this
388
+ * client's `__client_watermark`.
389
+ *
390
+ * Returns the server `result` plus `applied`: `true` when the DO ran this push
391
+ * as the next-in-order mutation, `false` when it was a replay ack (`clientSeq`
392
+ * was at or below the stored watermark — e.g. a stale sequence after a reload).
393
+ * A `false` verdict tells the caller to reissue above the now-known watermark
394
+ * (echoed into {@link confirmedMutationWatermark}) rather than treat the benign
395
+ * ack as a confirmed write. Every ack — applied or not — bumps the watermark.
396
+ *
397
+ * This is the online transport for `@lunora/db`'s client-mutator runtime; the
398
+ * optimistic overlay + durable-outbox concerns live in that runtime, not here.
399
+ */
400
+ async callMutator(functionPath, args, options) {
401
+ const clientSeq = options?.clientSeq;
402
+ if (clientSeq !== void 0 && (!Number.isInteger(clientSeq) || clientSeq <= 0)) {
403
+ throw new Error(`callMutator: clientSeq must be a positive integer, got ${String(clientSeq)}`);
404
+ }
405
+ const bucket = options?.shardKey ?? "";
406
+ let ackWatermark;
407
+ const result = await this.rpc(functionPath, args, options?.shardKey, {
408
+ captureBookmark: true,
409
+ clientId: this.clientId,
410
+ clientSeq,
411
+ onMutationAck: (lastMutationId) => {
412
+ ackWatermark = lastMutationId;
413
+ }
414
+ });
415
+ if (ackWatermark !== void 0 && ackWatermark > (this.clientWatermarks.get(bucket) ?? 0)) {
416
+ this.clientWatermarks.set(bucket, ackWatermark);
417
+ }
418
+ const applied = ackWatermark === void 0 || ackWatermark === clientSeq;
419
+ return { applied, result };
420
+ }
310
421
  /**
311
422
  * Subscribe to auth-token changes. Returns an unsubscribe function. The
312
423
  * listener is NOT invoked on registration — use {@link getAuthToken} for
@@ -566,7 +677,7 @@ class LunoraClient {
566
677
  throw new Error("LunoraClient is closed");
567
678
  }
568
679
  const argsRecord = args;
569
- const mutationId = nextId();
680
+ const mutationId = options.mutationId ?? nextId();
570
681
  const optimisticRollbacks = this.applyOptimisticUpdates(function_.__lunoraRef, argsRecord, options.shardKey, options.optimistic);
571
682
  if (options.optimisticUpdate) {
572
683
  this.applyOptimisticUpdate(options.optimisticUpdate, args, options.shardKey, optimisticRollbacks);
@@ -580,40 +691,12 @@ class LunoraClient {
580
691
  const shouldQueueOffline = this.WebSocketImpl !== void 0 && connectedGate;
581
692
  const midReconnect = wsState === "connecting" && connectedGate;
582
693
  if (wsState !== "open" && !hasSocket && shouldQueueOffline || midReconnect) {
583
- const issuingIdentity = this.identityFingerprint();
584
- return new Promise((resolve, reject) => {
585
- const entry = {
586
- args: argsRecord,
587
- functionPath: function_.__lunoraRef,
588
- // Reuse the call's idempotency key as the queue id so the
589
- // replay carries the same `x-lunora-mutation-id` the server
590
- // dedups on.
591
- id: mutationId,
592
- // Persist the stamp alongside the record so a hydrated write
593
- // can only replay under the identity that queued it.
594
- identity: issuingIdentity,
595
- reject: (error) => {
596
- this.queuedIdentities.delete(mutationId);
597
- for (let index = optimisticRollbacks.length - 1; index >= 0; index -= 1) {
598
- optimisticRollbacks[index]?.();
599
- }
600
- reject(error instanceof Error ? error : new Error(String(error)));
601
- },
602
- resolve,
603
- shardKey: options.shardKey
604
- };
605
- this.offlineQueue.enqueue(entry);
606
- if (entry.id !== void 0) {
607
- this.queuedIdentities.set(entry.id, issuingIdentity);
608
- }
609
- });
694
+ return this.enqueueOfflineMutation(function_, argsRecord, options.shardKey, mutationId, optimisticRollbacks);
610
695
  }
611
696
  try {
612
697
  return await this.rpc(function_.__lunoraRef, argsRecord, options.shardKey, { captureBookmark: true, mutationId });
613
698
  } catch (error) {
614
- for (let index = optimisticRollbacks.length - 1; index >= 0; index -= 1) {
615
- optimisticRollbacks[index]?.();
616
- }
699
+ rollbackOptimistic(optimisticRollbacks);
617
700
  throw error;
618
701
  }
619
702
  }
@@ -1291,12 +1374,52 @@ class LunoraClient {
1291
1374
  const conn = this.getConnection(subscriptionState.shardKey);
1292
1375
  const ok = conn ? sendOn(conn, { id: subscriptionState.id, type: "unsubscribe" }) : false;
1293
1376
  if (!ok && conn) {
1294
- conn.pendingUnsubscribes.push(subscriptionState.id);
1377
+ conn.pendingUnsubscribes.push({ id: subscriptionState.id, type: "unsubscribe" });
1295
1378
  }
1296
1379
  this.subscriptions.remove(subscriptionState);
1297
1380
  }
1298
1381
  };
1299
1382
  }
1383
+ /**
1384
+ * Subscribe to a declarative **shape** — server-side partial replication
1385
+ * scoped by `shardBy` + the shape's predicate + RLS. The parallel to
1386
+ * {@link subscribe} for the poke protocol: the client sends the shape *name* +
1387
+ * validated `args` (never a `where` the client could forge), the server seeds
1388
+ * the current membership as an insert-poke and streams live membership diffs.
1389
+ * Each applied poke materializes the shape's rowset and invokes `callback`.
1390
+ *
1391
+ * Unlike {@link subscribe}, shape subscriptions are NOT deduped by
1392
+ * (name, args): the server resolves them under the socket's verified identity,
1393
+ * so every call gets its own id + view. The returned function unsubscribes.
1394
+ */
1395
+ subscribeShape(shape, callback, options = {}) {
1396
+ if (this.closed) {
1397
+ throw new Error("LunoraClient is closed");
1398
+ }
1399
+ this.nextShapeId += 1;
1400
+ const id = `shape_${this.nextShapeId.toString()}`;
1401
+ const state = {
1402
+ args: shape.args,
1403
+ callbacks: /* @__PURE__ */ new Set([callback]),
1404
+ errorCallbacks: options.onError ? /* @__PURE__ */ new Set([options.onError]) : /* @__PURE__ */ new Set(),
1405
+ id,
1406
+ name: shape.name,
1407
+ onCheckpoint: options.onCheckpoint,
1408
+ rows: /* @__PURE__ */ new Map(),
1409
+ shardKey: options.shardKey
1410
+ };
1411
+ this.shapeSubscriptions.set(id, state);
1412
+ this.ensureSocket(options.shardKey);
1413
+ this.sendShapeSubscribeIfOpen(state);
1414
+ return () => {
1415
+ this.shapeSubscriptions.delete(id);
1416
+ const conn = this.getConnection(state.shardKey);
1417
+ const ok = conn ? sendOn(conn, { id, type: "shape_unsubscribe" }) : false;
1418
+ if (!ok && conn) {
1419
+ conn.pendingUnsubscribes.push({ id, type: "shape_unsubscribe" });
1420
+ }
1421
+ };
1422
+ }
1300
1423
  /**
1301
1424
  * Open a streaming query. The function reference must be a
1302
1425
  * `kind:"stream"` registration (built with `c.query.input(...).stream(...)`);
@@ -1400,8 +1523,66 @@ class LunoraClient {
1400
1523
  this.statusListeners.clear();
1401
1524
  this.tokenExpiredListeners.clear();
1402
1525
  this.whisperHandlers.clear();
1526
+ this.shapeSubscriptions.clear();
1527
+ this.pokeBuffers.clear();
1403
1528
  }
1404
1529
  // --- Internals ----------------------------------------------------------
1530
+ /**
1531
+ * Persist a mutation that can't go out on the wire right now (offline, or
1532
+ * mid-reconnect after a prior connect). The optimistic update has already
1533
+ * been applied by `mutation`; this only chooses the durable write path and
1534
+ * rolls the optimistic write back if persistence is rejected.
1535
+ *
1536
+ * Two paths: when an `outbox` sink is wired (the `@lunora/db` executor) it
1537
+ * owns persistence + at-least-once replay, so we delegate and return
1538
+ * optimistically (confirmation rides the synced view). Otherwise the
1539
+ * built-in `OfflineQueue` resolves/rejects the returned promise on replay.
1540
+ */
1541
+ async enqueueOfflineMutation(function_, argsRecord, shardKey, mutationId, optimisticRollbacks) {
1542
+ const issuingIdentity = this.identityFingerprint();
1543
+ if (this.outbox) {
1544
+ this.outboxMutationCounter += 1;
1545
+ const outboxMutationId = this.outboxMutationCounter;
1546
+ try {
1547
+ await this.outbox.enqueue({
1548
+ args: argsRecord,
1549
+ clientId: this.clientId,
1550
+ functionPath: function_.__lunoraRef,
1551
+ idempotencyKey: `${this.clientId}:${String(outboxMutationId)}`,
1552
+ identity: issuingIdentity,
1553
+ mutationId: outboxMutationId,
1554
+ shardKey
1555
+ });
1556
+ } catch (error) {
1557
+ rollbackOptimistic(optimisticRollbacks);
1558
+ throw error instanceof Error ? error : new Error(String(error));
1559
+ }
1560
+ return void 0;
1561
+ }
1562
+ return new Promise((resolve, reject) => {
1563
+ const entry = {
1564
+ args: argsRecord,
1565
+ functionPath: function_.__lunoraRef,
1566
+ // Reuse the call's idempotency key as the queue id so the replay
1567
+ // carries the same `x-lunora-mutation-id` the server dedups on.
1568
+ id: mutationId,
1569
+ // Persist the stamp alongside the record so a hydrated write can
1570
+ // only replay under the identity that queued it.
1571
+ identity: issuingIdentity,
1572
+ reject: (error) => {
1573
+ this.queuedIdentities.delete(mutationId);
1574
+ rollbackOptimistic(optimisticRollbacks);
1575
+ reject(error instanceof Error ? error : new Error(String(error)));
1576
+ },
1577
+ resolve,
1578
+ shardKey
1579
+ };
1580
+ this.offlineQueue.enqueue(entry);
1581
+ if (entry.id !== void 0) {
1582
+ this.queuedIdentities.set(entry.id, issuingIdentity);
1583
+ }
1584
+ });
1585
+ }
1405
1586
  /**
1406
1587
  * Restore offline mutations persisted in a prior session and open a socket
1407
1588
  * for each shard they target so they flush once the WS reconnects. Failures
@@ -1514,22 +1695,27 @@ class LunoraClient {
1514
1695
  }
1515
1696
  }
1516
1697
  /**
1517
- * Apply an optimistic update to every subscription that matches the
1518
- * mutation's function ref, shard key, and args, returning the rollback
1519
- * callbacks to invoke if the mutation later fails. Scoping to the same
1520
- * (fn, shardKey, args) keeps one user's mutation from clobbering another
1521
- * subscriber's value on the same function (e.g. two users on different rooms).
1698
+ * Apply an optimistic update to the subscription that matches the mutation's
1699
+ * `(functionRef, args, shardKey)` triple, returning the rollback callbacks to
1700
+ * invoke if the mutation later fails.
1701
+ *
1702
+ * The registry is already indexed by exactly this triple via
1703
+ * `SubscriptionRegistry.key`, so at most one subscription can match. A direct
1704
+ * O(1) keyed lookup replaces the former O(N) linear scan over all subscriptions.
1705
+ *
1706
+ * `shardKey` normalization: both `undefined` and `""` map to the empty string
1707
+ * inside `SubscriptionRegistry.key` (via `?? ""`), so a mutation fired without
1708
+ * a shardKey correctly matches a subscription registered without one regardless
1709
+ * of whether the caller passed `undefined` or omitted the field.
1522
1710
  */
1523
1711
  applyOptimisticUpdates(functionRef, argsRecord, mutationShardKey, optimistic) {
1524
1712
  const optimisticRollbacks = [];
1525
1713
  if (!optimistic) {
1526
1714
  return optimisticRollbacks;
1527
1715
  }
1528
- const mutationArgsKey = stableStringify(argsRecord);
1529
- for (const state of this.subscriptions.all()) {
1530
- if (state.fn.__lunoraRef !== functionRef || state.shardKey !== mutationShardKey || state.argsKey !== mutationArgsKey) {
1531
- continue;
1532
- }
1716
+ const matchKey = SubscriptionRegistry.key(functionRef, argsRecord, mutationShardKey);
1717
+ const state = this.subscriptions.get(matchKey);
1718
+ if (state) {
1533
1719
  const rollback = applyOptimisticToState(state, optimistic);
1534
1720
  if (rollback) {
1535
1721
  optimisticRollbacks.push(rollback);
@@ -1610,6 +1796,12 @@ class LunoraClient {
1610
1796
  if (flags.mutationId) {
1611
1797
  headers["x-lunora-mutation-id"] = flags.mutationId;
1612
1798
  }
1799
+ if (flags.clientId !== void 0) {
1800
+ headers["x-lunora-client-id"] = flags.clientId;
1801
+ }
1802
+ if (flags.clientSeq !== void 0) {
1803
+ headers["x-lunora-client-seq"] = flags.clientSeq.toString();
1804
+ }
1613
1805
  if (flags.attachBookmark) {
1614
1806
  const bookmark = this.bookmark.get();
1615
1807
  if (bookmark) {
@@ -1650,6 +1842,7 @@ class LunoraClient {
1650
1842
  const statusText = response.statusText ? ` ${response.statusText}` : "";
1651
1843
  throw new Error(`LunoraClient: request failed (status ${response.status.toString()}${statusText})`);
1652
1844
  }
1845
+ flags.onMutationAck?.(body.lastMutationId);
1653
1846
  return body.result;
1654
1847
  }
1655
1848
  /**
@@ -1740,11 +1933,27 @@ class LunoraClient {
1740
1933
  sendConnectEnvelope(conn) {
1741
1934
  const context = this.effectiveConnectionContext(connectionKey(conn.shardKey));
1742
1935
  sendOn(conn, {
1936
+ // Lets the server scope this connection's `__client_watermark` so
1937
+ // custom-mutator pokes can echo this client's `lastMutationId`.
1938
+ clientId: this.clientId,
1743
1939
  id: "connect",
1744
1940
  type: "connect",
1745
1941
  ...context === void 0 ? {} : { context }
1746
1942
  });
1747
1943
  }
1944
+ /**
1945
+ * Re-send every shape subscription bound to `shardKey` over its (now open)
1946
+ * socket. Each frame carries the shape's last applied checkpoint, so the
1947
+ * server resumes from it — or re-seeds when the cursor fell below CDC
1948
+ * retention or the epoch forked.
1949
+ */
1950
+ resendShapeSubscriptions(shardKey) {
1951
+ for (const state of this.shapeSubscriptions.values()) {
1952
+ if (connectionKey(state.shardKey) === connectionKey(shardKey)) {
1953
+ this.sendShapeSubscribeIfOpen(state);
1954
+ }
1955
+ }
1956
+ }
1748
1957
  ensureSocket(shardKey) {
1749
1958
  if (this.closed || this.WebSocketImpl === void 0) {
1750
1959
  return;
@@ -1789,11 +1998,12 @@ class LunoraClient {
1789
1998
  this.sendSubscribeIfOpen(state);
1790
1999
  }
1791
2000
  }
2001
+ this.resendShapeSubscriptions(shardKey);
1792
2002
  if (conn.pendingUnsubscribes.length > 0) {
1793
2003
  const pending = conn.pendingUnsubscribes;
1794
2004
  conn.pendingUnsubscribes = [];
1795
- for (const id of pending) {
1796
- sendOn(conn, { id, type: "unsubscribe" });
2005
+ for (const { id, type } of pending) {
2006
+ sendOn(conn, { id, type });
1797
2007
  }
1798
2008
  }
1799
2009
  if (conn.pendingStreams && conn.pendingStreams.length > 0) {
@@ -1918,6 +2128,21 @@ class LunoraClient {
1918
2128
  type: "subscribe"
1919
2129
  });
1920
2130
  }
2131
+ sendShapeSubscribeIfOpen(state) {
2132
+ const conn = this.getConnection(state.shardKey);
2133
+ if (conn?.wsState !== "open") {
2134
+ return;
2135
+ }
2136
+ sendOn(conn, {
2137
+ id: state.id,
2138
+ shape: { name: state.name, ...state.args === void 0 ? {} : { args: state.args } },
2139
+ type: "shape_subscribe",
2140
+ // Resume from the last applied checkpoint when we hold one; a cold
2141
+ // subscribe omits it and the server seeds the full membership.
2142
+ ...state.serverCursor === void 0 ? {} : { sinceCheckpoint: state.serverCursor },
2143
+ ...state.serverEpoch === void 0 ? {} : { sinceEpoch: state.serverEpoch }
2144
+ });
2145
+ }
1921
2146
  handleServerMessage(raw, shardKey) {
1922
2147
  const text = decodeServerFrame(raw);
1923
2148
  if (text === void 0) {
@@ -1956,6 +2181,18 @@ class LunoraClient {
1956
2181
  this.handleErrorMessage(message);
1957
2182
  break;
1958
2183
  }
2184
+ case "pokeEnd": {
2185
+ this.handlePokeEnd(message);
2186
+ break;
2187
+ }
2188
+ case "pokePart": {
2189
+ this.handlePokePart(message);
2190
+ break;
2191
+ }
2192
+ case "pokeStart": {
2193
+ this.handlePokeStart(message);
2194
+ break;
2195
+ }
1959
2196
  case "resume": {
1960
2197
  this.handleResumeMessage(message);
1961
2198
  break;
@@ -1981,12 +2218,79 @@ class LunoraClient {
1981
2218
  }
1982
2219
  const state = id === void 0 ? void 0 : this.subscriptions.getById(id);
1983
2220
  if (state) {
1984
- const error = buildSubscriptionError(message);
1985
- for (const errorCallback of state.errorCallbacks) {
1986
- try {
1987
- errorCallback(error);
1988
- } catch {
1989
- }
2221
+ fanSubscriptionError(state.errorCallbacks, buildSubscriptionError(message));
2222
+ return;
2223
+ }
2224
+ const shapeState = id === void 0 ? void 0 : this.shapeSubscriptions.get(id);
2225
+ if (shapeState) {
2226
+ fanSubscriptionError(shapeState.errorCallbacks, buildSubscriptionError(message));
2227
+ }
2228
+ }
2229
+ handlePokeStart(message) {
2230
+ if (this.pokeBuffers.size >= LunoraClient.MAX_POKE_BUFFERS) {
2231
+ const oldest = this.pokeBuffers.keys().next().value;
2232
+ if (oldest !== void 0) {
2233
+ this.pokeBuffers.delete(oldest);
2234
+ }
2235
+ }
2236
+ this.pokeBuffers.set(message.pokeId, { baseCheckpoint: message.baseCheckpoint, epoch: message.epoch, lastMutationId: /* @__PURE__ */ new Map(), parts: /* @__PURE__ */ new Map() });
2237
+ }
2238
+ handlePokePart(message) {
2239
+ const buffer = this.pokeBuffers.get(message.pokeId);
2240
+ if (!buffer) {
2241
+ return;
2242
+ }
2243
+ const existing = buffer.parts.get(message.shapeId) ?? [];
2244
+ existing.push(...message.rowsPatch);
2245
+ buffer.parts.set(message.shapeId, existing);
2246
+ if (message.lastMutationId !== void 0) {
2247
+ buffer.lastMutationId.set(message.shapeId, message.lastMutationId);
2248
+ }
2249
+ }
2250
+ handlePokeEnd(message) {
2251
+ const buffer = this.pokeBuffers.get(message.pokeId);
2252
+ if (!buffer) {
2253
+ return;
2254
+ }
2255
+ this.pokeBuffers.delete(message.pokeId);
2256
+ for (const [shapeId, ops] of buffer.parts) {
2257
+ const state = this.shapeSubscriptions.get(shapeId);
2258
+ if (!state) {
2259
+ continue;
2260
+ }
2261
+ const epochForked = buffer.epoch !== void 0 && state.serverEpoch !== void 0 && buffer.epoch !== state.serverEpoch;
2262
+ const baseDiverged = buffer.baseCheckpoint !== void 0 && state.serverCursor !== void 0 && state.serverCursor !== buffer.baseCheckpoint;
2263
+ if (epochForked || baseDiverged) {
2264
+ state.rows.clear();
2265
+ state.serverCursor = void 0;
2266
+ state.serverEpoch = void 0;
2267
+ this.emitShapeRows(state);
2268
+ this.sendShapeSubscribeIfOpen(state);
2269
+ continue;
2270
+ }
2271
+ applyRowOpsToView(state.rows, ops);
2272
+ if (message.checkpoint !== void 0) {
2273
+ state.serverCursor = message.checkpoint;
2274
+ }
2275
+ if (message.epoch !== void 0) {
2276
+ state.serverEpoch = message.epoch;
2277
+ }
2278
+ const watermark = buffer.lastMutationId.get(shapeId);
2279
+ if (watermark !== void 0) {
2280
+ state.lastMutationId = watermark;
2281
+ }
2282
+ this.emitShapeRows(state);
2283
+ state.onCheckpoint?.({ checkpoint: state.serverCursor, mutationId: state.lastMutationId });
2284
+ }
2285
+ }
2286
+ /** Materialize a shape's keyed view to an array and invoke its callbacks. */
2287
+ // eslint-disable-next-line class-methods-use-this -- a pure state→callback fan-out kept beside the shape-subscription pipeline it serves.
2288
+ emitShapeRows(state) {
2289
+ const rows = [...state.rows.values()];
2290
+ for (const shapeCallback of state.callbacks) {
2291
+ try {
2292
+ shapeCallback(rows);
2293
+ } catch {
1990
2294
  }
1991
2295
  }
1992
2296
  }
@@ -4,7 +4,11 @@ const nextId = () => {
4
4
  return crypto.randomUUID();
5
5
  }
6
6
  idCounter += 1;
7
- return `m_${Date.now().toString(36)}_${idCounter.toString(36)}`;
7
+ const entropy = typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function" ? [...crypto.getRandomValues(new Uint8Array(8))].map((byte) => byte.toString(16).padStart(2, "0")).join("") : (
8
+ // eslint-disable-next-line sonarjs/pseudo-random -- non-cryptographic uniqueness entropy, not a security token; only reached when neither crypto.randomUUID nor crypto.getRandomValues exists
9
+ Math.random().toString(16).slice(2, 12)
10
+ );
11
+ return `m_${Date.now().toString(36)}_${idCounter.toString(36)}_${entropy}`;
8
12
  };
9
13
  const reportPersistenceError = (handler, operation, error, mutationId) => {
10
14
  if (handler) {
@@ -0,0 +1,31 @@
1
+ const createMutatorRunner = (handle, sinks) => {
2
+ let inFlight = 0;
3
+ let latestInvocation = 0;
4
+ const mutate = async (args) => {
5
+ latestInvocation += 1;
6
+ const invocation = latestInvocation;
7
+ inFlight += 1;
8
+ sinks.setPending(true);
9
+ try {
10
+ await handle(args).isPersisted.promise;
11
+ if (invocation === latestInvocation) {
12
+ sinks.setError(void 0);
13
+ }
14
+ } catch (error) {
15
+ const normalized = error instanceof Error ? error : new Error(String(error));
16
+ if (invocation === latestInvocation) {
17
+ sinks.setError(normalized);
18
+ }
19
+ throw normalized;
20
+ } finally {
21
+ inFlight -= 1;
22
+ sinks.setPending(inFlight > 0);
23
+ }
24
+ };
25
+ const reset = () => {
26
+ sinks.setError(void 0);
27
+ };
28
+ return { mutate, reset };
29
+ };
30
+
31
+ export { createMutatorRunner };
@@ -1,4 +1,4 @@
1
- import { LunoraClient } from './LunoraClient-DHMV-94j.mjs';
1
+ import { LunoraClient } from './LunoraClient-DhwA_5Kj.mjs';
2
2
 
3
3
  const createServerClient = (options) => {
4
4
  const client = new LunoraClient({ fetch: options.fetch, url: options.url });