@lunora/client 1.0.0-alpha.20 → 1.0.0-alpha.22
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/dist/auth/index.d.mts +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/index.d.mts +212 -4
- package/dist/index.d.ts +212 -4
- package/dist/index.mjs +12 -7
- package/dist/packem_shared/ClientServiceWorker-C3PAFwy0.mjs +100 -0
- package/dist/packem_shared/{LunoraClient-hrmLXEHd.mjs → LunoraClient-Clb118SU.mjs} +339 -33
- package/dist/packem_shared/{OfflineQueue-GGYJRmhF.mjs → OfflineQueue-B4HUF7rt.mjs} +1 -1
- package/dist/packem_shared/SubscriptionRegistry-D4jfIzZu.mjs +31 -0
- package/dist/packem_shared/TabCoordinator-BwRR8H06.mjs +222 -0
- package/dist/packem_shared/{applyDelta-4jFGTPA3.mjs → applyDelta-CRKZ1PBt.mjs} +21 -1
- package/dist/packem_shared/createClientQuery-CQ51bWAE.mjs +71 -0
- package/dist/packem_shared/{createInMemoryPersistence-Ds7z8n8d.mjs → createInMemoryPersistence-DZ2VHWgm.mjs} +7 -47
- package/dist/packem_shared/{createInMemoryQueryCache-iWtKPrid.mjs → createInMemoryQueryCache-DiaGZkA2.mjs} +7 -47
- package/dist/packem_shared/createLocalStore-BDbbkoXw.mjs +2 -0
- package/dist/packem_shared/createReply-lI4tVS2w.mjs +36 -0
- package/dist/packem_shared/{createServerClient-CKKZrXLc.mjs → createServerClient-Dxemst5C.mjs} +1 -1
- package/dist/packem_shared/createSnapshotPrecondition-CBwnVz6r.mjs +18 -0
- package/dist/packem_shared/idb-utility-DrSVX43Q.mjs +48 -0
- package/dist/packem_shared/{local-store-BNgN3Dw3.mjs → local-store-DtcIW4c0.mjs} +7 -12
- package/dist/packem_shared/{lunora-client.d-DVdVtJV8.d.mts → lunora-client.d-pw-9sLl0.d.mts} +212 -2
- package/dist/packem_shared/{lunora-client.d-DVdVtJV8.d.ts → lunora-client.d-pw-9sLl0.d.ts} +212 -2
- package/dist/packem_shared/{offline-queue-B9vfdSqp.mjs → offline-queue-CF4_Co5k.mjs} +29 -0
- package/dist/packem_shared/{preload.d-BXFvxpiv.d.mts → preload.d-6ME5ubgq.d.mts} +1 -1
- package/dist/packem_shared/{preload.d-Bb69VRgE.d.ts → preload.d-BkQr-3Vh.d.ts} +1 -1
- package/dist/packem_shared/{subscription-DoyO04-2.mjs → stable-key-wv6eP48B.mjs} +1 -26
- 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 +2 -2
- package/dist/packem_shared/SubscriptionRegistry-Cxr70og-.mjs +0 -1
- package/dist/packem_shared/createLocalStore-IOur0jHF.mjs +0 -1
|
@@ -1,16 +1,46 @@
|
|
|
1
1
|
import { LunoraError } from '@lunora/errors';
|
|
2
|
-
import {
|
|
2
|
+
import { s as stableStringify } from './stable-key-wv6eP48B.mjs';
|
|
3
3
|
import createInMemoryBookmarkStorage from './createInMemoryBookmarkStorage-BoN7a7TH.mjs';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
4
|
+
import { ClientQueryStore } from './createClientQuery-CQ51bWAE.mjs';
|
|
5
|
+
import { TabCoordinator } from './TabCoordinator-BwRR8H06.mjs';
|
|
6
|
+
import { isMutationDelta, applyDelta } from './applyDelta-CRKZ1PBt.mjs';
|
|
7
|
+
import { f as foldOptimistic, a as applyOptimisticLayer, d as dropConfirmedLayers, n as notifySubscription, c as createLocalStore } from './local-store-DtcIW4c0.mjs';
|
|
8
|
+
import { O as OfflineQueue, n as nextId, i as isStaleVersion, r as reportPersistenceError } from './offline-queue-CF4_Co5k.mjs';
|
|
9
|
+
import { resolvePersistenceAdapter } from './createInMemoryPersistence-DZ2VHWgm.mjs';
|
|
10
|
+
import { resolveQueryCacheAdapter, queryCacheKey } from './createInMemoryQueryCache-DiaGZkA2.mjs';
|
|
9
11
|
import { createReconnect } from './createReconnect-Di_-oHH7.mjs';
|
|
10
12
|
import { createStream } from './DEFAULT_MAX_BUFFER-7hFnzNk9.mjs';
|
|
13
|
+
import { SubscriptionRegistry } from './SubscriptionRegistry-D4jfIzZu.mjs';
|
|
11
14
|
|
|
12
15
|
const MAX_BATCH_ENTRIES = 500;
|
|
13
16
|
|
|
17
|
+
const evictOldestEntry = (map, capacity) => {
|
|
18
|
+
if (map.size < capacity) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const oldest = map.keys().next().value;
|
|
22
|
+
if (oldest !== void 0) {
|
|
23
|
+
map.delete(oldest);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const toBase64 = (bytes) => {
|
|
28
|
+
let binary = "";
|
|
29
|
+
const chunk = 32768;
|
|
30
|
+
for (let index = 0; index < bytes.length; index += chunk) {
|
|
31
|
+
binary += String.fromCharCode(...bytes.subarray(index, index + chunk));
|
|
32
|
+
}
|
|
33
|
+
return btoa(binary);
|
|
34
|
+
};
|
|
35
|
+
const fromBase64 = (base64) => {
|
|
36
|
+
const binary = atob(base64);
|
|
37
|
+
const bytes = new Uint8Array(binary.length);
|
|
38
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
39
|
+
bytes[index] = binary.codePointAt(index) ?? 0;
|
|
40
|
+
}
|
|
41
|
+
return bytes;
|
|
42
|
+
};
|
|
43
|
+
|
|
14
44
|
const TAG = "$lunora.wire$";
|
|
15
45
|
const MAX_DEPTH = 64;
|
|
16
46
|
const MAX_BIGINT_DIGITS = 1024;
|
|
@@ -37,22 +67,6 @@ const ERROR_CTORS = {
|
|
|
37
67
|
TypeError,
|
|
38
68
|
URIError
|
|
39
69
|
};
|
|
40
|
-
const toBase64 = (bytes) => {
|
|
41
|
-
let binary = "";
|
|
42
|
-
const chunk = 32768;
|
|
43
|
-
for (let index = 0; index < bytes.length; index += chunk) {
|
|
44
|
-
binary += String.fromCharCode(...bytes.subarray(index, index + chunk));
|
|
45
|
-
}
|
|
46
|
-
return btoa(binary);
|
|
47
|
-
};
|
|
48
|
-
const fromBase64 = (base64) => {
|
|
49
|
-
const binary = atob(base64);
|
|
50
|
-
const bytes = new Uint8Array(binary.length);
|
|
51
|
-
for (let index = 0; index < binary.length; index += 1) {
|
|
52
|
-
bytes[index] = binary.codePointAt(index) ?? 0;
|
|
53
|
-
}
|
|
54
|
-
return bytes;
|
|
55
|
-
};
|
|
56
70
|
const encodeWire = (value, depth = 0) => {
|
|
57
71
|
if (depth > MAX_DEPTH) {
|
|
58
72
|
throw new RangeError(`wire-codec: value nesting exceeds the ${MAX_DEPTH}-level limit`);
|
|
@@ -453,11 +467,25 @@ const demuxBatchResults = (rawResults, count) => {
|
|
|
453
467
|
return slots.map((slot) => slot ?? { error: new Error("batch call returned no result"), ok: false });
|
|
454
468
|
};
|
|
455
469
|
const TRANSIENT_BATCH_ERROR_CODES = /* @__PURE__ */ new Set(["SHARD_ERROR", "SHARD_UNAVAILABLE"]);
|
|
470
|
+
const RESOLVED_PROMISE = Promise.resolve();
|
|
456
471
|
class LunoraClient {
|
|
457
472
|
/** 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. */
|
|
458
473
|
static MAX_POKE_BUFFERS = 256;
|
|
474
|
+
/**
|
|
475
|
+
* Create a typed {@link ClientQueryRef}. Convenience wrapper around
|
|
476
|
+
* {@link createClientQuery} so you don't need a separate import.
|
|
477
|
+
* @example
|
|
478
|
+
* ```ts
|
|
479
|
+
* const sidebarOpen = LunoraClient.createClientQuery("sidebarOpen", true);
|
|
480
|
+
* ```
|
|
481
|
+
*/
|
|
482
|
+
static createClientQuery(key, defaultValue) {
|
|
483
|
+
return { defaultValue, key };
|
|
484
|
+
}
|
|
459
485
|
url;
|
|
460
486
|
wsUrl;
|
|
487
|
+
/** Local reactive store for {@link ClientQueryRef} values — no server round-trip. Private; reach it via `getClientQuery` / `setClientQuery` / `subscribeClientQuery`. */
|
|
488
|
+
clientQueryStore;
|
|
461
489
|
wsToken;
|
|
462
490
|
/** Better-auth base path (trailing slash stripped) for the `get-session` lookup. */
|
|
463
491
|
authBasePath;
|
|
@@ -478,6 +506,20 @@ class LunoraClient {
|
|
|
478
506
|
outbox;
|
|
479
507
|
/** Stable per-client id stamped onto every `OutboxMutation` (custom-mutator watermark). */
|
|
480
508
|
clientId;
|
|
509
|
+
/**
|
|
510
|
+
* `true` when the constructor's hydration microtask has finished loading the
|
|
511
|
+
* durable read cache (Pillar 2) into `hydratedQueryCache`. Signals that
|
|
512
|
+
* the cache is ready for synchronous `peekHydratedQuery` reads.
|
|
513
|
+
*/
|
|
514
|
+
readyResolved = false;
|
|
515
|
+
/** Resolvers for `whenReady()` — called once hydration completes. */
|
|
516
|
+
readyResolve;
|
|
517
|
+
/**
|
|
518
|
+
* Promise that resolves once the durable read cache has been loaded. When
|
|
519
|
+
* `hydrateOnStart` is not set or no query cache is configured, resolves
|
|
520
|
+
* immediately (the constructor creates an already-resolved promise).
|
|
521
|
+
*/
|
|
522
|
+
readyPromise;
|
|
481
523
|
/**
|
|
482
524
|
* Highest custom-mutator watermark the server has echoed for this client,
|
|
483
525
|
* keyed by shard bucket (`shardKey ?? ""`) since the DO tracks one
|
|
@@ -511,6 +553,12 @@ class LunoraClient {
|
|
|
511
553
|
pendingCacheWrites = /* @__PURE__ */ new Map();
|
|
512
554
|
cacheFlushTimer;
|
|
513
555
|
subscriptions = new SubscriptionRegistry();
|
|
556
|
+
/**
|
|
557
|
+
* Cross-tab coordinator; created only when `crossTabSync: true`. When the
|
|
558
|
+
* client is not the elected leader, all WebSocket operations are skipped.
|
|
559
|
+
* Not `readonly` — `close()` clears it (mirrors `outboxLeaderRelease`).
|
|
560
|
+
*/
|
|
561
|
+
tabCoordinator;
|
|
514
562
|
/** One {@link ShardConnection} per shard key (keyed by `shardKey ?? ""`). */
|
|
515
563
|
connections = /* @__PURE__ */ new Map();
|
|
516
564
|
/** Default `connect`-envelope context applied to a shard with no explicit override. */
|
|
@@ -606,6 +654,59 @@ class LunoraClient {
|
|
|
606
654
|
this.persistenceVersion = options.persistenceVersion;
|
|
607
655
|
this.queryCache = resolveQueryCacheAdapter(options.queryCache);
|
|
608
656
|
this.onPersistenceError = options.offlineQueue?.onPersistenceError;
|
|
657
|
+
this.clientQueryStore = new ClientQueryStore();
|
|
658
|
+
if (options.crossTabSync) {
|
|
659
|
+
this.tabCoordinator = new TabCoordinator({
|
|
660
|
+
onBecomeLeader: () => {
|
|
661
|
+
for (const state of this.subscriptions.all()) {
|
|
662
|
+
this.ensureSocket(state.shardKey);
|
|
663
|
+
this.sendSubscribeIfOpen(state);
|
|
664
|
+
}
|
|
665
|
+
},
|
|
666
|
+
onStopBeingLeader: () => {
|
|
667
|
+
for (const [key, conn] of this.connections) {
|
|
668
|
+
conn.socket?.close();
|
|
669
|
+
this.connections.delete(key);
|
|
670
|
+
}
|
|
671
|
+
},
|
|
672
|
+
onSubscriptionData: (key, data) => {
|
|
673
|
+
const state = this.subscriptions.get(key);
|
|
674
|
+
if (state) {
|
|
675
|
+
state.serverBase = data;
|
|
676
|
+
const folded = state.optimisticLayers.length === 0 ? data : foldOptimistic(data, state.optimisticLayers);
|
|
677
|
+
state.lastValue = folded;
|
|
678
|
+
for (const callback of state.callbacks) {
|
|
679
|
+
try {
|
|
680
|
+
callback(folded);
|
|
681
|
+
} catch {
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
},
|
|
686
|
+
onSubscriptionError: (key, error) => {
|
|
687
|
+
const state = this.subscriptions.get(key);
|
|
688
|
+
if (state) {
|
|
689
|
+
for (const callback of state.errorCallbacks) {
|
|
690
|
+
try {
|
|
691
|
+
callback(error);
|
|
692
|
+
} catch {
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
this.tabCoordinator.start();
|
|
699
|
+
} else {
|
|
700
|
+
this.tabCoordinator = void 0;
|
|
701
|
+
}
|
|
702
|
+
if (options.hydrateOnStart && this.queryCache) {
|
|
703
|
+
this.readyPromise = new Promise((resolve) => {
|
|
704
|
+
this.readyResolve = resolve;
|
|
705
|
+
});
|
|
706
|
+
} else {
|
|
707
|
+
this.readyResolved = true;
|
|
708
|
+
this.readyPromise = RESOLVED_PROMISE;
|
|
709
|
+
}
|
|
609
710
|
this.offlineQueue = new OfflineQueue(options.offlineQueue, {
|
|
610
711
|
onEvict: (entry, error) => {
|
|
611
712
|
this.emitItemSettled(entry, "rejected", error);
|
|
@@ -625,7 +726,17 @@ class LunoraClient {
|
|
|
625
726
|
}
|
|
626
727
|
if (this.queryCache) {
|
|
627
728
|
queueMicrotask(() => {
|
|
628
|
-
|
|
729
|
+
const hydration = async () => {
|
|
730
|
+
try {
|
|
731
|
+
await this.hydrateQueryCache();
|
|
732
|
+
} catch {
|
|
733
|
+
} finally {
|
|
734
|
+
this.readyResolved = true;
|
|
735
|
+
this.readyResolve?.();
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
hydration().catch(() => {
|
|
739
|
+
});
|
|
629
740
|
});
|
|
630
741
|
}
|
|
631
742
|
}
|
|
@@ -998,6 +1109,135 @@ class LunoraClient {
|
|
|
998
1109
|
onMutationSettled(listener) {
|
|
999
1110
|
return this.mutationSettledListeners.add(listener);
|
|
1000
1111
|
}
|
|
1112
|
+
// --- Client Query (local state) ----------------------------------------
|
|
1113
|
+
/**
|
|
1114
|
+
* Read the current value for a {@link ClientQueryRef}. Returns
|
|
1115
|
+
* `ref.defaultValue` when no value has been explicitly set.
|
|
1116
|
+
*/
|
|
1117
|
+
getClientQuery(ref) {
|
|
1118
|
+
return this.clientQueryStore.get(ref);
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Set a new value for `ref` and notify every subscriber. Pass `undefined`
|
|
1122
|
+
* to reset the slot to `ref.defaultValue`.
|
|
1123
|
+
*/
|
|
1124
|
+
setClientQuery(ref, value) {
|
|
1125
|
+
this.clientQueryStore.set(ref, value);
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Subscribe to changes for `ref`. The callback is NOT invoked on
|
|
1129
|
+
* registration — call {@link getClientQuery} for the current value.
|
|
1130
|
+
* Returns an unsubscribe function.
|
|
1131
|
+
*/
|
|
1132
|
+
subscribeClientQuery(ref, callback) {
|
|
1133
|
+
return this.clientQueryStore.subscribe(ref, callback);
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Reset a {@link ClientQueryRef} to its default value, notifying every
|
|
1137
|
+
* subscriber. Equivalent to `setClientQuery(ref, ref.defaultValue)` but
|
|
1138
|
+
* removes the stored entry so a future {@link getClientQuery} returns
|
|
1139
|
+
* the default rather than an explicitly-set value.
|
|
1140
|
+
*/
|
|
1141
|
+
resetClientQuery(ref) {
|
|
1142
|
+
this.clientQueryStore.reset(ref);
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* Capture a snapshot of the current live query value at call time and
|
|
1146
|
+
* produce a `() => boolean` precondition that compares it against the
|
|
1147
|
+
* value at replay time (on queue drain / reconnect).
|
|
1148
|
+
*
|
|
1149
|
+
* When the precondition is checked it re-reads the query's current value
|
|
1150
|
+
* via `peekActiveQueryValue`. If the value differs from what was
|
|
1151
|
+
* captured at call time the precondition returns `false` and the offline
|
|
1152
|
+
* mutation is dropped as stale.
|
|
1153
|
+
*
|
|
1154
|
+
* This is a method wrapper around `createSnapshotPrecondition` that
|
|
1155
|
+
* binds the client instance for you — no need to pass `client` explicitly.
|
|
1156
|
+
* @example
|
|
1157
|
+
* ```ts
|
|
1158
|
+
* client.mutation(api.todos.update, { id, text }, {
|
|
1159
|
+
* precondition: client.snapshotPrecondition(api.todos.list, { userId }),
|
|
1160
|
+
* });
|
|
1161
|
+
* ```
|
|
1162
|
+
*/
|
|
1163
|
+
snapshotPrecondition(functionRef, args, shardKey) {
|
|
1164
|
+
const snapshot = this.peekActiveQueryValue(functionRef.__lunoraRef, args, shardKey);
|
|
1165
|
+
const snapshotKey = snapshot === void 0 ? void 0 : stableStringify(snapshot);
|
|
1166
|
+
return () => {
|
|
1167
|
+
const current = this.peekActiveQueryValue(functionRef.__lunoraRef, args, shardKey);
|
|
1168
|
+
if (snapshotKey === void 0 && current === void 0) {
|
|
1169
|
+
return true;
|
|
1170
|
+
}
|
|
1171
|
+
if (snapshotKey === void 0 || current === void 0) {
|
|
1172
|
+
return false;
|
|
1173
|
+
}
|
|
1174
|
+
return stableStringify(current) === snapshotKey;
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
// --- Hydration helpers --------------------------------------------------
|
|
1178
|
+
/**
|
|
1179
|
+
* Resolves once the durable read cache has been loaded into memory. When
|
|
1180
|
+
* `hydrateOnStart` is not configured or no query cache adapter is active,
|
|
1181
|
+
* returns an already-resolved promise so callers can always await it
|
|
1182
|
+
* unconditionally.
|
|
1183
|
+
*
|
|
1184
|
+
* Framework adapters (React, Vue, etc.) use this to gate the first
|
|
1185
|
+
* (enabled) render of a live query behind hydration, so the user sees
|
|
1186
|
+
* cached data instead of an undefined flash before the socket round-trip.
|
|
1187
|
+
*/
|
|
1188
|
+
whenReady() {
|
|
1189
|
+
return this.readyPromise;
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* Synchronously reports whether {@link whenReady} has already resolved (the
|
|
1193
|
+
* durable read cache is loaded, or none is configured). Framework adapters
|
|
1194
|
+
* read this to seed the hydration-gate state on the first render without
|
|
1195
|
+
* awaiting, then subscribe via {@link whenReady} for the pending case.
|
|
1196
|
+
*/
|
|
1197
|
+
get isReady() {
|
|
1198
|
+
return this.readyResolved;
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Synchronously peek at a value the durable read cache loaded for the given
|
|
1202
|
+
* function path + args + shard key. Returns `undefined` when:
|
|
1203
|
+
*
|
|
1204
|
+
* - No query cache adapter is configured.
|
|
1205
|
+
* - Hydration hasn't completed yet (race — await {@link whenReady} first).
|
|
1206
|
+
* - The cached value's identity fingerprint doesn't match the current auth.
|
|
1207
|
+
*
|
|
1208
|
+
* Unlike the internal {@link takeHydratedCache}, this is a READ-ONLY peek:
|
|
1209
|
+
* the cached entry stays in `hydratedQueryCache` so the subscription created
|
|
1210
|
+
* later by {@link subscribe} consumes it normally.
|
|
1211
|
+
*/
|
|
1212
|
+
peekHydratedQuery(functionPath, args, shardKey) {
|
|
1213
|
+
if (!this.readyResolved) {
|
|
1214
|
+
return void 0;
|
|
1215
|
+
}
|
|
1216
|
+
const argsKey = stableStringify(args);
|
|
1217
|
+
const key = queryCacheKey(functionPath, argsKey, shardKey);
|
|
1218
|
+
const entry = this.hydratedQueryCache.get(key);
|
|
1219
|
+
if (entry === void 0) {
|
|
1220
|
+
return void 0;
|
|
1221
|
+
}
|
|
1222
|
+
return entry.identity === this.identityFingerprint() ? entry.value : void 0;
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Peek at the **current live value** of an active subscription, if one
|
|
1226
|
+
* exists. Returns the subscription's `lastValue` (which includes any
|
|
1227
|
+
* optimistic overlay) or `undefined` if no subscription is active for the
|
|
1228
|
+
* given `(functionPath, args, shardKey)`.
|
|
1229
|
+
*
|
|
1230
|
+
* Unlike {@link peekHydratedQuery} (which reads from the durable read cache
|
|
1231
|
+
* and is independent of active subscriptions), this method reflects the
|
|
1232
|
+
* current in-memory state of an already-opened subscription — useful for
|
|
1233
|
+
* offline mutation preconditions that need to snapshot the value at call time
|
|
1234
|
+
* and compare it at replay time.
|
|
1235
|
+
*/
|
|
1236
|
+
peekActiveQueryValue(functionPath, args, shardKey) {
|
|
1237
|
+
const key = SubscriptionRegistry.key(functionPath, args, shardKey);
|
|
1238
|
+
const state = this.subscriptions.get(key);
|
|
1239
|
+
return state?.lastValue;
|
|
1240
|
+
}
|
|
1001
1241
|
// --- RPC ---------------------------------------------------------------
|
|
1002
1242
|
async query(function_, args, options = {}) {
|
|
1003
1243
|
if (this.closed) {
|
|
@@ -1095,7 +1335,15 @@ class LunoraClient {
|
|
|
1095
1335
|
const shouldQueueOffline = this.WebSocketImpl !== void 0 && connectedGate;
|
|
1096
1336
|
const midReconnect = wsState === "connecting" && connectedGate;
|
|
1097
1337
|
if (wsState !== "open" && !hasSocket && shouldQueueOffline || midReconnect) {
|
|
1098
|
-
return this.enqueueOfflineMutation(
|
|
1338
|
+
return this.enqueueOfflineMutation(
|
|
1339
|
+
function_,
|
|
1340
|
+
argsRecord,
|
|
1341
|
+
options.shardKey,
|
|
1342
|
+
mutationId,
|
|
1343
|
+
optimisticRollbacks,
|
|
1344
|
+
optimisticConfirms,
|
|
1345
|
+
options.precondition
|
|
1346
|
+
);
|
|
1099
1347
|
}
|
|
1100
1348
|
try {
|
|
1101
1349
|
let commitCursor;
|
|
@@ -2103,6 +2351,8 @@ class LunoraClient {
|
|
|
2103
2351
|
this.whisperHandlers.clear();
|
|
2104
2352
|
this.shapeSubscriptions.clear();
|
|
2105
2353
|
this.pokeBuffers.clear();
|
|
2354
|
+
this.tabCoordinator?.stop();
|
|
2355
|
+
this.tabCoordinator = void 0;
|
|
2106
2356
|
}
|
|
2107
2357
|
// --- Internals ----------------------------------------------------------
|
|
2108
2358
|
/**
|
|
@@ -2116,7 +2366,7 @@ class LunoraClient {
|
|
|
2116
2366
|
* optimistically (confirmation rides the synced view). Otherwise the
|
|
2117
2367
|
* built-in `OfflineQueue` resolves/rejects the returned promise on replay.
|
|
2118
2368
|
*/
|
|
2119
|
-
async enqueueOfflineMutation(function_, argsRecord, shardKey, mutationId, optimisticRollbacks, optimisticConfirms) {
|
|
2369
|
+
async enqueueOfflineMutation(function_, argsRecord, shardKey, mutationId, optimisticRollbacks, optimisticConfirms, precondition) {
|
|
2120
2370
|
const issuingIdentity = this.identityFingerprint();
|
|
2121
2371
|
if (this.outbox) {
|
|
2122
2372
|
this.outboxMutationCounter += 1;
|
|
@@ -2154,6 +2404,8 @@ class LunoraClient {
|
|
|
2154
2404
|
// Persist the stamp alongside the record so a hydrated write can
|
|
2155
2405
|
// only replay under the identity that queued it.
|
|
2156
2406
|
identity: issuingIdentity,
|
|
2407
|
+
// Optional precondition checked before replay — see drainConflict.
|
|
2408
|
+
precondition,
|
|
2157
2409
|
// Confirm the per-call optimistic layer(s) against the commit cursor
|
|
2158
2410
|
// the flush replay echoes (see flushOfflineQueue).
|
|
2159
2411
|
onCommit: (commitCursor) => {
|
|
@@ -2380,7 +2632,7 @@ class LunoraClient {
|
|
|
2380
2632
|
* mutation or leave a partial patch live.
|
|
2381
2633
|
*/
|
|
2382
2634
|
applyOptimisticUpdate(optimisticUpdate, args, shardKey, optimisticRollbacks, optimisticConfirms) {
|
|
2383
|
-
const { confirms, rollbacks, store } = createLocalStore(this.subscriptions, shardKey
|
|
2635
|
+
const { confirms, rollbacks, store } = createLocalStore(this.subscriptions, shardKey);
|
|
2384
2636
|
try {
|
|
2385
2637
|
optimisticUpdate(store, args);
|
|
2386
2638
|
} catch {
|
|
@@ -2608,6 +2860,9 @@ class LunoraClient {
|
|
|
2608
2860
|
if (this.closed || this.WebSocketImpl === void 0) {
|
|
2609
2861
|
return;
|
|
2610
2862
|
}
|
|
2863
|
+
if (this.tabCoordinator && !this.tabCoordinator.isLeader()) {
|
|
2864
|
+
return;
|
|
2865
|
+
}
|
|
2611
2866
|
const conn = this.getOrCreateConnection(shardKey);
|
|
2612
2867
|
if (conn.wsState === "open" || conn.wsState === "connecting") {
|
|
2613
2868
|
return;
|
|
@@ -2709,6 +2964,13 @@ class LunoraClient {
|
|
|
2709
2964
|
conn.wsState = "idle";
|
|
2710
2965
|
this.emitConnectionStatus();
|
|
2711
2966
|
this.markShardPendingAck(conn.shardKey);
|
|
2967
|
+
const pendingStreamIds = new Set((conn.pendingStreams ?? []).map((message) => message.id));
|
|
2968
|
+
for (const [id, stream] of this.streams) {
|
|
2969
|
+
if (connectionKey(stream.shardKey) === connectionKey(conn.shardKey) && !pendingStreamIds.has(id)) {
|
|
2970
|
+
stream.handle.fail(new LunoraError("STREAM_DISCONNECTED", "stream terminated: WebSocket disconnected"));
|
|
2971
|
+
this.streams.delete(id);
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2712
2974
|
if (this.WebSocketImpl === void 0) {
|
|
2713
2975
|
return;
|
|
2714
2976
|
}
|
|
@@ -2881,12 +3143,7 @@ class LunoraClient {
|
|
|
2881
3143
|
}
|
|
2882
3144
|
}
|
|
2883
3145
|
handlePokeStart(message) {
|
|
2884
|
-
|
|
2885
|
-
const oldest = this.pokeBuffers.keys().next().value;
|
|
2886
|
-
if (oldest !== void 0) {
|
|
2887
|
-
this.pokeBuffers.delete(oldest);
|
|
2888
|
-
}
|
|
2889
|
-
}
|
|
3146
|
+
evictOldestEntry(this.pokeBuffers, LunoraClient.MAX_POKE_BUFFERS);
|
|
2890
3147
|
this.pokeBuffers.set(message.pokeId, { baseCheckpoint: message.baseCheckpoint, epoch: message.epoch, lastMutationId: /* @__PURE__ */ new Map(), parts: /* @__PURE__ */ new Map() });
|
|
2891
3148
|
}
|
|
2892
3149
|
handlePokePart(message) {
|
|
@@ -2967,6 +3224,10 @@ class LunoraClient {
|
|
|
2967
3224
|
this.persistQueryValue(state);
|
|
2968
3225
|
dropConfirmedLayers(state, state.serverCursor);
|
|
2969
3226
|
notifySubscription(state, state.optimisticLayers.length === 0 ? payload : foldOptimistic(payload, state.optimisticLayers));
|
|
3227
|
+
if (this.tabCoordinator?.isLeader()) {
|
|
3228
|
+
const key = SubscriptionRegistry.key(state.fn.__lunoraRef, state.args, state.shardKey);
|
|
3229
|
+
this.tabCoordinator.broadcastSubscriptionData(key, payload);
|
|
3230
|
+
}
|
|
2970
3231
|
}
|
|
2971
3232
|
/**
|
|
2972
3233
|
* Handle a `resume` frame (Pillar 1b): the server proved nothing the
|
|
@@ -3108,6 +3369,24 @@ class LunoraClient {
|
|
|
3108
3369
|
if (token === null) {
|
|
3109
3370
|
return null;
|
|
3110
3371
|
}
|
|
3372
|
+
return this.hashToken(token);
|
|
3373
|
+
}
|
|
3374
|
+
/**
|
|
3375
|
+
* Stable token-hash fingerprint of a bearer token (the `<len>:<fnv>:<djb2>`
|
|
3376
|
+
* format a token-stamped queued write carries). Extracted so the replay gate
|
|
3377
|
+
* can recompute the hash of the current credential and recognise a write
|
|
3378
|
+
* stamped under it — even after the fingerprint was relabelled to a subject.
|
|
3379
|
+
*
|
|
3380
|
+
* Two independent 32-bit passes (FNV-1a + djb2) give a ~64-bit digest, so
|
|
3381
|
+
* two distinct equal-length tokens are astronomically unlikely to share a
|
|
3382
|
+
* fingerprint. A single 32-bit hash collides ~1-in-4e9 per equal-length
|
|
3383
|
+
* pair — enough that, on a shared device, user B could hydrate A's cached
|
|
3384
|
+
* reads. Different algorithms (not the same FNV with a different seed, which
|
|
3385
|
+
* would be affine-related) keep the two passes genuinely independent.
|
|
3386
|
+
* Still synchronous (no crypto) and stable across surrogate pairs.
|
|
3387
|
+
*/
|
|
3388
|
+
// eslint-disable-next-line class-methods-use-this -- pure helper; a method for locality with identityFingerprint, reads no shared state
|
|
3389
|
+
hashToken(token) {
|
|
3111
3390
|
let fnv = 2166136261;
|
|
3112
3391
|
let djb2 = 5381;
|
|
3113
3392
|
for (let index = 0; index < token.length; index += 1) {
|
|
@@ -3118,6 +3397,25 @@ class LunoraClient {
|
|
|
3118
3397
|
}
|
|
3119
3398
|
return `${token.length.toString(36)}:${(fnv >>> 0).toString(36)}:${(djb2 >>> 0).toString(36)}`;
|
|
3120
3399
|
}
|
|
3400
|
+
/**
|
|
3401
|
+
* True when `stamped` is a token-hash of the SAME credential still held now,
|
|
3402
|
+
* even though the live identity has since been relabelled to a subject. Covers
|
|
3403
|
+
* `setAuthToken(token, userId)` where the subject resolved a tick after the
|
|
3404
|
+
* token was set: a write persisted (or requeued) under the token hash must
|
|
3405
|
+
* still replay — the credential never changed, only its label — instead of
|
|
3406
|
+
* being dropped as an identity mismatch. This is the durable counterpart to
|
|
3407
|
+
* {@link restampQueuedIdentity}, which only relabels the in-memory live stamp
|
|
3408
|
+
* (consumed on the first flush) and never touches `item.identity` or the
|
|
3409
|
+
* persisted record, so a reload or a transient-failure requeue would otherwise
|
|
3410
|
+
* fall back to the stale token-hash and wrongly reject the same user's write.
|
|
3411
|
+
*/
|
|
3412
|
+
isSameCredentialUnderTokenHash(stamped) {
|
|
3413
|
+
if (stamped === null || stamped.startsWith("subj:")) {
|
|
3414
|
+
return false;
|
|
3415
|
+
}
|
|
3416
|
+
const token = this.authToken;
|
|
3417
|
+
return token === null ? false : this.hashToken(token) === stamped;
|
|
3418
|
+
}
|
|
3121
3419
|
/**
|
|
3122
3420
|
* Drain every in-memory offline write and reject it because the auth
|
|
3123
3421
|
* identity changed. Durable entries are also dropped from persistence so a
|
|
@@ -3168,6 +3466,14 @@ class LunoraClient {
|
|
|
3168
3466
|
this.queryCache?.clear().catch(() => void 0);
|
|
3169
3467
|
}
|
|
3170
3468
|
async flushOfflineQueue(shardKey) {
|
|
3469
|
+
const conflicted = this.offlineQueue.drainConflict();
|
|
3470
|
+
for (const item of conflicted) {
|
|
3471
|
+
this.unpersist(item.id);
|
|
3472
|
+
this.queuedIdentities.delete(item.id ?? "");
|
|
3473
|
+
const error = new Error("offline mutation skipped: precondition failed before replay");
|
|
3474
|
+
error.code = "OFFLINE_PRECONDITION_FAILED";
|
|
3475
|
+
this.emitItemSettled(item, "rejected", error);
|
|
3476
|
+
}
|
|
3171
3477
|
const key = connectionKey(shardKey);
|
|
3172
3478
|
const drained = this.offlineQueue.drain((item) => connectionKey(item.shardKey) === key);
|
|
3173
3479
|
if (drained.length === 0) {
|
|
@@ -3246,7 +3552,7 @@ class LunoraClient {
|
|
|
3246
3552
|
passesReplayIdentityGate(item, currentIdentity) {
|
|
3247
3553
|
const liveStamp = item.id === void 0 ? void 0 : this.queuedIdentities.get(item.id);
|
|
3248
3554
|
const stamped = liveStamp === void 0 ? item.identity : liveStamp;
|
|
3249
|
-
if (stamped !== void 0 && stamped !== currentIdentity) {
|
|
3555
|
+
if (stamped !== void 0 && stamped !== currentIdentity && !this.isSameCredentialUnderTokenHash(stamped)) {
|
|
3250
3556
|
this.queuedIdentities.delete(item.id ?? "");
|
|
3251
3557
|
this.unpersist(item.id);
|
|
3252
3558
|
const error = new Error("offline mutation skipped: auth identity changed before replay");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { O as OfflineQueue, n as nextId, r as reportPersistenceError } from './offline-queue-
|
|
1
|
+
export { O as OfflineQueue, n as nextId, r as reportPersistenceError } from './offline-queue-CF4_Co5k.mjs';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { s as stableStringify } from './stable-key-wv6eP48B.mjs';
|
|
2
|
+
|
|
3
|
+
class SubscriptionRegistry {
|
|
4
|
+
static key(functionPath, args, shardKey) {
|
|
5
|
+
return `${functionPath}::${stableStringify(args)}::${shardKey ?? ""}`;
|
|
6
|
+
}
|
|
7
|
+
byKey = /* @__PURE__ */ new Map();
|
|
8
|
+
byId = /* @__PURE__ */ new Map();
|
|
9
|
+
get(key) {
|
|
10
|
+
return this.byKey.get(key);
|
|
11
|
+
}
|
|
12
|
+
getById(id) {
|
|
13
|
+
return this.byId.get(id);
|
|
14
|
+
}
|
|
15
|
+
add(state) {
|
|
16
|
+
this.byKey.set(SubscriptionRegistry.key(state.fn.__lunoraRef, state.args, state.shardKey), state);
|
|
17
|
+
this.byId.set(state.id, state);
|
|
18
|
+
}
|
|
19
|
+
remove(state) {
|
|
20
|
+
const key = SubscriptionRegistry.key(state.fn.__lunoraRef, state.args, state.shardKey);
|
|
21
|
+
if (this.byKey.get(key) === state) {
|
|
22
|
+
this.byKey.delete(key);
|
|
23
|
+
}
|
|
24
|
+
this.byId.delete(state.id);
|
|
25
|
+
}
|
|
26
|
+
all() {
|
|
27
|
+
return [...this.byKey.values()];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export { SubscriptionRegistry };
|