@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.
- package/README.md +2 -0
- package/dist/auth/index.d.mts +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/index.d.mts +67 -4
- package/dist/index.d.ts +67 -4
- package/dist/index.mjs +4 -3
- package/dist/packem_shared/{LunoraClient-UiULzH_1.mjs → LunoraClient-BPQx0T7W.mjs} +393 -61
- package/dist/packem_shared/{OfflineQueue-D5p_QgF_.mjs → OfflineQueue-D-ASeqL7.mjs} +5 -1
- package/dist/packem_shared/SubscriptionRegistry-Dn-7k7eo.mjs +1 -0
- package/dist/packem_shared/createMutatorRunner-BETvCd0p.mjs +31 -0
- package/dist/packem_shared/{createServerClient-BjZc3gD8.mjs → createServerClient-BgzEOQ46.mjs} +1 -1
- package/dist/packem_shared/{lunora-client.d-DGvyuJ_p.d.mts → lunora-client.d-CL9inoxa.d.mts} +354 -10
- package/dist/packem_shared/{lunora-client.d-DGvyuJ_p.d.ts → lunora-client.d-CL9inoxa.d.ts} +354 -10
- package/dist/packem_shared/{preload.d-BoDmFqSG.d.ts → preload.d-B0O7Zm9z.d.ts} +1 -1
- package/dist/packem_shared/{preload.d-dSaRMuhL.d.mts → preload.d-Da_yjk6L.d.mts} +1 -1
- package/dist/packem_shared/subscription-C1Jy7HiF.mjs +55 -0
- package/dist/query/index.d.mts +2 -2
- package/dist/query/index.d.ts +2 -2
- package/dist/ssr/index.d.mts +3 -3
- package/dist/ssr/index.d.ts +3 -3
- package/dist/ssr/index.mjs +1 -1
- package/package.json +1 -1
- package/dist/packem_shared/SubscriptionRegistry-B-Qx_Gux.mjs +0 -26
|
@@ -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-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
743
|
-
*
|
|
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 {
|
|
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
|
|
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
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
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
|
-
|
|
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';
|