@lunora/client 1.0.0-alpha.21 → 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 +9 -4
- package/dist/packem_shared/ClientServiceWorker-C3PAFwy0.mjs +100 -0
- package/dist/packem_shared/{LunoraClient-kXpHNyaE.mjs → LunoraClient-Clb118SU.mjs} +279 -22
- 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/createClientQuery-CQ51bWAE.mjs +71 -0
- package/dist/packem_shared/createLocalStore-BDbbkoXw.mjs +2 -0
- package/dist/packem_shared/createReply-lI4tVS2w.mjs +36 -0
- package/dist/packem_shared/{createServerClient-DF-3mLmb.mjs → createServerClient-Dxemst5C.mjs} +1 -1
- package/dist/packem_shared/createSnapshotPrecondition-CBwnVz6r.mjs +18 -0
- package/dist/packem_shared/{local-store-BveBeFEo.mjs → local-store-DtcIW4c0.mjs} +1 -1
- package/dist/packem_shared/{lunora-client.d-BYkEjCEJ.d.mts → lunora-client.d-pw-9sLl0.d.mts} +183 -1
- package/dist/packem_shared/{lunora-client.d-BYkEjCEJ.d.ts → lunora-client.d-pw-9sLl0.d.ts} +183 -1
- package/dist/packem_shared/{offline-queue-B9vfdSqp.mjs → offline-queue-CF4_Co5k.mjs} +29 -0
- package/dist/packem_shared/{preload.d-DrfuisCE.d.mts → preload.d-6ME5ubgq.d.mts} +1 -1
- package/dist/packem_shared/{preload.d-B-vyHnml.d.ts → preload.d-BkQr-3Vh.d.ts} +1 -1
- package/dist/packem_shared/{subscription-BjynOXCU.mjs → stable-key-wv6eP48B.mjs} +1 -29
- 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-DjGKZsqq.mjs +0 -1
- package/dist/packem_shared/createLocalStore-jRoqmazl.mjs +0 -2
|
@@ -1,13 +1,16 @@
|
|
|
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 { ClientQueryStore } from './createClientQuery-CQ51bWAE.mjs';
|
|
5
|
+
import { TabCoordinator } from './TabCoordinator-BwRR8H06.mjs';
|
|
4
6
|
import { isMutationDelta, applyDelta } from './applyDelta-CRKZ1PBt.mjs';
|
|
5
|
-
import { a as applyOptimisticLayer, d as dropConfirmedLayers, n as notifySubscription,
|
|
6
|
-
import { O as OfflineQueue, n as nextId, i as isStaleVersion, r as reportPersistenceError } from './offline-queue-
|
|
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';
|
|
7
9
|
import { resolvePersistenceAdapter } from './createInMemoryPersistence-DZ2VHWgm.mjs';
|
|
8
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
|
|
|
@@ -21,6 +24,23 @@ const evictOldestEntry = (map, capacity) => {
|
|
|
21
24
|
}
|
|
22
25
|
};
|
|
23
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
|
+
|
|
24
44
|
const TAG = "$lunora.wire$";
|
|
25
45
|
const MAX_DEPTH = 64;
|
|
26
46
|
const MAX_BIGINT_DIGITS = 1024;
|
|
@@ -47,22 +67,6 @@ const ERROR_CTORS = {
|
|
|
47
67
|
TypeError,
|
|
48
68
|
URIError
|
|
49
69
|
};
|
|
50
|
-
const toBase64 = (bytes) => {
|
|
51
|
-
let binary = "";
|
|
52
|
-
const chunk = 32768;
|
|
53
|
-
for (let index = 0; index < bytes.length; index += chunk) {
|
|
54
|
-
binary += String.fromCharCode(...bytes.subarray(index, index + chunk));
|
|
55
|
-
}
|
|
56
|
-
return btoa(binary);
|
|
57
|
-
};
|
|
58
|
-
const fromBase64 = (base64) => {
|
|
59
|
-
const binary = atob(base64);
|
|
60
|
-
const bytes = new Uint8Array(binary.length);
|
|
61
|
-
for (let index = 0; index < binary.length; index += 1) {
|
|
62
|
-
bytes[index] = binary.codePointAt(index) ?? 0;
|
|
63
|
-
}
|
|
64
|
-
return bytes;
|
|
65
|
-
};
|
|
66
70
|
const encodeWire = (value, depth = 0) => {
|
|
67
71
|
if (depth > MAX_DEPTH) {
|
|
68
72
|
throw new RangeError(`wire-codec: value nesting exceeds the ${MAX_DEPTH}-level limit`);
|
|
@@ -463,11 +467,25 @@ const demuxBatchResults = (rawResults, count) => {
|
|
|
463
467
|
return slots.map((slot) => slot ?? { error: new Error("batch call returned no result"), ok: false });
|
|
464
468
|
};
|
|
465
469
|
const TRANSIENT_BATCH_ERROR_CODES = /* @__PURE__ */ new Set(["SHARD_ERROR", "SHARD_UNAVAILABLE"]);
|
|
470
|
+
const RESOLVED_PROMISE = Promise.resolve();
|
|
466
471
|
class LunoraClient {
|
|
467
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. */
|
|
468
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
|
+
}
|
|
469
485
|
url;
|
|
470
486
|
wsUrl;
|
|
487
|
+
/** Local reactive store for {@link ClientQueryRef} values — no server round-trip. Private; reach it via `getClientQuery` / `setClientQuery` / `subscribeClientQuery`. */
|
|
488
|
+
clientQueryStore;
|
|
471
489
|
wsToken;
|
|
472
490
|
/** Better-auth base path (trailing slash stripped) for the `get-session` lookup. */
|
|
473
491
|
authBasePath;
|
|
@@ -488,6 +506,20 @@ class LunoraClient {
|
|
|
488
506
|
outbox;
|
|
489
507
|
/** Stable per-client id stamped onto every `OutboxMutation` (custom-mutator watermark). */
|
|
490
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;
|
|
491
523
|
/**
|
|
492
524
|
* Highest custom-mutator watermark the server has echoed for this client,
|
|
493
525
|
* keyed by shard bucket (`shardKey ?? ""`) since the DO tracks one
|
|
@@ -521,6 +553,12 @@ class LunoraClient {
|
|
|
521
553
|
pendingCacheWrites = /* @__PURE__ */ new Map();
|
|
522
554
|
cacheFlushTimer;
|
|
523
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;
|
|
524
562
|
/** One {@link ShardConnection} per shard key (keyed by `shardKey ?? ""`). */
|
|
525
563
|
connections = /* @__PURE__ */ new Map();
|
|
526
564
|
/** Default `connect`-envelope context applied to a shard with no explicit override. */
|
|
@@ -616,6 +654,59 @@ class LunoraClient {
|
|
|
616
654
|
this.persistenceVersion = options.persistenceVersion;
|
|
617
655
|
this.queryCache = resolveQueryCacheAdapter(options.queryCache);
|
|
618
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
|
+
}
|
|
619
710
|
this.offlineQueue = new OfflineQueue(options.offlineQueue, {
|
|
620
711
|
onEvict: (entry, error) => {
|
|
621
712
|
this.emitItemSettled(entry, "rejected", error);
|
|
@@ -635,7 +726,17 @@ class LunoraClient {
|
|
|
635
726
|
}
|
|
636
727
|
if (this.queryCache) {
|
|
637
728
|
queueMicrotask(() => {
|
|
638
|
-
|
|
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
|
+
});
|
|
639
740
|
});
|
|
640
741
|
}
|
|
641
742
|
}
|
|
@@ -1008,6 +1109,135 @@ class LunoraClient {
|
|
|
1008
1109
|
onMutationSettled(listener) {
|
|
1009
1110
|
return this.mutationSettledListeners.add(listener);
|
|
1010
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
|
+
}
|
|
1011
1241
|
// --- RPC ---------------------------------------------------------------
|
|
1012
1242
|
async query(function_, args, options = {}) {
|
|
1013
1243
|
if (this.closed) {
|
|
@@ -1105,7 +1335,15 @@ class LunoraClient {
|
|
|
1105
1335
|
const shouldQueueOffline = this.WebSocketImpl !== void 0 && connectedGate;
|
|
1106
1336
|
const midReconnect = wsState === "connecting" && connectedGate;
|
|
1107
1337
|
if (wsState !== "open" && !hasSocket && shouldQueueOffline || midReconnect) {
|
|
1108
|
-
return this.enqueueOfflineMutation(
|
|
1338
|
+
return this.enqueueOfflineMutation(
|
|
1339
|
+
function_,
|
|
1340
|
+
argsRecord,
|
|
1341
|
+
options.shardKey,
|
|
1342
|
+
mutationId,
|
|
1343
|
+
optimisticRollbacks,
|
|
1344
|
+
optimisticConfirms,
|
|
1345
|
+
options.precondition
|
|
1346
|
+
);
|
|
1109
1347
|
}
|
|
1110
1348
|
try {
|
|
1111
1349
|
let commitCursor;
|
|
@@ -2113,6 +2351,8 @@ class LunoraClient {
|
|
|
2113
2351
|
this.whisperHandlers.clear();
|
|
2114
2352
|
this.shapeSubscriptions.clear();
|
|
2115
2353
|
this.pokeBuffers.clear();
|
|
2354
|
+
this.tabCoordinator?.stop();
|
|
2355
|
+
this.tabCoordinator = void 0;
|
|
2116
2356
|
}
|
|
2117
2357
|
// --- Internals ----------------------------------------------------------
|
|
2118
2358
|
/**
|
|
@@ -2126,7 +2366,7 @@ class LunoraClient {
|
|
|
2126
2366
|
* optimistically (confirmation rides the synced view). Otherwise the
|
|
2127
2367
|
* built-in `OfflineQueue` resolves/rejects the returned promise on replay.
|
|
2128
2368
|
*/
|
|
2129
|
-
async enqueueOfflineMutation(function_, argsRecord, shardKey, mutationId, optimisticRollbacks, optimisticConfirms) {
|
|
2369
|
+
async enqueueOfflineMutation(function_, argsRecord, shardKey, mutationId, optimisticRollbacks, optimisticConfirms, precondition) {
|
|
2130
2370
|
const issuingIdentity = this.identityFingerprint();
|
|
2131
2371
|
if (this.outbox) {
|
|
2132
2372
|
this.outboxMutationCounter += 1;
|
|
@@ -2164,6 +2404,8 @@ class LunoraClient {
|
|
|
2164
2404
|
// Persist the stamp alongside the record so a hydrated write can
|
|
2165
2405
|
// only replay under the identity that queued it.
|
|
2166
2406
|
identity: issuingIdentity,
|
|
2407
|
+
// Optional precondition checked before replay — see drainConflict.
|
|
2408
|
+
precondition,
|
|
2167
2409
|
// Confirm the per-call optimistic layer(s) against the commit cursor
|
|
2168
2410
|
// the flush replay echoes (see flushOfflineQueue).
|
|
2169
2411
|
onCommit: (commitCursor) => {
|
|
@@ -2618,6 +2860,9 @@ class LunoraClient {
|
|
|
2618
2860
|
if (this.closed || this.WebSocketImpl === void 0) {
|
|
2619
2861
|
return;
|
|
2620
2862
|
}
|
|
2863
|
+
if (this.tabCoordinator && !this.tabCoordinator.isLeader()) {
|
|
2864
|
+
return;
|
|
2865
|
+
}
|
|
2621
2866
|
const conn = this.getOrCreateConnection(shardKey);
|
|
2622
2867
|
if (conn.wsState === "open" || conn.wsState === "connecting") {
|
|
2623
2868
|
return;
|
|
@@ -2979,6 +3224,10 @@ class LunoraClient {
|
|
|
2979
3224
|
this.persistQueryValue(state);
|
|
2980
3225
|
dropConfirmedLayers(state, state.serverCursor);
|
|
2981
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
|
+
}
|
|
2982
3231
|
}
|
|
2983
3232
|
/**
|
|
2984
3233
|
* Handle a `resume` frame (Pillar 1b): the server proved nothing the
|
|
@@ -3217,6 +3466,14 @@ class LunoraClient {
|
|
|
3217
3466
|
this.queryCache?.clear().catch(() => void 0);
|
|
3218
3467
|
}
|
|
3219
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
|
+
}
|
|
3220
3477
|
const key = connectionKey(shardKey);
|
|
3221
3478
|
const drained = this.offlineQueue.drain((item) => connectionKey(item.shardKey) === key);
|
|
3222
3479
|
if (drained.length === 0) {
|
|
@@ -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 };
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
let nextTabId = 0;
|
|
2
|
+
const DEFAULT_CHANNEL = "lunora-bridge";
|
|
3
|
+
const DEFAULT_HEARTBEAT_MS = 1e3;
|
|
4
|
+
const DEFAULT_LEADER_TIMEOUT_MS = 3e3;
|
|
5
|
+
class TabCoordinator {
|
|
6
|
+
bc;
|
|
7
|
+
tabId;
|
|
8
|
+
heartbeatInterval;
|
|
9
|
+
leaderTimeout;
|
|
10
|
+
/** The tab id of the current known leader, or `undefined` if no leader. */
|
|
11
|
+
knownLeader = void 0;
|
|
12
|
+
/** `true` when this tab believes it is the leader. */
|
|
13
|
+
leader = false;
|
|
14
|
+
/** `true` once `start()` has been called. */
|
|
15
|
+
running = false;
|
|
16
|
+
/** Timestamp of the most recent leader heartbeat. */
|
|
17
|
+
lastHeartbeat = 0;
|
|
18
|
+
heartbeatTimer = void 0;
|
|
19
|
+
leaderCheckTimer = void 0;
|
|
20
|
+
/** Callbacks set via constructor options. */
|
|
21
|
+
onBecomeLeader;
|
|
22
|
+
onStopBeingLeader;
|
|
23
|
+
onSubscriptionData;
|
|
24
|
+
onSubscriptionError;
|
|
25
|
+
constructor(options = {}) {
|
|
26
|
+
nextTabId += 1;
|
|
27
|
+
this.tabId = `tab_${String(nextTabId)}_${crypto.randomUUID()}`;
|
|
28
|
+
this.heartbeatInterval = options.heartbeatInterval ?? DEFAULT_HEARTBEAT_MS;
|
|
29
|
+
this.leaderTimeout = options.leaderTimeout ?? DEFAULT_LEADER_TIMEOUT_MS;
|
|
30
|
+
this.onBecomeLeader = options.onBecomeLeader;
|
|
31
|
+
this.onStopBeingLeader = options.onStopBeingLeader;
|
|
32
|
+
this.onSubscriptionData = options.onSubscriptionData;
|
|
33
|
+
this.onSubscriptionError = options.onSubscriptionError;
|
|
34
|
+
if (typeof BroadcastChannel === "undefined") {
|
|
35
|
+
this.bc = void 0;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
this.bc = new BroadcastChannel(options.channelName ?? DEFAULT_CHANNEL);
|
|
39
|
+
this.bc.addEventListener("message", (event) => {
|
|
40
|
+
this.handleMessage(event.data);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
// -----------------------------------------------------------------------
|
|
44
|
+
// Public API
|
|
45
|
+
// -----------------------------------------------------------------------
|
|
46
|
+
/**
|
|
47
|
+
* Start the coordinator: attempt to claim leadership and begin the
|
|
48
|
+
* heartbeat/leader-check cycle. Safe to call multiple times.
|
|
49
|
+
*/
|
|
50
|
+
start() {
|
|
51
|
+
if (this.running) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
this.running = true;
|
|
55
|
+
if (this.bc === void 0) {
|
|
56
|
+
this.becomeLeader();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
this.broadcast({ type: "claim-leadership", tabId: this.tabId, ts: Date.now() });
|
|
60
|
+
setTimeout(() => {
|
|
61
|
+
if (!this.running) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (this.knownLeader === void 0) {
|
|
65
|
+
this.becomeLeader();
|
|
66
|
+
}
|
|
67
|
+
}, this.leaderTimeout);
|
|
68
|
+
this.leaderCheckTimer = setInterval(() => {
|
|
69
|
+
this.checkLeaderHealth();
|
|
70
|
+
}, this.leaderTimeout);
|
|
71
|
+
this.heartbeatTimer = setInterval(() => {
|
|
72
|
+
this.sendHeartbeat();
|
|
73
|
+
}, this.heartbeatInterval);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Stop the coordinator: yield leadership (if held), close the channel, and
|
|
77
|
+
* clear all timers. Safe to call multiple times.
|
|
78
|
+
*/
|
|
79
|
+
stop() {
|
|
80
|
+
this.running = false;
|
|
81
|
+
if (this.leader) {
|
|
82
|
+
this.broadcast({ type: "yield-leadership", tabId: this.tabId });
|
|
83
|
+
this.leader = false;
|
|
84
|
+
this.knownLeader = void 0;
|
|
85
|
+
this.onStopBeingLeader?.();
|
|
86
|
+
}
|
|
87
|
+
if (this.heartbeatTimer !== void 0) {
|
|
88
|
+
clearInterval(this.heartbeatTimer);
|
|
89
|
+
this.heartbeatTimer = void 0;
|
|
90
|
+
}
|
|
91
|
+
if (this.leaderCheckTimer !== void 0) {
|
|
92
|
+
clearInterval(this.leaderCheckTimer);
|
|
93
|
+
this.leaderCheckTimer = void 0;
|
|
94
|
+
}
|
|
95
|
+
this.bc?.close();
|
|
96
|
+
}
|
|
97
|
+
/** `true` when this tab is the current WebSocket leader. */
|
|
98
|
+
isLeader() {
|
|
99
|
+
return this.leader;
|
|
100
|
+
}
|
|
101
|
+
/** The tab id of the current leader, or `undefined` if unknown / no leader. */
|
|
102
|
+
get leaderTabId() {
|
|
103
|
+
return this.knownLeader;
|
|
104
|
+
}
|
|
105
|
+
/** The id of this tab. */
|
|
106
|
+
get id() {
|
|
107
|
+
return this.tabId;
|
|
108
|
+
}
|
|
109
|
+
/** `true` when the coordinator has been started and is not yet stopped. */
|
|
110
|
+
get isRunning() {
|
|
111
|
+
return this.running;
|
|
112
|
+
}
|
|
113
|
+
// -----------------------------------------------------------------------
|
|
114
|
+
// Broadcasting
|
|
115
|
+
// -----------------------------------------------------------------------
|
|
116
|
+
/**
|
|
117
|
+
* Broadcast subscription data to all follower tabs. Only the leader should
|
|
118
|
+
* call this.
|
|
119
|
+
*/
|
|
120
|
+
broadcastSubscriptionData(key, data) {
|
|
121
|
+
if (!this.leader) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
this.broadcast({ type: "subscription-data", tabId: this.tabId, key, data });
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Broadcast a subscription error to all follower tabs. Only the leader
|
|
128
|
+
* should call this.
|
|
129
|
+
*/
|
|
130
|
+
broadcastSubscriptionError(key, error) {
|
|
131
|
+
if (!this.leader) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
this.broadcast({ type: "subscription-error", tabId: this.tabId, key, error });
|
|
135
|
+
}
|
|
136
|
+
// -----------------------------------------------------------------------
|
|
137
|
+
// Internal
|
|
138
|
+
// -----------------------------------------------------------------------
|
|
139
|
+
broadcast(message) {
|
|
140
|
+
this.bc?.postMessage(message);
|
|
141
|
+
}
|
|
142
|
+
handleMessage(message) {
|
|
143
|
+
switch (message.type) {
|
|
144
|
+
case "claim-leadership": {
|
|
145
|
+
if (message.tabId === this.tabId) {
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
if (this.leader) {
|
|
149
|
+
this.broadcast({ type: "heartbeat", tabId: this.tabId, ts: Date.now() });
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
if (this.knownLeader === void 0 && message.tabId < this.tabId) {
|
|
153
|
+
this.knownLeader = message.tabId;
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case "heartbeat": {
|
|
158
|
+
this.lastHeartbeat = message.ts;
|
|
159
|
+
this.knownLeader = message.tabId;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case "subscription-data": {
|
|
163
|
+
if (this.leader || message.tabId === this.tabId) {
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
this.onSubscriptionData?.(message.key, message.data);
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
case "subscription-error": {
|
|
170
|
+
if (this.leader || message.tabId === this.tabId) {
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
this.onSubscriptionError?.(message.key, message.error);
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
case "yield-leadership": {
|
|
177
|
+
if (this.knownLeader === message.tabId) {
|
|
178
|
+
this.knownLeader = void 0;
|
|
179
|
+
}
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
becomeLeader() {
|
|
185
|
+
if (this.leader) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
this.leader = true;
|
|
189
|
+
this.knownLeader = this.tabId;
|
|
190
|
+
this.lastHeartbeat = Date.now();
|
|
191
|
+
this.onBecomeLeader?.();
|
|
192
|
+
this.broadcast({ type: "heartbeat", tabId: this.tabId, ts: Date.now() });
|
|
193
|
+
}
|
|
194
|
+
sendHeartbeat() {
|
|
195
|
+
if (!this.leader) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
this.broadcast({ type: "heartbeat", tabId: this.tabId, ts: Date.now() });
|
|
199
|
+
}
|
|
200
|
+
checkLeaderHealth() {
|
|
201
|
+
if (!this.running) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (this.leader) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (this.knownLeader !== void 0) {
|
|
208
|
+
const elapsed = Date.now() - this.lastHeartbeat;
|
|
209
|
+
if (elapsed > this.leaderTimeout) {
|
|
210
|
+
this.knownLeader = void 0;
|
|
211
|
+
this.broadcast({ type: "claim-leadership", tabId: this.tabId, ts: Date.now() });
|
|
212
|
+
setTimeout(() => {
|
|
213
|
+
if (this.running && this.knownLeader === void 0) {
|
|
214
|
+
this.becomeLeader();
|
|
215
|
+
}
|
|
216
|
+
}, this.leaderTimeout);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export { TabCoordinator };
|