@lunora/client 1.0.0-alpha.21 → 1.0.0-alpha.23
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 +249 -4
- package/dist/index.d.ts +249 -4
- package/dist/index.mjs +10 -4
- package/dist/packem_shared/ClientServiceWorker-C3PAFwy0.mjs +100 -0
- package/dist/packem_shared/{LunoraClient-kXpHNyaE.mjs → LunoraClient-BBCQjjbl.mjs} +382 -243
- package/dist/packem_shared/{OfflineQueue-GGYJRmhF.mjs → OfflineQueue-B4HUF7rt.mjs} +1 -1
- package/dist/packem_shared/SubscriptionRegistry-CxS_Inha.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-BtqUmOQA.mjs +2 -0
- package/dist/packem_shared/createReply-lI4tVS2w.mjs +36 -0
- package/dist/packem_shared/{createServerClient-DF-3mLmb.mjs → createServerClient-CTTAmvMx.mjs} +1 -1
- package/dist/packem_shared/createSnapshotPrecondition-CxQ1T4ZP.mjs +18 -0
- package/dist/packem_shared/httpStream-BJU-aflc.mjs +159 -0
- package/dist/packem_shared/{local-store-BveBeFEo.mjs → local-store-DIq-UWfD.mjs} +1 -1
- package/dist/packem_shared/{lunora-client.d-BYkEjCEJ.d.mts → lunora-client.d-JvtVpf8A.d.mts} +302 -18
- package/dist/packem_shared/{lunora-client.d-BYkEjCEJ.d.ts → lunora-client.d-JvtVpf8A.d.ts} +302 -18
- package/dist/packem_shared/{offline-queue-B9vfdSqp.mjs → offline-queue-CF4_Co5k.mjs} +29 -0
- package/dist/packem_shared/{preload.d-B-vyHnml.d.ts → preload.d-C4_d_l5v.d.ts} +1 -1
- package/dist/packem_shared/{preload.d-DrfuisCE.d.mts → preload.d-DKbjGN5O.d.mts} +1 -1
- package/dist/packem_shared/wire-key-Djie6aaR.mjs +266 -0
- package/dist/query/index.d.mts +2 -2
- package/dist/query/index.d.ts +2 -2
- package/dist/ssr/index.d.mts +3 -3
- package/dist/ssr/index.d.ts +3 -3
- package/dist/ssr/index.mjs +1 -1
- package/package.json +2 -2
- package/dist/packem_shared/SubscriptionRegistry-DjGKZsqq.mjs +0 -1
- package/dist/packem_shared/createLocalStore-jRoqmazl.mjs +0 -2
- package/dist/packem_shared/subscription-BjynOXCU.mjs +0 -68
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { LunoraError } from '@lunora/errors';
|
|
2
|
-
import {
|
|
2
|
+
import { s as stableWireKey, d as decodeWire, e as encodeWire } from './wire-key-Djie6aaR.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 {
|
|
6
|
-
import {
|
|
7
|
+
import { httpStream } from './httpStream-BJU-aflc.mjs';
|
|
8
|
+
import { f as foldOptimistic, a as applyOptimisticLayer, d as dropConfirmedLayers, n as notifySubscription, c as createLocalStore } from './local-store-DIq-UWfD.mjs';
|
|
9
|
+
import { O as OfflineQueue, n as nextId, i as isStaleVersion, r as reportPersistenceError } from './offline-queue-CF4_Co5k.mjs';
|
|
7
10
|
import { resolvePersistenceAdapter } from './createInMemoryPersistence-DZ2VHWgm.mjs';
|
|
8
11
|
import { resolveQueryCacheAdapter, queryCacheKey } from './createInMemoryQueryCache-DiaGZkA2.mjs';
|
|
9
12
|
import { createReconnect } from './createReconnect-Di_-oHH7.mjs';
|
|
10
13
|
import { createStream } from './DEFAULT_MAX_BUFFER-7hFnzNk9.mjs';
|
|
14
|
+
import { SubscriptionRegistry } from './SubscriptionRegistry-CxS_Inha.mjs';
|
|
11
15
|
|
|
12
16
|
const MAX_BATCH_ENTRIES = 500;
|
|
13
17
|
|
|
@@ -21,229 +25,6 @@ const evictOldestEntry = (map, capacity) => {
|
|
|
21
25
|
}
|
|
22
26
|
};
|
|
23
27
|
|
|
24
|
-
const TAG = "$lunora.wire$";
|
|
25
|
-
const MAX_DEPTH = 64;
|
|
26
|
-
const MAX_BIGINT_DIGITS = 1024;
|
|
27
|
-
const UNSAFE_KEY = "__proto__";
|
|
28
|
-
const TYPED_ARRAY_CTORS = {
|
|
29
|
-
BigInt64Array,
|
|
30
|
-
BigUint64Array,
|
|
31
|
-
Float32Array,
|
|
32
|
-
Float64Array,
|
|
33
|
-
Int8Array,
|
|
34
|
-
Int16Array,
|
|
35
|
-
Int32Array,
|
|
36
|
-
Uint8Array,
|
|
37
|
-
Uint8ClampedArray,
|
|
38
|
-
Uint16Array,
|
|
39
|
-
Uint32Array
|
|
40
|
-
};
|
|
41
|
-
const ERROR_CTORS = {
|
|
42
|
-
Error,
|
|
43
|
-
EvalError,
|
|
44
|
-
RangeError,
|
|
45
|
-
ReferenceError,
|
|
46
|
-
SyntaxError,
|
|
47
|
-
TypeError,
|
|
48
|
-
URIError
|
|
49
|
-
};
|
|
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
|
-
const encodeWire = (value, depth = 0) => {
|
|
67
|
-
if (depth > MAX_DEPTH) {
|
|
68
|
-
throw new RangeError(`wire-codec: value nesting exceeds the ${MAX_DEPTH}-level limit`);
|
|
69
|
-
}
|
|
70
|
-
if (value === void 0) {
|
|
71
|
-
return [TAG, "undefined"];
|
|
72
|
-
}
|
|
73
|
-
if (value === null) {
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
const kind = typeof value;
|
|
77
|
-
if (kind === "bigint") {
|
|
78
|
-
return [TAG, "bigint", value.toString()];
|
|
79
|
-
}
|
|
80
|
-
if (kind === "number") {
|
|
81
|
-
const numeric = value;
|
|
82
|
-
if (Number.isNaN(numeric)) {
|
|
83
|
-
return [TAG, "nan"];
|
|
84
|
-
}
|
|
85
|
-
if (numeric === Infinity) {
|
|
86
|
-
return [TAG, "inf"];
|
|
87
|
-
}
|
|
88
|
-
if (numeric === -Infinity) {
|
|
89
|
-
return [TAG, "-inf"];
|
|
90
|
-
}
|
|
91
|
-
return numeric;
|
|
92
|
-
}
|
|
93
|
-
if (kind !== "object") {
|
|
94
|
-
return value;
|
|
95
|
-
}
|
|
96
|
-
if (value instanceof Date) {
|
|
97
|
-
return [TAG, "date", encodeWire(value.getTime(), depth + 1)];
|
|
98
|
-
}
|
|
99
|
-
if (value instanceof Error) {
|
|
100
|
-
const error = value;
|
|
101
|
-
const properties = {};
|
|
102
|
-
for (const key of Object.keys(error)) {
|
|
103
|
-
if (error[key] !== void 0) {
|
|
104
|
-
properties[key] = encodeWire(error[key], depth + 1);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
const encodedError = [TAG, "error", error.name, error.message, properties];
|
|
108
|
-
if (error.cause !== void 0) {
|
|
109
|
-
encodedError.push(encodeWire(error.cause, depth + 1));
|
|
110
|
-
}
|
|
111
|
-
return encodedError;
|
|
112
|
-
}
|
|
113
|
-
if (value instanceof URL) {
|
|
114
|
-
return [TAG, "url", value.href];
|
|
115
|
-
}
|
|
116
|
-
if (value instanceof Map) {
|
|
117
|
-
return [TAG, "map", [...value.entries()].map(([k, v]) => [encodeWire(k, depth + 1), encodeWire(v, depth + 1)])];
|
|
118
|
-
}
|
|
119
|
-
if (value instanceof Set) {
|
|
120
|
-
return [TAG, "set", [...value].map((item) => encodeWire(item, depth + 1))];
|
|
121
|
-
}
|
|
122
|
-
if (value instanceof ArrayBuffer) {
|
|
123
|
-
return [TAG, "bytes", toBase64(new Uint8Array(value)), "ArrayBuffer"];
|
|
124
|
-
}
|
|
125
|
-
if (ArrayBuffer.isView(value)) {
|
|
126
|
-
const view = value;
|
|
127
|
-
const ctorName = view.constructor.name;
|
|
128
|
-
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
|
129
|
-
return ctorName === "Uint8Array" ? [TAG, "bytes", toBase64(bytes)] : [TAG, "bytes", toBase64(bytes), ctorName];
|
|
130
|
-
}
|
|
131
|
-
if (Array.isArray(value)) {
|
|
132
|
-
const encoded = value.map((item) => encodeWire(item, depth + 1));
|
|
133
|
-
return encoded.length > 0 && encoded[0] === TAG ? [TAG, "arr", encoded] : encoded;
|
|
134
|
-
}
|
|
135
|
-
const proto = Object.getPrototypeOf(value);
|
|
136
|
-
if (proto !== null && proto !== Object.prototype) {
|
|
137
|
-
const name = value.constructor?.name ?? "value";
|
|
138
|
-
throw new TypeError(
|
|
139
|
-
`wire-codec: cannot encode a ${name} over the Lunora wire — only plain objects, arrays, and the supported built-ins (Date, Error, URL, Map, Set, ArrayBuffer/typed arrays, bigint) round-trip`
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
const source = value;
|
|
143
|
-
const result = {};
|
|
144
|
-
for (const key of Object.keys(source)) {
|
|
145
|
-
const field = source[key];
|
|
146
|
-
if (field !== void 0) {
|
|
147
|
-
result[key] = encodeWire(field, depth + 1);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
return result;
|
|
151
|
-
};
|
|
152
|
-
const decodeWire = (value, depth = 0) => {
|
|
153
|
-
if (depth > MAX_DEPTH) {
|
|
154
|
-
throw new RangeError(`wire-codec: value nesting exceeds the ${MAX_DEPTH}-level limit`);
|
|
155
|
-
}
|
|
156
|
-
if (value === null || typeof value !== "object") {
|
|
157
|
-
return value;
|
|
158
|
-
}
|
|
159
|
-
if (Array.isArray(value)) {
|
|
160
|
-
if (value[0] === TAG) {
|
|
161
|
-
const tag = value[1];
|
|
162
|
-
switch (tag) {
|
|
163
|
-
case "-inf": {
|
|
164
|
-
return -Infinity;
|
|
165
|
-
}
|
|
166
|
-
case "arr": {
|
|
167
|
-
return value[2].map((item) => decodeWire(item, depth + 1));
|
|
168
|
-
}
|
|
169
|
-
case "bigint": {
|
|
170
|
-
const raw = value[2];
|
|
171
|
-
if (typeof raw !== "string" || raw.length > MAX_BIGINT_DIGITS || !/^-?\d+$/.test(raw)) {
|
|
172
|
-
throw new RangeError(`wire-codec: invalid or over-long bigint (max ${MAX_BIGINT_DIGITS} digits)`);
|
|
173
|
-
}
|
|
174
|
-
return BigInt(raw);
|
|
175
|
-
}
|
|
176
|
-
case "date": {
|
|
177
|
-
return new Date(decodeWire(value[2], depth + 1));
|
|
178
|
-
}
|
|
179
|
-
case "map": {
|
|
180
|
-
return new Map(value[2].map(([k, v]) => [decodeWire(k, depth + 1), decodeWire(v, depth + 1)]));
|
|
181
|
-
}
|
|
182
|
-
case "set": {
|
|
183
|
-
return new Set(value[2].map((item) => decodeWire(item, depth + 1)));
|
|
184
|
-
}
|
|
185
|
-
case "url": {
|
|
186
|
-
return new URL(value[2]);
|
|
187
|
-
}
|
|
188
|
-
case "error": {
|
|
189
|
-
const name = value[2];
|
|
190
|
-
const message = value[3];
|
|
191
|
-
const Ctor = (Object.hasOwn(ERROR_CTORS, name) ? ERROR_CTORS[name] : void 0) ?? Error;
|
|
192
|
-
const error = new Ctor(message);
|
|
193
|
-
if (error.name !== name) {
|
|
194
|
-
Object.defineProperty(error, "name", { configurable: true, value: name, writable: true });
|
|
195
|
-
}
|
|
196
|
-
const props = decodeWire(value[4], depth + 1);
|
|
197
|
-
for (const key of Object.keys(props)) {
|
|
198
|
-
if (key === UNSAFE_KEY) {
|
|
199
|
-
Object.defineProperty(error, key, { configurable: true, enumerable: true, value: props[key], writable: true });
|
|
200
|
-
} else {
|
|
201
|
-
error[key] = props[key];
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
if (value.length > 5) {
|
|
205
|
-
Object.defineProperty(error, "cause", { configurable: true, value: decodeWire(value[5], depth + 1), writable: true });
|
|
206
|
-
}
|
|
207
|
-
return error;
|
|
208
|
-
}
|
|
209
|
-
case "bytes": {
|
|
210
|
-
const bytes = fromBase64(value[2]);
|
|
211
|
-
const ctorName = value[3] ?? "Uint8Array";
|
|
212
|
-
if (ctorName === "ArrayBuffer") {
|
|
213
|
-
return bytes.buffer.byteLength === bytes.byteLength ? bytes.buffer : bytes.slice().buffer;
|
|
214
|
-
}
|
|
215
|
-
const Ctor = Object.hasOwn(TYPED_ARRAY_CTORS, ctorName) ? TYPED_ARRAY_CTORS[ctorName] : void 0;
|
|
216
|
-
return Ctor ? new Ctor(bytes.slice().buffer) : bytes;
|
|
217
|
-
}
|
|
218
|
-
case "inf": {
|
|
219
|
-
return Infinity;
|
|
220
|
-
}
|
|
221
|
-
case "nan": {
|
|
222
|
-
return Number.NaN;
|
|
223
|
-
}
|
|
224
|
-
case "undefined": {
|
|
225
|
-
return void 0;
|
|
226
|
-
}
|
|
227
|
-
default: {
|
|
228
|
-
return value.map((item) => decodeWire(item, depth + 1));
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
return value.map((item) => decodeWire(item, depth + 1));
|
|
233
|
-
}
|
|
234
|
-
const source = value;
|
|
235
|
-
const result = {};
|
|
236
|
-
for (const key of Object.keys(source)) {
|
|
237
|
-
const decoded = decodeWire(source[key], depth + 1);
|
|
238
|
-
if (key === UNSAFE_KEY) {
|
|
239
|
-
Object.defineProperty(result, key, { configurable: true, enumerable: true, value: decoded, writable: true });
|
|
240
|
-
} else {
|
|
241
|
-
result[key] = decoded;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
return result;
|
|
245
|
-
};
|
|
246
|
-
|
|
247
28
|
class Listeners {
|
|
248
29
|
listeners = /* @__PURE__ */ new Set();
|
|
249
30
|
add(listener) {
|
|
@@ -463,11 +244,25 @@ const demuxBatchResults = (rawResults, count) => {
|
|
|
463
244
|
return slots.map((slot) => slot ?? { error: new Error("batch call returned no result"), ok: false });
|
|
464
245
|
};
|
|
465
246
|
const TRANSIENT_BATCH_ERROR_CODES = /* @__PURE__ */ new Set(["SHARD_ERROR", "SHARD_UNAVAILABLE"]);
|
|
247
|
+
const RESOLVED_PROMISE = Promise.resolve();
|
|
466
248
|
class LunoraClient {
|
|
467
249
|
/** 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
250
|
static MAX_POKE_BUFFERS = 256;
|
|
251
|
+
/**
|
|
252
|
+
* Create a typed {@link ClientQueryRef}. Convenience wrapper around
|
|
253
|
+
* {@link createClientQuery} so you don't need a separate import.
|
|
254
|
+
* @example
|
|
255
|
+
* ```ts
|
|
256
|
+
* const sidebarOpen = LunoraClient.createClientQuery("sidebarOpen", true);
|
|
257
|
+
* ```
|
|
258
|
+
*/
|
|
259
|
+
static createClientQuery(key, defaultValue) {
|
|
260
|
+
return { defaultValue, key };
|
|
261
|
+
}
|
|
469
262
|
url;
|
|
470
263
|
wsUrl;
|
|
264
|
+
/** Local reactive store for {@link ClientQueryRef} values — no server round-trip. Private; reach it via `getClientQuery` / `setClientQuery` / `subscribeClientQuery`. */
|
|
265
|
+
clientQueryStore;
|
|
471
266
|
wsToken;
|
|
472
267
|
/** Better-auth base path (trailing slash stripped) for the `get-session` lookup. */
|
|
473
268
|
authBasePath;
|
|
@@ -488,6 +283,20 @@ class LunoraClient {
|
|
|
488
283
|
outbox;
|
|
489
284
|
/** Stable per-client id stamped onto every `OutboxMutation` (custom-mutator watermark). */
|
|
490
285
|
clientId;
|
|
286
|
+
/**
|
|
287
|
+
* `true` when the constructor's hydration microtask has finished loading the
|
|
288
|
+
* durable read cache (Pillar 2) into `hydratedQueryCache`. Signals that
|
|
289
|
+
* the cache is ready for synchronous `peekHydratedQuery` reads.
|
|
290
|
+
*/
|
|
291
|
+
readyResolved = false;
|
|
292
|
+
/** Resolvers for `whenReady()` — called once hydration completes. */
|
|
293
|
+
readyResolve;
|
|
294
|
+
/**
|
|
295
|
+
* Promise that resolves once the durable read cache has been loaded. When
|
|
296
|
+
* `hydrateOnStart` is not set or no query cache is configured, resolves
|
|
297
|
+
* immediately (the constructor creates an already-resolved promise).
|
|
298
|
+
*/
|
|
299
|
+
readyPromise;
|
|
491
300
|
/**
|
|
492
301
|
* Highest custom-mutator watermark the server has echoed for this client,
|
|
493
302
|
* keyed by shard bucket (`shardKey ?? ""`) since the DO tracks one
|
|
@@ -521,6 +330,12 @@ class LunoraClient {
|
|
|
521
330
|
pendingCacheWrites = /* @__PURE__ */ new Map();
|
|
522
331
|
cacheFlushTimer;
|
|
523
332
|
subscriptions = new SubscriptionRegistry();
|
|
333
|
+
/**
|
|
334
|
+
* Cross-tab coordinator; created only when `crossTabSync: true`. When the
|
|
335
|
+
* client is not the elected leader, all WebSocket operations are skipped.
|
|
336
|
+
* Not `readonly` — `close()` clears it (mirrors `outboxLeaderRelease`).
|
|
337
|
+
*/
|
|
338
|
+
tabCoordinator;
|
|
524
339
|
/** One {@link ShardConnection} per shard key (keyed by `shardKey ?? ""`). */
|
|
525
340
|
connections = /* @__PURE__ */ new Map();
|
|
526
341
|
/** Default `connect`-envelope context applied to a shard with no explicit override. */
|
|
@@ -616,6 +431,59 @@ class LunoraClient {
|
|
|
616
431
|
this.persistenceVersion = options.persistenceVersion;
|
|
617
432
|
this.queryCache = resolveQueryCacheAdapter(options.queryCache);
|
|
618
433
|
this.onPersistenceError = options.offlineQueue?.onPersistenceError;
|
|
434
|
+
this.clientQueryStore = new ClientQueryStore();
|
|
435
|
+
if (options.crossTabSync) {
|
|
436
|
+
this.tabCoordinator = new TabCoordinator({
|
|
437
|
+
onBecomeLeader: () => {
|
|
438
|
+
for (const state of this.subscriptions.all()) {
|
|
439
|
+
this.ensureSocket(state.shardKey);
|
|
440
|
+
this.sendSubscribeIfOpen(state);
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
onStopBeingLeader: () => {
|
|
444
|
+
for (const [key, conn] of this.connections) {
|
|
445
|
+
conn.socket?.close();
|
|
446
|
+
this.connections.delete(key);
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
onSubscriptionData: (key, data) => {
|
|
450
|
+
const state = this.subscriptions.get(key);
|
|
451
|
+
if (state) {
|
|
452
|
+
state.serverBase = data;
|
|
453
|
+
const folded = state.optimisticLayers.length === 0 ? data : foldOptimistic(data, state.optimisticLayers);
|
|
454
|
+
state.lastValue = folded;
|
|
455
|
+
for (const callback of state.callbacks) {
|
|
456
|
+
try {
|
|
457
|
+
callback(folded);
|
|
458
|
+
} catch {
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
onSubscriptionError: (key, error) => {
|
|
464
|
+
const state = this.subscriptions.get(key);
|
|
465
|
+
if (state) {
|
|
466
|
+
for (const callback of state.errorCallbacks) {
|
|
467
|
+
try {
|
|
468
|
+
callback(error);
|
|
469
|
+
} catch {
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
this.tabCoordinator.start();
|
|
476
|
+
} else {
|
|
477
|
+
this.tabCoordinator = void 0;
|
|
478
|
+
}
|
|
479
|
+
if (options.hydrateOnStart && this.queryCache) {
|
|
480
|
+
this.readyPromise = new Promise((resolve) => {
|
|
481
|
+
this.readyResolve = resolve;
|
|
482
|
+
});
|
|
483
|
+
} else {
|
|
484
|
+
this.readyResolved = true;
|
|
485
|
+
this.readyPromise = RESOLVED_PROMISE;
|
|
486
|
+
}
|
|
619
487
|
this.offlineQueue = new OfflineQueue(options.offlineQueue, {
|
|
620
488
|
onEvict: (entry, error) => {
|
|
621
489
|
this.emitItemSettled(entry, "rejected", error);
|
|
@@ -635,7 +503,17 @@ class LunoraClient {
|
|
|
635
503
|
}
|
|
636
504
|
if (this.queryCache) {
|
|
637
505
|
queueMicrotask(() => {
|
|
638
|
-
|
|
506
|
+
const hydration = async () => {
|
|
507
|
+
try {
|
|
508
|
+
await this.hydrateQueryCache();
|
|
509
|
+
} catch {
|
|
510
|
+
} finally {
|
|
511
|
+
this.readyResolved = true;
|
|
512
|
+
this.readyResolve?.();
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
hydration().catch(() => {
|
|
516
|
+
});
|
|
639
517
|
});
|
|
640
518
|
}
|
|
641
519
|
}
|
|
@@ -791,8 +669,10 @@ class LunoraClient {
|
|
|
791
669
|
* Replace the token appended to WS upgrade URLs as `?token=…` and close
|
|
792
670
|
* every open shard socket so the reconnect picks up the new value. Call
|
|
793
671
|
* this whenever the user's WS credential changes (rotating the admin token
|
|
794
|
-
* in the studio, switching workspaces, etc.).
|
|
795
|
-
*
|
|
672
|
+
* in the studio, switching workspaces, etc.). Accepts a static string or a
|
|
673
|
+
* {@link WsTokenProvider} resolved fresh at every (re)connect — the channel
|
|
674
|
+
* for short-lived credentials like the minted ephemeral admin sub-token.
|
|
675
|
+
* Bearer tokens for HTTP RPC are independent — see {@link setAuthToken}.
|
|
796
676
|
*/
|
|
797
677
|
setWsToken(token) {
|
|
798
678
|
if (this.wsToken === token) {
|
|
@@ -1008,6 +888,135 @@ class LunoraClient {
|
|
|
1008
888
|
onMutationSettled(listener) {
|
|
1009
889
|
return this.mutationSettledListeners.add(listener);
|
|
1010
890
|
}
|
|
891
|
+
// --- Client Query (local state) ----------------------------------------
|
|
892
|
+
/**
|
|
893
|
+
* Read the current value for a {@link ClientQueryRef}. Returns
|
|
894
|
+
* `ref.defaultValue` when no value has been explicitly set.
|
|
895
|
+
*/
|
|
896
|
+
getClientQuery(ref) {
|
|
897
|
+
return this.clientQueryStore.get(ref);
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Set a new value for `ref` and notify every subscriber. Pass `undefined`
|
|
901
|
+
* to reset the slot to `ref.defaultValue`.
|
|
902
|
+
*/
|
|
903
|
+
setClientQuery(ref, value) {
|
|
904
|
+
this.clientQueryStore.set(ref, value);
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Subscribe to changes for `ref`. The callback is NOT invoked on
|
|
908
|
+
* registration — call {@link getClientQuery} for the current value.
|
|
909
|
+
* Returns an unsubscribe function.
|
|
910
|
+
*/
|
|
911
|
+
subscribeClientQuery(ref, callback) {
|
|
912
|
+
return this.clientQueryStore.subscribe(ref, callback);
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Reset a {@link ClientQueryRef} to its default value, notifying every
|
|
916
|
+
* subscriber. Equivalent to `setClientQuery(ref, ref.defaultValue)` but
|
|
917
|
+
* removes the stored entry so a future {@link getClientQuery} returns
|
|
918
|
+
* the default rather than an explicitly-set value.
|
|
919
|
+
*/
|
|
920
|
+
resetClientQuery(ref) {
|
|
921
|
+
this.clientQueryStore.reset(ref);
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Capture a snapshot of the current live query value at call time and
|
|
925
|
+
* produce a `() => boolean` precondition that compares it against the
|
|
926
|
+
* value at replay time (on queue drain / reconnect).
|
|
927
|
+
*
|
|
928
|
+
* When the precondition is checked it re-reads the query's current value
|
|
929
|
+
* via `peekActiveQueryValue`. If the value differs from what was
|
|
930
|
+
* captured at call time the precondition returns `false` and the offline
|
|
931
|
+
* mutation is dropped as stale.
|
|
932
|
+
*
|
|
933
|
+
* This is a method wrapper around `createSnapshotPrecondition` that
|
|
934
|
+
* binds the client instance for you — no need to pass `client` explicitly.
|
|
935
|
+
* @example
|
|
936
|
+
* ```ts
|
|
937
|
+
* client.mutation(api.todos.update, { id, text }, {
|
|
938
|
+
* precondition: client.snapshotPrecondition(api.todos.list, { userId }),
|
|
939
|
+
* });
|
|
940
|
+
* ```
|
|
941
|
+
*/
|
|
942
|
+
snapshotPrecondition(functionRef, args, shardKey) {
|
|
943
|
+
const snapshot = this.peekActiveQueryValue(functionRef.__lunoraRef, args, shardKey);
|
|
944
|
+
const snapshotKey = snapshot === void 0 ? void 0 : stableWireKey(snapshot);
|
|
945
|
+
return () => {
|
|
946
|
+
const current = this.peekActiveQueryValue(functionRef.__lunoraRef, args, shardKey);
|
|
947
|
+
if (snapshotKey === void 0 && current === void 0) {
|
|
948
|
+
return true;
|
|
949
|
+
}
|
|
950
|
+
if (snapshotKey === void 0 || current === void 0) {
|
|
951
|
+
return false;
|
|
952
|
+
}
|
|
953
|
+
return stableWireKey(current) === snapshotKey;
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
// --- Hydration helpers --------------------------------------------------
|
|
957
|
+
/**
|
|
958
|
+
* Resolves once the durable read cache has been loaded into memory. When
|
|
959
|
+
* `hydrateOnStart` is not configured or no query cache adapter is active,
|
|
960
|
+
* returns an already-resolved promise so callers can always await it
|
|
961
|
+
* unconditionally.
|
|
962
|
+
*
|
|
963
|
+
* Framework adapters (React, Vue, etc.) use this to gate the first
|
|
964
|
+
* (enabled) render of a live query behind hydration, so the user sees
|
|
965
|
+
* cached data instead of an undefined flash before the socket round-trip.
|
|
966
|
+
*/
|
|
967
|
+
whenReady() {
|
|
968
|
+
return this.readyPromise;
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Synchronously reports whether {@link whenReady} has already resolved (the
|
|
972
|
+
* durable read cache is loaded, or none is configured). Framework adapters
|
|
973
|
+
* read this to seed the hydration-gate state on the first render without
|
|
974
|
+
* awaiting, then subscribe via {@link whenReady} for the pending case.
|
|
975
|
+
*/
|
|
976
|
+
get isReady() {
|
|
977
|
+
return this.readyResolved;
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Synchronously peek at a value the durable read cache loaded for the given
|
|
981
|
+
* function path + args + shard key. Returns `undefined` when:
|
|
982
|
+
*
|
|
983
|
+
* - No query cache adapter is configured.
|
|
984
|
+
* - Hydration hasn't completed yet (race — await {@link whenReady} first).
|
|
985
|
+
* - The cached value's identity fingerprint doesn't match the current auth.
|
|
986
|
+
*
|
|
987
|
+
* Unlike the internal {@link takeHydratedCache}, this is a READ-ONLY peek:
|
|
988
|
+
* the cached entry stays in `hydratedQueryCache` so the subscription created
|
|
989
|
+
* later by {@link subscribe} consumes it normally.
|
|
990
|
+
*/
|
|
991
|
+
peekHydratedQuery(functionPath, args, shardKey) {
|
|
992
|
+
if (!this.readyResolved) {
|
|
993
|
+
return void 0;
|
|
994
|
+
}
|
|
995
|
+
const argsKey = stableWireKey(args);
|
|
996
|
+
const key = queryCacheKey(functionPath, argsKey, shardKey);
|
|
997
|
+
const entry = this.hydratedQueryCache.get(key);
|
|
998
|
+
if (entry === void 0) {
|
|
999
|
+
return void 0;
|
|
1000
|
+
}
|
|
1001
|
+
return entry.identity === this.identityFingerprint() ? entry.value : void 0;
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Peek at the **current live value** of an active subscription, if one
|
|
1005
|
+
* exists. Returns the subscription's `lastValue` (which includes any
|
|
1006
|
+
* optimistic overlay) or `undefined` if no subscription is active for the
|
|
1007
|
+
* given `(functionPath, args, shardKey)`.
|
|
1008
|
+
*
|
|
1009
|
+
* Unlike {@link peekHydratedQuery} (which reads from the durable read cache
|
|
1010
|
+
* and is independent of active subscriptions), this method reflects the
|
|
1011
|
+
* current in-memory state of an already-opened subscription — useful for
|
|
1012
|
+
* offline mutation preconditions that need to snapshot the value at call time
|
|
1013
|
+
* and compare it at replay time.
|
|
1014
|
+
*/
|
|
1015
|
+
peekActiveQueryValue(functionPath, args, shardKey) {
|
|
1016
|
+
const key = SubscriptionRegistry.key(functionPath, args, shardKey);
|
|
1017
|
+
const state = this.subscriptions.get(key);
|
|
1018
|
+
return state?.lastValue;
|
|
1019
|
+
}
|
|
1011
1020
|
// --- RPC ---------------------------------------------------------------
|
|
1012
1021
|
async query(function_, args, options = {}) {
|
|
1013
1022
|
if (this.closed) {
|
|
@@ -1105,7 +1114,15 @@ class LunoraClient {
|
|
|
1105
1114
|
const shouldQueueOffline = this.WebSocketImpl !== void 0 && connectedGate;
|
|
1106
1115
|
const midReconnect = wsState === "connecting" && connectedGate;
|
|
1107
1116
|
if (wsState !== "open" && !hasSocket && shouldQueueOffline || midReconnect) {
|
|
1108
|
-
return this.enqueueOfflineMutation(
|
|
1117
|
+
return this.enqueueOfflineMutation(
|
|
1118
|
+
function_,
|
|
1119
|
+
argsRecord,
|
|
1120
|
+
options.shardKey,
|
|
1121
|
+
mutationId,
|
|
1122
|
+
optimisticRollbacks,
|
|
1123
|
+
optimisticConfirms,
|
|
1124
|
+
options.precondition
|
|
1125
|
+
);
|
|
1109
1126
|
}
|
|
1110
1127
|
try {
|
|
1111
1128
|
let commitCursor;
|
|
@@ -1296,8 +1313,10 @@ class LunoraClient {
|
|
|
1296
1313
|
* Subscribe to the live scheduled-jobs list over the SchedulerDO's admin
|
|
1297
1314
|
* WebSocket. `onJobs` fires with the full list on connect and on every
|
|
1298
1315
|
* change (schedule / cancel / alarm-fire). Reconnects with the client's
|
|
1299
|
-
* configured backoff. Requires `wsToken` to be set to
|
|
1300
|
-
* browser can't send an `Authorization` header on a WS)
|
|
1316
|
+
* configured backoff. Requires `wsToken` to be set to an admin credential
|
|
1317
|
+
* (the browser can't send an `Authorization` header on a WS) — the master
|
|
1318
|
+
* token, or preferably a {@link WsTokenProvider} minting the ephemeral
|
|
1319
|
+
* sub-token so the master credential stays out of the URL. Returns an
|
|
1301
1320
|
* unsubscribe function that closes the socket and stops reconnecting.
|
|
1302
1321
|
*/
|
|
1303
1322
|
subscribeScheduledJobs(onJobs) {
|
|
@@ -1312,11 +1331,11 @@ class LunoraClient {
|
|
|
1312
1331
|
let socket;
|
|
1313
1332
|
let timer;
|
|
1314
1333
|
let closed = false;
|
|
1315
|
-
const
|
|
1334
|
+
const openWith = (token) => {
|
|
1316
1335
|
if (closed || this.WebSocketImpl === void 0) {
|
|
1317
1336
|
return;
|
|
1318
1337
|
}
|
|
1319
|
-
const url =
|
|
1338
|
+
const url = token === void 0 ? base : `${base}?token=${encodeURIComponent(token)}`;
|
|
1320
1339
|
socket = new this.WebSocketImpl(url);
|
|
1321
1340
|
socket.addEventListener("open", () => {
|
|
1322
1341
|
reconnect.reset();
|
|
@@ -1339,6 +1358,29 @@ class LunoraClient {
|
|
|
1339
1358
|
socket.addEventListener("error", () => {
|
|
1340
1359
|
});
|
|
1341
1360
|
};
|
|
1361
|
+
const connectWithProvider = async (provider) => {
|
|
1362
|
+
let token;
|
|
1363
|
+
try {
|
|
1364
|
+
token = await provider();
|
|
1365
|
+
} catch {
|
|
1366
|
+
if (!closed) {
|
|
1367
|
+
timer = setTimeout(connect, reconnect.next());
|
|
1368
|
+
}
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
openWith(token);
|
|
1372
|
+
};
|
|
1373
|
+
const connect = () => {
|
|
1374
|
+
if (closed || this.WebSocketImpl === void 0) {
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
const { wsToken } = this;
|
|
1378
|
+
if (typeof wsToken === "function") {
|
|
1379
|
+
connectWithProvider(wsToken).catch(() => void 0);
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
openWith(wsToken);
|
|
1383
|
+
};
|
|
1342
1384
|
connect();
|
|
1343
1385
|
return () => {
|
|
1344
1386
|
closed = true;
|
|
@@ -1905,7 +1947,7 @@ class LunoraClient {
|
|
|
1905
1947
|
if (!state) {
|
|
1906
1948
|
this.nextSubId += 1;
|
|
1907
1949
|
const id = `sub_${this.nextSubId.toString()}`;
|
|
1908
|
-
const argsKey =
|
|
1950
|
+
const argsKey = stableWireKey(argsRecord);
|
|
1909
1951
|
const cached = this.takeHydratedCache(function_.__lunoraRef, argsKey, options.shardKey);
|
|
1910
1952
|
state = {
|
|
1911
1953
|
acked: false,
|
|
@@ -1985,7 +2027,10 @@ class LunoraClient {
|
|
|
1985
2027
|
name: shape.name,
|
|
1986
2028
|
onCheckpoint: options.onCheckpoint,
|
|
1987
2029
|
rows: /* @__PURE__ */ new Map(),
|
|
1988
|
-
shardKey: options.shardKey
|
|
2030
|
+
shardKey: options.shardKey,
|
|
2031
|
+
// Encode ONCE, here, so an unsupported arg value throws at this call
|
|
2032
|
+
// site instead of inside a reconnect's open handler.
|
|
2033
|
+
wireArgs: shape.args === void 0 ? void 0 : encodeCallArgs(shape.args, `shape args for '${shape.name}'`)
|
|
1989
2034
|
};
|
|
1990
2035
|
this.shapeSubscriptions.set(id, state);
|
|
1991
2036
|
this.ensureSocket(options.shardKey);
|
|
@@ -2069,6 +2114,39 @@ class LunoraClient {
|
|
|
2069
2114
|
}
|
|
2070
2115
|
return iterable;
|
|
2071
2116
|
}
|
|
2117
|
+
/**
|
|
2118
|
+
* Open a typed **HTTP-SSE route stream** (`httpRoute.<verb>(path).stream()`).
|
|
2119
|
+
* Distinct from {@link LunoraClient.stream}, which consumes the WS procedure
|
|
2120
|
+
* stream (`kind: "stream"`): this one opens the route's own URL with `fetch`
|
|
2121
|
+
* and parses the Server-Sent Events framing the route pump writes (`data:`
|
|
2122
|
+
* chunks, a final `event: complete`, an `event: error` on throw).
|
|
2123
|
+
*
|
|
2124
|
+
* The reference comes from the generated `httpStreams.*` registry, so the
|
|
2125
|
+
* yielded chunk type is the route handler's yielded type. Cancelling the
|
|
2126
|
+
* returned iterable (or aborting `options.signal`) aborts the fetch, which
|
|
2127
|
+
* the server handler observes via its `signal`. The client's bearer token
|
|
2128
|
+
* (when set) rides as an `authorization` header.
|
|
2129
|
+
* @experimental Reconnect/POST-body/wire-fidelity design questions are still open, so the shape may change.
|
|
2130
|
+
*/
|
|
2131
|
+
httpStream(route, args, options = {}) {
|
|
2132
|
+
if (this.closed) {
|
|
2133
|
+
throw new LunoraError("CLIENT_CLOSED", "LunoraClient is closed");
|
|
2134
|
+
}
|
|
2135
|
+
if (!this.fetchImpl) {
|
|
2136
|
+
throw new LunoraError("INTERNAL", "LunoraClient: no `fetch` implementation available");
|
|
2137
|
+
}
|
|
2138
|
+
const headers = {
|
|
2139
|
+
...this.authToken ? { authorization: `Bearer ${this.authToken}` } : {},
|
|
2140
|
+
...options.headers
|
|
2141
|
+
};
|
|
2142
|
+
return httpStream(route, args, {
|
|
2143
|
+
baseUrl: this.url,
|
|
2144
|
+
fetch: this.fetchImpl,
|
|
2145
|
+
headers,
|
|
2146
|
+
maxBuffer: options.maxBuffer,
|
|
2147
|
+
signal: options.signal
|
|
2148
|
+
});
|
|
2149
|
+
}
|
|
2072
2150
|
close() {
|
|
2073
2151
|
this.closed = true;
|
|
2074
2152
|
this.outboxLeaderRelease?.();
|
|
@@ -2113,6 +2191,8 @@ class LunoraClient {
|
|
|
2113
2191
|
this.whisperHandlers.clear();
|
|
2114
2192
|
this.shapeSubscriptions.clear();
|
|
2115
2193
|
this.pokeBuffers.clear();
|
|
2194
|
+
this.tabCoordinator?.stop();
|
|
2195
|
+
this.tabCoordinator = void 0;
|
|
2116
2196
|
}
|
|
2117
2197
|
// --- Internals ----------------------------------------------------------
|
|
2118
2198
|
/**
|
|
@@ -2126,7 +2206,7 @@ class LunoraClient {
|
|
|
2126
2206
|
* optimistically (confirmation rides the synced view). Otherwise the
|
|
2127
2207
|
* built-in `OfflineQueue` resolves/rejects the returned promise on replay.
|
|
2128
2208
|
*/
|
|
2129
|
-
async enqueueOfflineMutation(function_, argsRecord, shardKey, mutationId, optimisticRollbacks, optimisticConfirms) {
|
|
2209
|
+
async enqueueOfflineMutation(function_, argsRecord, shardKey, mutationId, optimisticRollbacks, optimisticConfirms, precondition) {
|
|
2130
2210
|
const issuingIdentity = this.identityFingerprint();
|
|
2131
2211
|
if (this.outbox) {
|
|
2132
2212
|
this.outboxMutationCounter += 1;
|
|
@@ -2164,6 +2244,8 @@ class LunoraClient {
|
|
|
2164
2244
|
// Persist the stamp alongside the record so a hydrated write can
|
|
2165
2245
|
// only replay under the identity that queued it.
|
|
2166
2246
|
identity: issuingIdentity,
|
|
2247
|
+
// Optional precondition checked before replay — see drainConflict.
|
|
2248
|
+
precondition,
|
|
2167
2249
|
// Confirm the per-call optimistic layer(s) against the commit cursor
|
|
2168
2250
|
// the flush replay echoes (see flushOfflineQueue).
|
|
2169
2251
|
onCommit: (commitCursor) => {
|
|
@@ -2424,13 +2506,13 @@ class LunoraClient {
|
|
|
2424
2506
|
}
|
|
2425
2507
|
return conn;
|
|
2426
2508
|
}
|
|
2427
|
-
wsUrlFor(shardKey) {
|
|
2509
|
+
wsUrlFor(shardKey, token) {
|
|
2428
2510
|
const params = [];
|
|
2429
2511
|
if (shardKey !== void 0) {
|
|
2430
2512
|
params.push(`shard=${encodeURIComponent(shardKey)}`);
|
|
2431
2513
|
}
|
|
2432
|
-
if (
|
|
2433
|
-
params.push(`token=${encodeURIComponent(
|
|
2514
|
+
if (token !== void 0) {
|
|
2515
|
+
params.push(`token=${encodeURIComponent(token)}`);
|
|
2434
2516
|
}
|
|
2435
2517
|
if (params.length === 0) {
|
|
2436
2518
|
return this.wsUrl;
|
|
@@ -2618,13 +2700,49 @@ class LunoraClient {
|
|
|
2618
2700
|
if (this.closed || this.WebSocketImpl === void 0) {
|
|
2619
2701
|
return;
|
|
2620
2702
|
}
|
|
2703
|
+
if (this.tabCoordinator && !this.tabCoordinator.isLeader()) {
|
|
2704
|
+
return;
|
|
2705
|
+
}
|
|
2621
2706
|
const conn = this.getOrCreateConnection(shardKey);
|
|
2622
2707
|
if (conn.wsState === "open" || conn.wsState === "connecting") {
|
|
2623
2708
|
return;
|
|
2624
2709
|
}
|
|
2625
2710
|
conn.wsState = "connecting";
|
|
2626
2711
|
this.emitConnectionStatus();
|
|
2627
|
-
|
|
2712
|
+
if (typeof this.wsToken === "function") {
|
|
2713
|
+
this.openSocketWithProvidedToken(conn, shardKey, this.wsToken).catch(() => void 0);
|
|
2714
|
+
return;
|
|
2715
|
+
}
|
|
2716
|
+
this.openSocket(conn, shardKey, this.wsToken);
|
|
2717
|
+
}
|
|
2718
|
+
/**
|
|
2719
|
+
* Resolve the {@link WsTokenProvider} and open the shard socket with the
|
|
2720
|
+
* minted token. The connection is already in the `connecting` state, so the
|
|
2721
|
+
* async gap is race-guarded: a client `close()`, a `setWsToken` bounce, or a
|
|
2722
|
+
* competing connect that landed first all abandon this attempt. A provider
|
|
2723
|
+
* failure fails the attempt through {@link handleDisconnect}, which arms the
|
|
2724
|
+
* normal reconnect backoff — a broken mint endpoint degrades to retries, not
|
|
2725
|
+
* a silent tokenless socket the admin gate would reject.
|
|
2726
|
+
*/
|
|
2727
|
+
async openSocketWithProvidedToken(conn, shardKey, provider) {
|
|
2728
|
+
let token;
|
|
2729
|
+
try {
|
|
2730
|
+
token = await provider();
|
|
2731
|
+
} catch {
|
|
2732
|
+
this.handleDisconnect(conn);
|
|
2733
|
+
return;
|
|
2734
|
+
}
|
|
2735
|
+
if (this.closed || this.WebSocketImpl === void 0 || conn.wsState !== "connecting" || conn.socket !== void 0) {
|
|
2736
|
+
return;
|
|
2737
|
+
}
|
|
2738
|
+
this.openSocket(conn, shardKey, token);
|
|
2739
|
+
}
|
|
2740
|
+
/** Construct the shard socket and wire its lifecycle handlers. The connection must already be in the `connecting` state. */
|
|
2741
|
+
openSocket(conn, shardKey, token) {
|
|
2742
|
+
if (this.WebSocketImpl === void 0) {
|
|
2743
|
+
return;
|
|
2744
|
+
}
|
|
2745
|
+
const socket = new this.WebSocketImpl(this.wsUrlFor(shardKey, token));
|
|
2628
2746
|
conn.socket = socket;
|
|
2629
2747
|
if (this.connectTimeoutMs > 0) {
|
|
2630
2748
|
conn.connectTimer = setTimeout(() => {
|
|
@@ -2786,7 +2904,12 @@ class LunoraClient {
|
|
|
2786
2904
|
// sub (a hydrated read or an earlier frame), so the server can
|
|
2787
2905
|
// resume instead of re-snapshotting. Omitted on a cold sub.
|
|
2788
2906
|
query: {
|
|
2789
|
-
|
|
2907
|
+
// Wire-encode so a `bigint`/`Date`/bytes arg survives the frame's
|
|
2908
|
+
// `JSON.stringify` (the shard `decodeWire`s at its subscribe entry
|
|
2909
|
+
// point). Identity for pure-JSON args. Cannot throw here: the
|
|
2910
|
+
// registry key (`stableWireKey`) already encoded these args at
|
|
2911
|
+
// subscribe() time, so reconnect resends stay safe.
|
|
2912
|
+
args: encodeWire(state.args),
|
|
2790
2913
|
functionPath: state.fn.__lunoraRef,
|
|
2791
2914
|
table,
|
|
2792
2915
|
...state.serverCursor === void 0 ? {} : { sinceSeq: state.serverCursor },
|
|
@@ -2802,7 +2925,11 @@ class LunoraClient {
|
|
|
2802
2925
|
}
|
|
2803
2926
|
sendOn(conn, {
|
|
2804
2927
|
id: state.id,
|
|
2805
|
-
|
|
2928
|
+
// `wireArgs` is the pre-encoded form of `args` (computed at
|
|
2929
|
+
// `subscribeShape` time) so a `bigint`/`Date`/bytes arg survives the
|
|
2930
|
+
// frame's `JSON.stringify`; the shard `decodeWire`s it at its
|
|
2931
|
+
// `shape_subscribe` entry point. Identity for pure-JSON args.
|
|
2932
|
+
shape: { name: state.name, ...state.wireArgs === void 0 ? {} : { args: state.wireArgs } },
|
|
2806
2933
|
type: "shape_subscribe",
|
|
2807
2934
|
// Resume from the last applied checkpoint when we hold one; a cold
|
|
2808
2935
|
// subscribe omits it and the server seeds the full membership.
|
|
@@ -2979,6 +3106,10 @@ class LunoraClient {
|
|
|
2979
3106
|
this.persistQueryValue(state);
|
|
2980
3107
|
dropConfirmedLayers(state, state.serverCursor);
|
|
2981
3108
|
notifySubscription(state, state.optimisticLayers.length === 0 ? payload : foldOptimistic(payload, state.optimisticLayers));
|
|
3109
|
+
if (this.tabCoordinator?.isLeader()) {
|
|
3110
|
+
const key = SubscriptionRegistry.key(state.fn.__lunoraRef, state.args, state.shardKey);
|
|
3111
|
+
this.tabCoordinator.broadcastSubscriptionData(key, payload);
|
|
3112
|
+
}
|
|
2982
3113
|
}
|
|
2983
3114
|
/**
|
|
2984
3115
|
* Handle a `resume` frame (Pillar 1b): the server proved nothing the
|
|
@@ -3217,6 +3348,14 @@ class LunoraClient {
|
|
|
3217
3348
|
this.queryCache?.clear().catch(() => void 0);
|
|
3218
3349
|
}
|
|
3219
3350
|
async flushOfflineQueue(shardKey) {
|
|
3351
|
+
const conflicted = this.offlineQueue.drainConflict();
|
|
3352
|
+
for (const item of conflicted) {
|
|
3353
|
+
this.unpersist(item.id);
|
|
3354
|
+
this.queuedIdentities.delete(item.id ?? "");
|
|
3355
|
+
const error = new Error("offline mutation skipped: precondition failed before replay");
|
|
3356
|
+
error.code = "OFFLINE_PRECONDITION_FAILED";
|
|
3357
|
+
this.emitItemSettled(item, "rejected", error);
|
|
3358
|
+
}
|
|
3220
3359
|
const key = connectionKey(shardKey);
|
|
3221
3360
|
const drained = this.offlineQueue.drain((item) => connectionKey(item.shardKey) === key);
|
|
3222
3361
|
if (drained.length === 0) {
|