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

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
@@ -1610,6 +1791,12 @@ class LunoraClient {
1610
1791
  if (flags.mutationId) {
1611
1792
  headers["x-lunora-mutation-id"] = flags.mutationId;
1612
1793
  }
1794
+ if (flags.clientId !== void 0) {
1795
+ headers["x-lunora-client-id"] = flags.clientId;
1796
+ }
1797
+ if (flags.clientSeq !== void 0) {
1798
+ headers["x-lunora-client-seq"] = flags.clientSeq.toString();
1799
+ }
1613
1800
  if (flags.attachBookmark) {
1614
1801
  const bookmark = this.bookmark.get();
1615
1802
  if (bookmark) {
@@ -1650,6 +1837,7 @@ class LunoraClient {
1650
1837
  const statusText = response.statusText ? ` ${response.statusText}` : "";
1651
1838
  throw new Error(`LunoraClient: request failed (status ${response.status.toString()}${statusText})`);
1652
1839
  }
1840
+ flags.onMutationAck?.(body.lastMutationId);
1653
1841
  return body.result;
1654
1842
  }
1655
1843
  /**
@@ -1740,11 +1928,27 @@ class LunoraClient {
1740
1928
  sendConnectEnvelope(conn) {
1741
1929
  const context = this.effectiveConnectionContext(connectionKey(conn.shardKey));
1742
1930
  sendOn(conn, {
1931
+ // Lets the server scope this connection's `__client_watermark` so
1932
+ // custom-mutator pokes can echo this client's `lastMutationId`.
1933
+ clientId: this.clientId,
1743
1934
  id: "connect",
1744
1935
  type: "connect",
1745
1936
  ...context === void 0 ? {} : { context }
1746
1937
  });
1747
1938
  }
1939
+ /**
1940
+ * Re-send every shape subscription bound to `shardKey` over its (now open)
1941
+ * socket. Each frame carries the shape's last applied checkpoint, so the
1942
+ * server resumes from it — or re-seeds when the cursor fell below CDC
1943
+ * retention or the epoch forked.
1944
+ */
1945
+ resendShapeSubscriptions(shardKey) {
1946
+ for (const state of this.shapeSubscriptions.values()) {
1947
+ if (connectionKey(state.shardKey) === connectionKey(shardKey)) {
1948
+ this.sendShapeSubscribeIfOpen(state);
1949
+ }
1950
+ }
1951
+ }
1748
1952
  ensureSocket(shardKey) {
1749
1953
  if (this.closed || this.WebSocketImpl === void 0) {
1750
1954
  return;
@@ -1789,11 +1993,12 @@ class LunoraClient {
1789
1993
  this.sendSubscribeIfOpen(state);
1790
1994
  }
1791
1995
  }
1996
+ this.resendShapeSubscriptions(shardKey);
1792
1997
  if (conn.pendingUnsubscribes.length > 0) {
1793
1998
  const pending = conn.pendingUnsubscribes;
1794
1999
  conn.pendingUnsubscribes = [];
1795
- for (const id of pending) {
1796
- sendOn(conn, { id, type: "unsubscribe" });
2000
+ for (const { id, type } of pending) {
2001
+ sendOn(conn, { id, type });
1797
2002
  }
1798
2003
  }
1799
2004
  if (conn.pendingStreams && conn.pendingStreams.length > 0) {
@@ -1918,6 +2123,21 @@ class LunoraClient {
1918
2123
  type: "subscribe"
1919
2124
  });
1920
2125
  }
2126
+ sendShapeSubscribeIfOpen(state) {
2127
+ const conn = this.getConnection(state.shardKey);
2128
+ if (conn?.wsState !== "open") {
2129
+ return;
2130
+ }
2131
+ sendOn(conn, {
2132
+ id: state.id,
2133
+ shape: { name: state.name, ...state.args === void 0 ? {} : { args: state.args } },
2134
+ type: "shape_subscribe",
2135
+ // Resume from the last applied checkpoint when we hold one; a cold
2136
+ // subscribe omits it and the server seeds the full membership.
2137
+ ...state.serverCursor === void 0 ? {} : { sinceCheckpoint: state.serverCursor },
2138
+ ...state.serverEpoch === void 0 ? {} : { sinceEpoch: state.serverEpoch }
2139
+ });
2140
+ }
1921
2141
  handleServerMessage(raw, shardKey) {
1922
2142
  const text = decodeServerFrame(raw);
1923
2143
  if (text === void 0) {
@@ -1956,6 +2176,18 @@ class LunoraClient {
1956
2176
  this.handleErrorMessage(message);
1957
2177
  break;
1958
2178
  }
2179
+ case "pokeEnd": {
2180
+ this.handlePokeEnd(message);
2181
+ break;
2182
+ }
2183
+ case "pokePart": {
2184
+ this.handlePokePart(message);
2185
+ break;
2186
+ }
2187
+ case "pokeStart": {
2188
+ this.handlePokeStart(message);
2189
+ break;
2190
+ }
1959
2191
  case "resume": {
1960
2192
  this.handleResumeMessage(message);
1961
2193
  break;
@@ -1981,12 +2213,79 @@ class LunoraClient {
1981
2213
  }
1982
2214
  const state = id === void 0 ? void 0 : this.subscriptions.getById(id);
1983
2215
  if (state) {
1984
- const error = buildSubscriptionError(message);
1985
- for (const errorCallback of state.errorCallbacks) {
1986
- try {
1987
- errorCallback(error);
1988
- } catch {
1989
- }
2216
+ fanSubscriptionError(state.errorCallbacks, buildSubscriptionError(message));
2217
+ return;
2218
+ }
2219
+ const shapeState = id === void 0 ? void 0 : this.shapeSubscriptions.get(id);
2220
+ if (shapeState) {
2221
+ fanSubscriptionError(shapeState.errorCallbacks, buildSubscriptionError(message));
2222
+ }
2223
+ }
2224
+ handlePokeStart(message) {
2225
+ if (this.pokeBuffers.size >= LunoraClient.MAX_POKE_BUFFERS) {
2226
+ const oldest = this.pokeBuffers.keys().next().value;
2227
+ if (oldest !== void 0) {
2228
+ this.pokeBuffers.delete(oldest);
2229
+ }
2230
+ }
2231
+ this.pokeBuffers.set(message.pokeId, { baseCheckpoint: message.baseCheckpoint, epoch: message.epoch, lastMutationId: /* @__PURE__ */ new Map(), parts: /* @__PURE__ */ new Map() });
2232
+ }
2233
+ handlePokePart(message) {
2234
+ const buffer = this.pokeBuffers.get(message.pokeId);
2235
+ if (!buffer) {
2236
+ return;
2237
+ }
2238
+ const existing = buffer.parts.get(message.shapeId) ?? [];
2239
+ existing.push(...message.rowsPatch);
2240
+ buffer.parts.set(message.shapeId, existing);
2241
+ if (message.lastMutationId !== void 0) {
2242
+ buffer.lastMutationId.set(message.shapeId, message.lastMutationId);
2243
+ }
2244
+ }
2245
+ handlePokeEnd(message) {
2246
+ const buffer = this.pokeBuffers.get(message.pokeId);
2247
+ if (!buffer) {
2248
+ return;
2249
+ }
2250
+ this.pokeBuffers.delete(message.pokeId);
2251
+ for (const [shapeId, ops] of buffer.parts) {
2252
+ const state = this.shapeSubscriptions.get(shapeId);
2253
+ if (!state) {
2254
+ continue;
2255
+ }
2256
+ const epochForked = buffer.epoch !== void 0 && state.serverEpoch !== void 0 && buffer.epoch !== state.serverEpoch;
2257
+ const baseDiverged = buffer.baseCheckpoint !== void 0 && state.serverCursor !== void 0 && state.serverCursor !== buffer.baseCheckpoint;
2258
+ if (epochForked || baseDiverged) {
2259
+ state.rows.clear();
2260
+ state.serverCursor = void 0;
2261
+ state.serverEpoch = void 0;
2262
+ this.emitShapeRows(state);
2263
+ this.sendShapeSubscribeIfOpen(state);
2264
+ continue;
2265
+ }
2266
+ applyRowOpsToView(state.rows, ops);
2267
+ if (message.checkpoint !== void 0) {
2268
+ state.serverCursor = message.checkpoint;
2269
+ }
2270
+ if (message.epoch !== void 0) {
2271
+ state.serverEpoch = message.epoch;
2272
+ }
2273
+ const watermark = buffer.lastMutationId.get(shapeId);
2274
+ if (watermark !== void 0) {
2275
+ state.lastMutationId = watermark;
2276
+ }
2277
+ this.emitShapeRows(state);
2278
+ state.onCheckpoint?.({ checkpoint: state.serverCursor, mutationId: state.lastMutationId });
2279
+ }
2280
+ }
2281
+ /** Materialize a shape's keyed view to an array and invoke its callbacks. */
2282
+ // eslint-disable-next-line class-methods-use-this -- a pure state→callback fan-out kept beside the shape-subscription pipeline it serves.
2283
+ emitShapeRows(state) {
2284
+ const rows = [...state.rows.values()];
2285
+ for (const shapeCallback of state.callbacks) {
2286
+ try {
2287
+ shapeCallback(rows);
2288
+ } catch {
1990
2289
  }
1991
2290
  }
1992
2291
  }
@@ -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-BPQx0T7W.mjs';
2
2
 
3
3
  const createServerClient = (options) => {
4
4
  const client = new LunoraClient({ fetch: options.fetch, url: options.url });