@lunora/client 1.0.0-alpha.3 → 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.
@@ -1,17 +1,32 @@
1
+ import { S as SubscriptionRegistry, s as stableStringify } from './subscription-C1Jy7HiF.mjs';
1
2
  import createInMemoryBookmarkStorage from './createInMemoryBookmarkStorage-BoN7a7TH.mjs';
2
3
  import { isMutationDelta, applyDelta } from './applyDelta-4jFGTPA3.mjs';
3
4
  import { createLocalStore } from './createLocalStore-DSUfoLqY.mjs';
4
- import { OfflineQueue, nextId, reportPersistenceError } from './OfflineQueue-D5p_QgF_.mjs';
5
+ import { OfflineQueue, nextId, reportPersistenceError } from './OfflineQueue-D-ASeqL7.mjs';
5
6
  import { queryCacheKey } from './createInMemoryQueryCache-B1PQ9Twl.mjs';
6
7
  import { createReconnect } from './createReconnect-Di_-oHH7.mjs';
7
8
  import { createStream } from './DEFAULT_MAX_BUFFER-BDkqO5PW.mjs';
8
- import { SubscriptionRegistry } from './SubscriptionRegistry-B-Qx_Gux.mjs';
9
9
 
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;
29
+ const DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
15
30
  const QUERY_CACHE_DEBOUNCE_MS = 250;
16
31
  const MAX_PENDING_STREAMS = 64;
17
32
  const SHARD_TRAFFIC_PATH = "/_lunora/admin/shard-traffic";
@@ -63,22 +78,6 @@ const AUTH_REMOVE_MEMBER_PATH = "/_lunora/admin/auth/organizations/members/remov
63
78
  const AUTH_CANCEL_INVITATION_PATH = "/_lunora/admin/auth/organizations/invitations/cancel";
64
79
  const DEFAULT_AUTH_BASE_PATH = "/api/auth";
65
80
  const GET_SESSION_PATH = "/get-session";
66
- const compareEntryKeys = ([a], [b]) => {
67
- if (a < b) {
68
- return -1;
69
- }
70
- return a > b ? 1 : 0;
71
- };
72
- const stableStringify = (value) => {
73
- if (value === null || typeof value !== "object") {
74
- return JSON.stringify(value);
75
- }
76
- if (Array.isArray(value)) {
77
- return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
78
- }
79
- const entries = Object.entries(value).toSorted(compareEntryKeys);
80
- return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
81
- };
82
81
  const deriveWsUrl = (url) => {
83
82
  if (url.startsWith("https://")) {
84
83
  return `wss://${url.slice("https://".length)}`;
@@ -152,6 +151,14 @@ const buildSubscriptionError = (message) => {
152
151
  const messageText = (typeof message.message === "string" ? message.message : void 0) ?? nestedMessage ?? "subscription error";
153
152
  return { message: messageText, ...code === void 0 ? {} : { code } };
154
153
  };
154
+ const fanSubscriptionError = (callbacks, error) => {
155
+ for (const errorCallback of callbacks) {
156
+ try {
157
+ errorCallback(error);
158
+ } catch {
159
+ }
160
+ }
161
+ };
155
162
  const sharedDecoder = new TextDecoder();
156
163
  const decodeServerFrame = (raw) => {
157
164
  if (typeof raw === "string") {
@@ -174,6 +181,8 @@ const sendOn = (conn, message) => {
174
181
  }
175
182
  };
176
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;
177
186
  url;
178
187
  wsUrl;
179
188
  wsToken;
@@ -183,9 +192,30 @@ class LunoraClient {
183
192
  WebSocketImpl;
184
193
  bookmark;
185
194
  reconnectOptions;
195
+ /** WS connect timeout (ms); `0` disables it. See {@link LunoraClientOptions.connectTimeoutMs}. */
196
+ connectTimeoutMs;
186
197
  /** Keepalive cadence (ms); `0` disables the heartbeat. See {@link LunoraClientOptions.heartbeatIntervalMs}. */
187
198
  heartbeatIntervalMs;
188
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;
189
219
  onPersistenceError;
190
220
  persistence;
191
221
  /** Durable read cache (Pillar 2); `undefined` when `queryCache` is omitted or `false`. */
@@ -265,6 +295,11 @@ class LunoraClient {
265
295
  * calls `.cancel()` or the iterator is garbage-collected.
266
296
  */
267
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;
268
303
  constructor(options) {
269
304
  this.url = options.url;
270
305
  this.wsUrl = options.wsUrl ?? joinUrl(deriveWsUrl(options.url), WS_PATH);
@@ -276,11 +311,14 @@ class LunoraClient {
276
311
  this.bookmark = options.bookmarkStorage ?? createInMemoryBookmarkStorage();
277
312
  this.reconnectOptions = options.reconnect;
278
313
  this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
314
+ this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
279
315
  this.defaultConnectionContext = options.connectionContext;
280
316
  this.persistence = options.persistence;
281
317
  this.queryCache = options.queryCache === false ? void 0 : options.queryCache;
282
318
  this.onPersistenceError = options.offlineQueue?.onPersistenceError;
283
319
  this.offlineQueue = new OfflineQueue(options.offlineQueue, options.persistence);
320
+ this.outbox = options.outbox;
321
+ this.clientId = options.clientId ?? `client-${nextId()}`;
284
322
  if (this.persistence) {
285
323
  queueMicrotask(() => {
286
324
  this.hydratePersistedQueue().catch(() => void 0);
@@ -319,6 +357,67 @@ class LunoraClient {
319
357
  getAuthToken() {
320
358
  return this.authToken;
321
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
+ }
322
421
  /**
323
422
  * Subscribe to auth-token changes. Returns an unsubscribe function. The
324
423
  * listener is NOT invoked on registration — use {@link getAuthToken} for
@@ -578,7 +677,7 @@ class LunoraClient {
578
677
  throw new Error("LunoraClient is closed");
579
678
  }
580
679
  const argsRecord = args;
581
- const mutationId = nextId();
680
+ const mutationId = options.mutationId ?? nextId();
582
681
  const optimisticRollbacks = this.applyOptimisticUpdates(function_.__lunoraRef, argsRecord, options.shardKey, options.optimistic);
583
682
  if (options.optimisticUpdate) {
584
683
  this.applyOptimisticUpdate(options.optimisticUpdate, args, options.shardKey, optimisticRollbacks);
@@ -592,40 +691,12 @@ class LunoraClient {
592
691
  const shouldQueueOffline = this.WebSocketImpl !== void 0 && connectedGate;
593
692
  const midReconnect = wsState === "connecting" && connectedGate;
594
693
  if (wsState !== "open" && !hasSocket && shouldQueueOffline || midReconnect) {
595
- const issuingIdentity = this.identityFingerprint();
596
- return new Promise((resolve, reject) => {
597
- const entry = {
598
- args: argsRecord,
599
- functionPath: function_.__lunoraRef,
600
- // Reuse the call's idempotency key as the queue id so the
601
- // replay carries the same `x-lunora-mutation-id` the server
602
- // dedups on.
603
- id: mutationId,
604
- // Persist the stamp alongside the record so a hydrated write
605
- // can only replay under the identity that queued it.
606
- identity: issuingIdentity,
607
- reject: (error) => {
608
- this.queuedIdentities.delete(mutationId);
609
- for (let index = optimisticRollbacks.length - 1; index >= 0; index -= 1) {
610
- optimisticRollbacks[index]?.();
611
- }
612
- reject(error instanceof Error ? error : new Error(String(error)));
613
- },
614
- resolve,
615
- shardKey: options.shardKey
616
- };
617
- this.offlineQueue.enqueue(entry);
618
- if (entry.id !== void 0) {
619
- this.queuedIdentities.set(entry.id, issuingIdentity);
620
- }
621
- });
694
+ return this.enqueueOfflineMutation(function_, argsRecord, options.shardKey, mutationId, optimisticRollbacks);
622
695
  }
623
696
  try {
624
697
  return await this.rpc(function_.__lunoraRef, argsRecord, options.shardKey, { captureBookmark: true, mutationId });
625
698
  } catch (error) {
626
- for (let index = optimisticRollbacks.length - 1; index >= 0; index -= 1) {
627
- optimisticRollbacks[index]?.();
628
- }
699
+ rollbackOptimistic(optimisticRollbacks);
629
700
  throw error;
630
701
  }
631
702
  }
@@ -739,8 +810,12 @@ class LunoraClient {
739
810
  * List a workflow's instances via the admin Workflows proxy
740
811
  * (`/_lunora/admin/workflows/instances`) — the Cloudflare control-plane data
741
812
  * the `Workflow` binding can't expose. Requires the worker to be built with a
742
- * `workflowsClient` (Cloudflare account id + API token); otherwise the proxy
743
- * responds 501 and this rejects. `name` is the deployed workflow name.
813
+ * `workflowsClient` (Cloudflare account id + API token). When one isn't
814
+ * configured this does NOT reject: the proxy returns a `200 { configured:
815
+ * false }` sentinel, so the result resolves with `configured === false` and an
816
+ * empty `instances` list — callers should branch on that flag rather than
817
+ * try/catch. (The instance-detail / status endpoints still reject with 501.)
818
+ * `name` is the deployed workflow name.
744
819
  */
745
820
  async listWorkflowInstances(options) {
746
821
  if (this.closed) {
@@ -757,7 +832,13 @@ class LunoraClient {
757
832
  query.set("perPage", String(options.perPage));
758
833
  }
759
834
  const body = await this.adminFetch(`${WORKFLOWS_INSTANCES_PATH}?${query.toString()}`, "GET");
760
- return { instances: body.instances ?? [], page: body.page ?? 1, perPage: body.perPage ?? options.perPage ?? 0, totalCount: body.totalCount };
835
+ return {
836
+ configured: body.configured,
837
+ instances: body.instances ?? [],
838
+ page: body.page ?? 1,
839
+ perPage: body.perPage ?? options.perPage ?? 0,
840
+ totalCount: body.totalCount
841
+ };
761
842
  }
762
843
  /** Read one workflow instance with its step timeline (`/_lunora/admin/workflows/instance`). */
763
844
  async getWorkflowInstance(options) {
@@ -1293,12 +1374,52 @@ class LunoraClient {
1293
1374
  const conn = this.getConnection(subscriptionState.shardKey);
1294
1375
  const ok = conn ? sendOn(conn, { id: subscriptionState.id, type: "unsubscribe" }) : false;
1295
1376
  if (!ok && conn) {
1296
- conn.pendingUnsubscribes.push(subscriptionState.id);
1377
+ conn.pendingUnsubscribes.push({ id: subscriptionState.id, type: "unsubscribe" });
1297
1378
  }
1298
1379
  this.subscriptions.remove(subscriptionState);
1299
1380
  }
1300
1381
  };
1301
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
+ }
1302
1423
  /**
1303
1424
  * Open a streaming query. The function reference must be a
1304
1425
  * `kind:"stream"` registration (built with `c.query.input(...).stream(...)`);
@@ -1375,6 +1496,10 @@ class LunoraClient {
1375
1496
  clearTimeout(conn.reconnectTimer);
1376
1497
  conn.reconnectTimer = void 0;
1377
1498
  }
1499
+ if (conn.connectTimer !== void 0) {
1500
+ clearTimeout(conn.connectTimer);
1501
+ conn.connectTimer = void 0;
1502
+ }
1378
1503
  this.stopHeartbeat(conn);
1379
1504
  if (conn.socket) {
1380
1505
  try {
@@ -1398,8 +1523,66 @@ class LunoraClient {
1398
1523
  this.statusListeners.clear();
1399
1524
  this.tokenExpiredListeners.clear();
1400
1525
  this.whisperHandlers.clear();
1526
+ this.shapeSubscriptions.clear();
1527
+ this.pokeBuffers.clear();
1401
1528
  }
1402
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
+ }
1403
1586
  /**
1404
1587
  * Restore offline mutations persisted in a prior session and open a socket
1405
1588
  * for each shard they target so they flush once the WS reconnects. Failures
@@ -1564,6 +1747,7 @@ class LunoraClient {
1564
1747
  let conn = this.connections.get(key);
1565
1748
  if (!conn) {
1566
1749
  conn = {
1750
+ connectTimer: void 0,
1567
1751
  heartbeatTimer: void 0,
1568
1752
  pendingUnsubscribes: [],
1569
1753
  reconnect: createReconnect(this.reconnectOptions),
@@ -1607,6 +1791,12 @@ class LunoraClient {
1607
1791
  if (flags.mutationId) {
1608
1792
  headers["x-lunora-mutation-id"] = flags.mutationId;
1609
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
+ }
1610
1800
  if (flags.attachBookmark) {
1611
1801
  const bookmark = this.bookmark.get();
1612
1802
  if (bookmark) {
@@ -1647,6 +1837,7 @@ class LunoraClient {
1647
1837
  const statusText = response.statusText ? ` ${response.statusText}` : "";
1648
1838
  throw new Error(`LunoraClient: request failed (status ${response.status.toString()}${statusText})`);
1649
1839
  }
1840
+ flags.onMutationAck?.(body.lastMutationId);
1650
1841
  return body.result;
1651
1842
  }
1652
1843
  /**
@@ -1737,11 +1928,27 @@ class LunoraClient {
1737
1928
  sendConnectEnvelope(conn) {
1738
1929
  const context = this.effectiveConnectionContext(connectionKey(conn.shardKey));
1739
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,
1740
1934
  id: "connect",
1741
1935
  type: "connect",
1742
1936
  ...context === void 0 ? {} : { context }
1743
1937
  });
1744
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
+ }
1745
1952
  ensureSocket(shardKey) {
1746
1953
  if (this.closed || this.WebSocketImpl === void 0) {
1747
1954
  return;
@@ -1754,7 +1961,27 @@ class LunoraClient {
1754
1961
  this.emitConnectionStatus();
1755
1962
  const socket = new this.WebSocketImpl(this.wsUrlFor(shardKey));
1756
1963
  conn.socket = socket;
1964
+ if (this.connectTimeoutMs > 0) {
1965
+ conn.connectTimer = setTimeout(() => {
1966
+ conn.connectTimer = void 0;
1967
+ if (conn.socket !== socket || conn.wsState !== "connecting") {
1968
+ return;
1969
+ }
1970
+ try {
1971
+ socket.close();
1972
+ } catch {
1973
+ }
1974
+ this.handleDisconnect(conn);
1975
+ }, this.connectTimeoutMs);
1976
+ }
1757
1977
  socket.addEventListener("open", () => {
1978
+ if (conn.socket !== socket) {
1979
+ return;
1980
+ }
1981
+ if (conn.connectTimer !== void 0) {
1982
+ clearTimeout(conn.connectTimer);
1983
+ conn.connectTimer = void 0;
1984
+ }
1758
1985
  conn.wsState = "open";
1759
1986
  conn.wasEverConnected = true;
1760
1987
  conn.reconnect.reset();
@@ -1766,11 +1993,12 @@ class LunoraClient {
1766
1993
  this.sendSubscribeIfOpen(state);
1767
1994
  }
1768
1995
  }
1996
+ this.resendShapeSubscriptions(shardKey);
1769
1997
  if (conn.pendingUnsubscribes.length > 0) {
1770
1998
  const pending = conn.pendingUnsubscribes;
1771
1999
  conn.pendingUnsubscribes = [];
1772
- for (const id of pending) {
1773
- sendOn(conn, { id, type: "unsubscribe" });
2000
+ for (const { id, type } of pending) {
2001
+ sendOn(conn, { id, type });
1774
2002
  }
1775
2003
  }
1776
2004
  if (conn.pendingStreams && conn.pendingStreams.length > 0) {
@@ -1793,12 +2021,18 @@ class LunoraClient {
1793
2021
  this.handleServerMessage(event.data, shardKey);
1794
2022
  });
1795
2023
  socket.addEventListener("close", (event) => {
2024
+ if (conn.socket !== socket) {
2025
+ return;
2026
+ }
1796
2027
  if (event?.code === 4001) {
1797
2028
  this.notifyTokenExpired();
1798
2029
  }
1799
2030
  this.handleDisconnect(conn);
1800
2031
  });
1801
2032
  socket.addEventListener("error", () => {
2033
+ if (conn.socket !== socket) {
2034
+ return;
2035
+ }
1802
2036
  if (conn.wsState === "connecting" || conn.wsState === "open") {
1803
2037
  this.handleDisconnect(conn);
1804
2038
  }
@@ -1812,6 +2046,10 @@ class LunoraClient {
1812
2046
  return;
1813
2047
  }
1814
2048
  this.stopHeartbeat(conn);
2049
+ if (conn.connectTimer !== void 0) {
2050
+ clearTimeout(conn.connectTimer);
2051
+ conn.connectTimer = void 0;
2052
+ }
1815
2053
  conn.socket = void 0;
1816
2054
  conn.wsState = "idle";
1817
2055
  this.emitConnectionStatus();
@@ -1885,6 +2123,21 @@ class LunoraClient {
1885
2123
  type: "subscribe"
1886
2124
  });
1887
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
+ }
1888
2141
  handleServerMessage(raw, shardKey) {
1889
2142
  const text = decodeServerFrame(raw);
1890
2143
  if (text === void 0) {
@@ -1923,6 +2176,18 @@ class LunoraClient {
1923
2176
  this.handleErrorMessage(message);
1924
2177
  break;
1925
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
+ }
1926
2191
  case "resume": {
1927
2192
  this.handleResumeMessage(message);
1928
2193
  break;
@@ -1948,12 +2213,79 @@ class LunoraClient {
1948
2213
  }
1949
2214
  const state = id === void 0 ? void 0 : this.subscriptions.getById(id);
1950
2215
  if (state) {
1951
- const error = buildSubscriptionError(message);
1952
- for (const errorCallback of state.errorCallbacks) {
1953
- try {
1954
- errorCallback(error);
1955
- } catch {
1956
- }
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 {
1957
2289
  }
1958
2290
  }
1959
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 @@
1
+ export { S as SubscriptionRegistry } from './subscription-C1Jy7HiF.mjs';