@lunora/client 1.0.0-alpha.4 → 1.0.0-alpha.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +3 -2
- package/dist/packem_shared/{LunoraClient-DHMV-94j.mjs → LunoraClient-DhwA_5Kj.mjs} +355 -51
- package/dist/packem_shared/{OfflineQueue-D5p_QgF_.mjs → OfflineQueue-D-ASeqL7.mjs} +5 -1
- package/dist/packem_shared/createMutatorRunner-BETvCd0p.mjs +31 -0
- package/dist/packem_shared/{createServerClient-DMh-2UDv.mjs → createServerClient-XahK8bPv.mjs} +1 -1
- package/dist/packem_shared/{lunora-client.d-CbnxmuqG.d.ts → lunora-client.d-DzEWBYeP.d.mts} +336 -11
- package/dist/packem_shared/{lunora-client.d-CbnxmuqG.d.mts → lunora-client.d-DzEWBYeP.d.ts} +336 -11
- package/dist/packem_shared/{preload.d-C73DT6bL.d.ts → preload.d-CmMMC-wm.d.ts} +1 -1
- package/dist/packem_shared/{preload.d-Bhq6DBSe.d.mts → preload.d-DuYqEQ5J.d.mts} +1 -1
- 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
|
@@ -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-
|
|
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
|
-
|
|
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
|
-
|
|
615
|
-
optimisticRollbacks[index]?.();
|
|
616
|
-
}
|
|
699
|
+
rollbackOptimistic(optimisticRollbacks);
|
|
617
700
|
throw error;
|
|
618
701
|
}
|
|
619
702
|
}
|
|
@@ -1291,12 +1374,52 @@ class LunoraClient {
|
|
|
1291
1374
|
const conn = this.getConnection(subscriptionState.shardKey);
|
|
1292
1375
|
const ok = conn ? sendOn(conn, { id: subscriptionState.id, type: "unsubscribe" }) : false;
|
|
1293
1376
|
if (!ok && conn) {
|
|
1294
|
-
conn.pendingUnsubscribes.push(subscriptionState.id);
|
|
1377
|
+
conn.pendingUnsubscribes.push({ id: subscriptionState.id, type: "unsubscribe" });
|
|
1295
1378
|
}
|
|
1296
1379
|
this.subscriptions.remove(subscriptionState);
|
|
1297
1380
|
}
|
|
1298
1381
|
};
|
|
1299
1382
|
}
|
|
1383
|
+
/**
|
|
1384
|
+
* Subscribe to a declarative **shape** — server-side partial replication
|
|
1385
|
+
* scoped by `shardBy` + the shape's predicate + RLS. The parallel to
|
|
1386
|
+
* {@link subscribe} for the poke protocol: the client sends the shape *name* +
|
|
1387
|
+
* validated `args` (never a `where` the client could forge), the server seeds
|
|
1388
|
+
* the current membership as an insert-poke and streams live membership diffs.
|
|
1389
|
+
* Each applied poke materializes the shape's rowset and invokes `callback`.
|
|
1390
|
+
*
|
|
1391
|
+
* Unlike {@link subscribe}, shape subscriptions are NOT deduped by
|
|
1392
|
+
* (name, args): the server resolves them under the socket's verified identity,
|
|
1393
|
+
* so every call gets its own id + view. The returned function unsubscribes.
|
|
1394
|
+
*/
|
|
1395
|
+
subscribeShape(shape, callback, options = {}) {
|
|
1396
|
+
if (this.closed) {
|
|
1397
|
+
throw new Error("LunoraClient is closed");
|
|
1398
|
+
}
|
|
1399
|
+
this.nextShapeId += 1;
|
|
1400
|
+
const id = `shape_${this.nextShapeId.toString()}`;
|
|
1401
|
+
const state = {
|
|
1402
|
+
args: shape.args,
|
|
1403
|
+
callbacks: /* @__PURE__ */ new Set([callback]),
|
|
1404
|
+
errorCallbacks: options.onError ? /* @__PURE__ */ new Set([options.onError]) : /* @__PURE__ */ new Set(),
|
|
1405
|
+
id,
|
|
1406
|
+
name: shape.name,
|
|
1407
|
+
onCheckpoint: options.onCheckpoint,
|
|
1408
|
+
rows: /* @__PURE__ */ new Map(),
|
|
1409
|
+
shardKey: options.shardKey
|
|
1410
|
+
};
|
|
1411
|
+
this.shapeSubscriptions.set(id, state);
|
|
1412
|
+
this.ensureSocket(options.shardKey);
|
|
1413
|
+
this.sendShapeSubscribeIfOpen(state);
|
|
1414
|
+
return () => {
|
|
1415
|
+
this.shapeSubscriptions.delete(id);
|
|
1416
|
+
const conn = this.getConnection(state.shardKey);
|
|
1417
|
+
const ok = conn ? sendOn(conn, { id, type: "shape_unsubscribe" }) : false;
|
|
1418
|
+
if (!ok && conn) {
|
|
1419
|
+
conn.pendingUnsubscribes.push({ id, type: "shape_unsubscribe" });
|
|
1420
|
+
}
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1300
1423
|
/**
|
|
1301
1424
|
* Open a streaming query. The function reference must be a
|
|
1302
1425
|
* `kind:"stream"` registration (built with `c.query.input(...).stream(...)`);
|
|
@@ -1400,8 +1523,66 @@ class LunoraClient {
|
|
|
1400
1523
|
this.statusListeners.clear();
|
|
1401
1524
|
this.tokenExpiredListeners.clear();
|
|
1402
1525
|
this.whisperHandlers.clear();
|
|
1526
|
+
this.shapeSubscriptions.clear();
|
|
1527
|
+
this.pokeBuffers.clear();
|
|
1403
1528
|
}
|
|
1404
1529
|
// --- Internals ----------------------------------------------------------
|
|
1530
|
+
/**
|
|
1531
|
+
* Persist a mutation that can't go out on the wire right now (offline, or
|
|
1532
|
+
* mid-reconnect after a prior connect). The optimistic update has already
|
|
1533
|
+
* been applied by `mutation`; this only chooses the durable write path and
|
|
1534
|
+
* rolls the optimistic write back if persistence is rejected.
|
|
1535
|
+
*
|
|
1536
|
+
* Two paths: when an `outbox` sink is wired (the `@lunora/db` executor) it
|
|
1537
|
+
* owns persistence + at-least-once replay, so we delegate and return
|
|
1538
|
+
* optimistically (confirmation rides the synced view). Otherwise the
|
|
1539
|
+
* built-in `OfflineQueue` resolves/rejects the returned promise on replay.
|
|
1540
|
+
*/
|
|
1541
|
+
async enqueueOfflineMutation(function_, argsRecord, shardKey, mutationId, optimisticRollbacks) {
|
|
1542
|
+
const issuingIdentity = this.identityFingerprint();
|
|
1543
|
+
if (this.outbox) {
|
|
1544
|
+
this.outboxMutationCounter += 1;
|
|
1545
|
+
const outboxMutationId = this.outboxMutationCounter;
|
|
1546
|
+
try {
|
|
1547
|
+
await this.outbox.enqueue({
|
|
1548
|
+
args: argsRecord,
|
|
1549
|
+
clientId: this.clientId,
|
|
1550
|
+
functionPath: function_.__lunoraRef,
|
|
1551
|
+
idempotencyKey: `${this.clientId}:${String(outboxMutationId)}`,
|
|
1552
|
+
identity: issuingIdentity,
|
|
1553
|
+
mutationId: outboxMutationId,
|
|
1554
|
+
shardKey
|
|
1555
|
+
});
|
|
1556
|
+
} catch (error) {
|
|
1557
|
+
rollbackOptimistic(optimisticRollbacks);
|
|
1558
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
1559
|
+
}
|
|
1560
|
+
return void 0;
|
|
1561
|
+
}
|
|
1562
|
+
return new Promise((resolve, reject) => {
|
|
1563
|
+
const entry = {
|
|
1564
|
+
args: argsRecord,
|
|
1565
|
+
functionPath: function_.__lunoraRef,
|
|
1566
|
+
// Reuse the call's idempotency key as the queue id so the replay
|
|
1567
|
+
// carries the same `x-lunora-mutation-id` the server dedups on.
|
|
1568
|
+
id: mutationId,
|
|
1569
|
+
// Persist the stamp alongside the record so a hydrated write can
|
|
1570
|
+
// only replay under the identity that queued it.
|
|
1571
|
+
identity: issuingIdentity,
|
|
1572
|
+
reject: (error) => {
|
|
1573
|
+
this.queuedIdentities.delete(mutationId);
|
|
1574
|
+
rollbackOptimistic(optimisticRollbacks);
|
|
1575
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
1576
|
+
},
|
|
1577
|
+
resolve,
|
|
1578
|
+
shardKey
|
|
1579
|
+
};
|
|
1580
|
+
this.offlineQueue.enqueue(entry);
|
|
1581
|
+
if (entry.id !== void 0) {
|
|
1582
|
+
this.queuedIdentities.set(entry.id, issuingIdentity);
|
|
1583
|
+
}
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1405
1586
|
/**
|
|
1406
1587
|
* Restore offline mutations persisted in a prior session and open a socket
|
|
1407
1588
|
* for each shard they target so they flush once the WS reconnects. Failures
|
|
@@ -1514,22 +1695,27 @@ class LunoraClient {
|
|
|
1514
1695
|
}
|
|
1515
1696
|
}
|
|
1516
1697
|
/**
|
|
1517
|
-
* Apply an optimistic update to
|
|
1518
|
-
*
|
|
1519
|
-
*
|
|
1520
|
-
*
|
|
1521
|
-
*
|
|
1698
|
+
* Apply an optimistic update to the subscription that matches the mutation's
|
|
1699
|
+
* `(functionRef, args, shardKey)` triple, returning the rollback callbacks to
|
|
1700
|
+
* invoke if the mutation later fails.
|
|
1701
|
+
*
|
|
1702
|
+
* The registry is already indexed by exactly this triple via
|
|
1703
|
+
* `SubscriptionRegistry.key`, so at most one subscription can match. A direct
|
|
1704
|
+
* O(1) keyed lookup replaces the former O(N) linear scan over all subscriptions.
|
|
1705
|
+
*
|
|
1706
|
+
* `shardKey` normalization: both `undefined` and `""` map to the empty string
|
|
1707
|
+
* inside `SubscriptionRegistry.key` (via `?? ""`), so a mutation fired without
|
|
1708
|
+
* a shardKey correctly matches a subscription registered without one regardless
|
|
1709
|
+
* of whether the caller passed `undefined` or omitted the field.
|
|
1522
1710
|
*/
|
|
1523
1711
|
applyOptimisticUpdates(functionRef, argsRecord, mutationShardKey, optimistic) {
|
|
1524
1712
|
const optimisticRollbacks = [];
|
|
1525
1713
|
if (!optimistic) {
|
|
1526
1714
|
return optimisticRollbacks;
|
|
1527
1715
|
}
|
|
1528
|
-
const
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
continue;
|
|
1532
|
-
}
|
|
1716
|
+
const matchKey = SubscriptionRegistry.key(functionRef, argsRecord, mutationShardKey);
|
|
1717
|
+
const state = this.subscriptions.get(matchKey);
|
|
1718
|
+
if (state) {
|
|
1533
1719
|
const rollback = applyOptimisticToState(state, optimistic);
|
|
1534
1720
|
if (rollback) {
|
|
1535
1721
|
optimisticRollbacks.push(rollback);
|
|
@@ -1610,6 +1796,12 @@ class LunoraClient {
|
|
|
1610
1796
|
if (flags.mutationId) {
|
|
1611
1797
|
headers["x-lunora-mutation-id"] = flags.mutationId;
|
|
1612
1798
|
}
|
|
1799
|
+
if (flags.clientId !== void 0) {
|
|
1800
|
+
headers["x-lunora-client-id"] = flags.clientId;
|
|
1801
|
+
}
|
|
1802
|
+
if (flags.clientSeq !== void 0) {
|
|
1803
|
+
headers["x-lunora-client-seq"] = flags.clientSeq.toString();
|
|
1804
|
+
}
|
|
1613
1805
|
if (flags.attachBookmark) {
|
|
1614
1806
|
const bookmark = this.bookmark.get();
|
|
1615
1807
|
if (bookmark) {
|
|
@@ -1650,6 +1842,7 @@ class LunoraClient {
|
|
|
1650
1842
|
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
1651
1843
|
throw new Error(`LunoraClient: request failed (status ${response.status.toString()}${statusText})`);
|
|
1652
1844
|
}
|
|
1845
|
+
flags.onMutationAck?.(body.lastMutationId);
|
|
1653
1846
|
return body.result;
|
|
1654
1847
|
}
|
|
1655
1848
|
/**
|
|
@@ -1740,11 +1933,27 @@ class LunoraClient {
|
|
|
1740
1933
|
sendConnectEnvelope(conn) {
|
|
1741
1934
|
const context = this.effectiveConnectionContext(connectionKey(conn.shardKey));
|
|
1742
1935
|
sendOn(conn, {
|
|
1936
|
+
// Lets the server scope this connection's `__client_watermark` so
|
|
1937
|
+
// custom-mutator pokes can echo this client's `lastMutationId`.
|
|
1938
|
+
clientId: this.clientId,
|
|
1743
1939
|
id: "connect",
|
|
1744
1940
|
type: "connect",
|
|
1745
1941
|
...context === void 0 ? {} : { context }
|
|
1746
1942
|
});
|
|
1747
1943
|
}
|
|
1944
|
+
/**
|
|
1945
|
+
* Re-send every shape subscription bound to `shardKey` over its (now open)
|
|
1946
|
+
* socket. Each frame carries the shape's last applied checkpoint, so the
|
|
1947
|
+
* server resumes from it — or re-seeds when the cursor fell below CDC
|
|
1948
|
+
* retention or the epoch forked.
|
|
1949
|
+
*/
|
|
1950
|
+
resendShapeSubscriptions(shardKey) {
|
|
1951
|
+
for (const state of this.shapeSubscriptions.values()) {
|
|
1952
|
+
if (connectionKey(state.shardKey) === connectionKey(shardKey)) {
|
|
1953
|
+
this.sendShapeSubscribeIfOpen(state);
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1748
1957
|
ensureSocket(shardKey) {
|
|
1749
1958
|
if (this.closed || this.WebSocketImpl === void 0) {
|
|
1750
1959
|
return;
|
|
@@ -1789,11 +1998,12 @@ class LunoraClient {
|
|
|
1789
1998
|
this.sendSubscribeIfOpen(state);
|
|
1790
1999
|
}
|
|
1791
2000
|
}
|
|
2001
|
+
this.resendShapeSubscriptions(shardKey);
|
|
1792
2002
|
if (conn.pendingUnsubscribes.length > 0) {
|
|
1793
2003
|
const pending = conn.pendingUnsubscribes;
|
|
1794
2004
|
conn.pendingUnsubscribes = [];
|
|
1795
|
-
for (const id of pending) {
|
|
1796
|
-
sendOn(conn, { id, type
|
|
2005
|
+
for (const { id, type } of pending) {
|
|
2006
|
+
sendOn(conn, { id, type });
|
|
1797
2007
|
}
|
|
1798
2008
|
}
|
|
1799
2009
|
if (conn.pendingStreams && conn.pendingStreams.length > 0) {
|
|
@@ -1918,6 +2128,21 @@ class LunoraClient {
|
|
|
1918
2128
|
type: "subscribe"
|
|
1919
2129
|
});
|
|
1920
2130
|
}
|
|
2131
|
+
sendShapeSubscribeIfOpen(state) {
|
|
2132
|
+
const conn = this.getConnection(state.shardKey);
|
|
2133
|
+
if (conn?.wsState !== "open") {
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
sendOn(conn, {
|
|
2137
|
+
id: state.id,
|
|
2138
|
+
shape: { name: state.name, ...state.args === void 0 ? {} : { args: state.args } },
|
|
2139
|
+
type: "shape_subscribe",
|
|
2140
|
+
// Resume from the last applied checkpoint when we hold one; a cold
|
|
2141
|
+
// subscribe omits it and the server seeds the full membership.
|
|
2142
|
+
...state.serverCursor === void 0 ? {} : { sinceCheckpoint: state.serverCursor },
|
|
2143
|
+
...state.serverEpoch === void 0 ? {} : { sinceEpoch: state.serverEpoch }
|
|
2144
|
+
});
|
|
2145
|
+
}
|
|
1921
2146
|
handleServerMessage(raw, shardKey) {
|
|
1922
2147
|
const text = decodeServerFrame(raw);
|
|
1923
2148
|
if (text === void 0) {
|
|
@@ -1956,6 +2181,18 @@ class LunoraClient {
|
|
|
1956
2181
|
this.handleErrorMessage(message);
|
|
1957
2182
|
break;
|
|
1958
2183
|
}
|
|
2184
|
+
case "pokeEnd": {
|
|
2185
|
+
this.handlePokeEnd(message);
|
|
2186
|
+
break;
|
|
2187
|
+
}
|
|
2188
|
+
case "pokePart": {
|
|
2189
|
+
this.handlePokePart(message);
|
|
2190
|
+
break;
|
|
2191
|
+
}
|
|
2192
|
+
case "pokeStart": {
|
|
2193
|
+
this.handlePokeStart(message);
|
|
2194
|
+
break;
|
|
2195
|
+
}
|
|
1959
2196
|
case "resume": {
|
|
1960
2197
|
this.handleResumeMessage(message);
|
|
1961
2198
|
break;
|
|
@@ -1981,12 +2218,79 @@ class LunoraClient {
|
|
|
1981
2218
|
}
|
|
1982
2219
|
const state = id === void 0 ? void 0 : this.subscriptions.getById(id);
|
|
1983
2220
|
if (state) {
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
2221
|
+
fanSubscriptionError(state.errorCallbacks, buildSubscriptionError(message));
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
const shapeState = id === void 0 ? void 0 : this.shapeSubscriptions.get(id);
|
|
2225
|
+
if (shapeState) {
|
|
2226
|
+
fanSubscriptionError(shapeState.errorCallbacks, buildSubscriptionError(message));
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
handlePokeStart(message) {
|
|
2230
|
+
if (this.pokeBuffers.size >= LunoraClient.MAX_POKE_BUFFERS) {
|
|
2231
|
+
const oldest = this.pokeBuffers.keys().next().value;
|
|
2232
|
+
if (oldest !== void 0) {
|
|
2233
|
+
this.pokeBuffers.delete(oldest);
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
this.pokeBuffers.set(message.pokeId, { baseCheckpoint: message.baseCheckpoint, epoch: message.epoch, lastMutationId: /* @__PURE__ */ new Map(), parts: /* @__PURE__ */ new Map() });
|
|
2237
|
+
}
|
|
2238
|
+
handlePokePart(message) {
|
|
2239
|
+
const buffer = this.pokeBuffers.get(message.pokeId);
|
|
2240
|
+
if (!buffer) {
|
|
2241
|
+
return;
|
|
2242
|
+
}
|
|
2243
|
+
const existing = buffer.parts.get(message.shapeId) ?? [];
|
|
2244
|
+
existing.push(...message.rowsPatch);
|
|
2245
|
+
buffer.parts.set(message.shapeId, existing);
|
|
2246
|
+
if (message.lastMutationId !== void 0) {
|
|
2247
|
+
buffer.lastMutationId.set(message.shapeId, message.lastMutationId);
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
handlePokeEnd(message) {
|
|
2251
|
+
const buffer = this.pokeBuffers.get(message.pokeId);
|
|
2252
|
+
if (!buffer) {
|
|
2253
|
+
return;
|
|
2254
|
+
}
|
|
2255
|
+
this.pokeBuffers.delete(message.pokeId);
|
|
2256
|
+
for (const [shapeId, ops] of buffer.parts) {
|
|
2257
|
+
const state = this.shapeSubscriptions.get(shapeId);
|
|
2258
|
+
if (!state) {
|
|
2259
|
+
continue;
|
|
2260
|
+
}
|
|
2261
|
+
const epochForked = buffer.epoch !== void 0 && state.serverEpoch !== void 0 && buffer.epoch !== state.serverEpoch;
|
|
2262
|
+
const baseDiverged = buffer.baseCheckpoint !== void 0 && state.serverCursor !== void 0 && state.serverCursor !== buffer.baseCheckpoint;
|
|
2263
|
+
if (epochForked || baseDiverged) {
|
|
2264
|
+
state.rows.clear();
|
|
2265
|
+
state.serverCursor = void 0;
|
|
2266
|
+
state.serverEpoch = void 0;
|
|
2267
|
+
this.emitShapeRows(state);
|
|
2268
|
+
this.sendShapeSubscribeIfOpen(state);
|
|
2269
|
+
continue;
|
|
2270
|
+
}
|
|
2271
|
+
applyRowOpsToView(state.rows, ops);
|
|
2272
|
+
if (message.checkpoint !== void 0) {
|
|
2273
|
+
state.serverCursor = message.checkpoint;
|
|
2274
|
+
}
|
|
2275
|
+
if (message.epoch !== void 0) {
|
|
2276
|
+
state.serverEpoch = message.epoch;
|
|
2277
|
+
}
|
|
2278
|
+
const watermark = buffer.lastMutationId.get(shapeId);
|
|
2279
|
+
if (watermark !== void 0) {
|
|
2280
|
+
state.lastMutationId = watermark;
|
|
2281
|
+
}
|
|
2282
|
+
this.emitShapeRows(state);
|
|
2283
|
+
state.onCheckpoint?.({ checkpoint: state.serverCursor, mutationId: state.lastMutationId });
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
/** Materialize a shape's keyed view to an array and invoke its callbacks. */
|
|
2287
|
+
// eslint-disable-next-line class-methods-use-this -- a pure state→callback fan-out kept beside the shape-subscription pipeline it serves.
|
|
2288
|
+
emitShapeRows(state) {
|
|
2289
|
+
const rows = [...state.rows.values()];
|
|
2290
|
+
for (const shapeCallback of state.callbacks) {
|
|
2291
|
+
try {
|
|
2292
|
+
shapeCallback(rows);
|
|
2293
|
+
} catch {
|
|
1990
2294
|
}
|
|
1991
2295
|
}
|
|
1992
2296
|
}
|
|
@@ -4,7 +4,11 @@ const nextId = () => {
|
|
|
4
4
|
return crypto.randomUUID();
|
|
5
5
|
}
|
|
6
6
|
idCounter += 1;
|
|
7
|
-
|
|
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 };
|