@lunora/client 1.0.0-alpha.2 → 1.0.0-alpha.21
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/LICENSE.md +6 -0
- package/README.md +2 -0
- package/__assets__/package-og.svg +1 -1
- package/dist/auth/index.d.mts +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/index.d.mts +144 -24
- package/dist/index.d.ts +144 -24
- package/dist/index.mjs +10 -9
- package/dist/packem_shared/CONFLICT_ERROR_CODE-B8gQ8tyU.mjs +33 -0
- package/dist/packem_shared/{DEFAULT_MAX_BUFFER-BDkqO5PW.mjs → DEFAULT_MAX_BUFFER-7hFnzNk9.mjs} +6 -3
- package/dist/packem_shared/{LunoraClient-UiULzH_1.mjs → LunoraClient-kXpHNyaE.mjs} +1562 -267
- package/dist/packem_shared/OfflineQueue-GGYJRmhF.mjs +1 -0
- package/dist/packem_shared/SubscriptionRegistry-DjGKZsqq.mjs +1 -0
- package/dist/packem_shared/{applyDelta-4jFGTPA3.mjs → applyDelta-CRKZ1PBt.mjs} +21 -1
- package/dist/packem_shared/createInMemoryPersistence-DZ2VHWgm.mjs +79 -0
- package/dist/packem_shared/{createInMemoryQueryCache-B1PQ9Twl.mjs → createInMemoryQueryCache-DiaGZkA2.mjs} +25 -51
- package/dist/packem_shared/createLocalStore-jRoqmazl.mjs +2 -0
- package/dist/packem_shared/createMutatorRunner-BETvCd0p.mjs +31 -0
- package/dist/packem_shared/{createServerClient-BjZc3gD8.mjs → createServerClient-DF-3mLmb.mjs} +1 -1
- package/dist/packem_shared/idb-utility-DrSVX43Q.mjs +48 -0
- package/dist/packem_shared/local-store-BveBeFEo.mjs +106 -0
- package/dist/packem_shared/{lunora-client.d-DGvyuJ_p.d.mts → lunora-client.d-BYkEjCEJ.d.mts} +1001 -58
- package/dist/packem_shared/{lunora-client.d-DGvyuJ_p.d.ts → lunora-client.d-BYkEjCEJ.d.ts} +1001 -58
- package/dist/packem_shared/{OfflineQueue-D5p_QgF_.mjs → offline-queue-B9vfdSqp.mjs} +49 -6
- package/dist/packem_shared/{preload.d-BoDmFqSG.d.ts → preload.d-B-vyHnml.d.ts} +1 -1
- package/dist/packem_shared/{preload.d-dSaRMuhL.d.mts → preload.d-DrfuisCE.d.mts} +1 -1
- package/dist/packem_shared/subscription-BjynOXCU.mjs +68 -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 +5 -2
- package/dist/packem_shared/CONFLICT_ERROR_CODE-aUdVbEDw.mjs +0 -4
- package/dist/packem_shared/SubscriptionRegistry-B-Qx_Gux.mjs +0 -26
- package/dist/packem_shared/createInMemoryPersistence-CW82inU5.mjs +0 -105
- package/dist/packem_shared/createLocalStore-DSUfoLqY.mjs +0 -36
|
@@ -1,17 +1,295 @@
|
|
|
1
|
+
import { LunoraError } from '@lunora/errors';
|
|
2
|
+
import { S as SubscriptionRegistry, s as stableStringify } from './subscription-BjynOXCU.mjs';
|
|
1
3
|
import createInMemoryBookmarkStorage from './createInMemoryBookmarkStorage-BoN7a7TH.mjs';
|
|
2
|
-
import { isMutationDelta, applyDelta } from './applyDelta-
|
|
3
|
-
import { createLocalStore } from './
|
|
4
|
-
import { OfflineQueue, nextId, reportPersistenceError } from './
|
|
5
|
-
import {
|
|
4
|
+
import { isMutationDelta, applyDelta } from './applyDelta-CRKZ1PBt.mjs';
|
|
5
|
+
import { a as applyOptimisticLayer, d as dropConfirmedLayers, n as notifySubscription, f as foldOptimistic, c as createLocalStore } from './local-store-BveBeFEo.mjs';
|
|
6
|
+
import { O as OfflineQueue, n as nextId, i as isStaleVersion, r as reportPersistenceError } from './offline-queue-B9vfdSqp.mjs';
|
|
7
|
+
import { resolvePersistenceAdapter } from './createInMemoryPersistence-DZ2VHWgm.mjs';
|
|
8
|
+
import { resolveQueryCacheAdapter, queryCacheKey } from './createInMemoryQueryCache-DiaGZkA2.mjs';
|
|
6
9
|
import { createReconnect } from './createReconnect-Di_-oHH7.mjs';
|
|
7
|
-
import { createStream } from './DEFAULT_MAX_BUFFER-
|
|
8
|
-
|
|
10
|
+
import { createStream } from './DEFAULT_MAX_BUFFER-7hFnzNk9.mjs';
|
|
11
|
+
|
|
12
|
+
const MAX_BATCH_ENTRIES = 500;
|
|
13
|
+
|
|
14
|
+
const evictOldestEntry = (map, capacity) => {
|
|
15
|
+
if (map.size < capacity) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const oldest = map.keys().next().value;
|
|
19
|
+
if (oldest !== void 0) {
|
|
20
|
+
map.delete(oldest);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
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
|
+
class Listeners {
|
|
248
|
+
listeners = /* @__PURE__ */ new Set();
|
|
249
|
+
add(listener) {
|
|
250
|
+
this.listeners.add(listener);
|
|
251
|
+
return () => {
|
|
252
|
+
this.listeners.delete(listener);
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
// The conditional rest tuple makes `emit()` argument-free for a
|
|
256
|
+
// `Listeners<void>` and one-argument for every other payload.
|
|
257
|
+
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- `[T] extends [void]` is the discriminant for the payload-free overload, not a value-position void
|
|
258
|
+
emit(...args) {
|
|
259
|
+
const [value] = args;
|
|
260
|
+
for (const listener of this.listeners) {
|
|
261
|
+
try {
|
|
262
|
+
listener(value);
|
|
263
|
+
} catch {
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
clear() {
|
|
268
|
+
this.listeners.clear();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
9
271
|
|
|
10
272
|
const RPC_PATH = "/_lunora/rpc";
|
|
273
|
+
const RPC_BATCH_PATH = "/_lunora/rpc-batch";
|
|
11
274
|
const WS_PATH = "/_lunora/ws";
|
|
12
275
|
const bucketQuery = (bucket) => bucket === void 0 || bucket === "" ? "" : `&bucket=${encodeURIComponent(bucket)}`;
|
|
276
|
+
const rollbackOptimistic = (optimisticRollbacks) => {
|
|
277
|
+
for (let index = optimisticRollbacks.length - 1; index >= 0; index -= 1) {
|
|
278
|
+
optimisticRollbacks[index]?.();
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
const applyRowOpsToView = (rows, ops) => {
|
|
282
|
+
for (const op of ops) {
|
|
283
|
+
if (op.op === "delete") {
|
|
284
|
+
rows.delete(op.key);
|
|
285
|
+
} else if (op.value !== void 0) {
|
|
286
|
+
rows.set(op.key, op.value);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
};
|
|
13
290
|
const WS_KEEPALIVE_PING = "lunora-ping";
|
|
14
291
|
const DEFAULT_HEARTBEAT_INTERVAL_MS = 3e4;
|
|
292
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
|
|
15
293
|
const QUERY_CACHE_DEBOUNCE_MS = 250;
|
|
16
294
|
const MAX_PENDING_STREAMS = 64;
|
|
17
295
|
const SHARD_TRAFFIC_PATH = "/_lunora/admin/shard-traffic";
|
|
@@ -38,6 +316,9 @@ const GLOBAL_TABLE_PATH = "/_lunora/admin/global/table";
|
|
|
38
316
|
const GLOBAL_FACET_PATH = "/_lunora/admin/global/facet";
|
|
39
317
|
const VECTOR_INDEXES_PATH = "/_lunora/admin/vector/indexes";
|
|
40
318
|
const VECTOR_QUERY_PATH = "/_lunora/admin/vector/query";
|
|
319
|
+
const KV_NAMESPACES_PATH = "/_lunora/admin/kv/namespaces";
|
|
320
|
+
const KV_KEYS_PATH = "/_lunora/admin/kv/keys";
|
|
321
|
+
const KV_VALUE_PATH = "/_lunora/admin/kv/value";
|
|
41
322
|
const AUTH_USERS_PATH = "/_lunora/admin/auth/users";
|
|
42
323
|
const AUTH_SESSIONS_PATH = "/_lunora/admin/auth/sessions";
|
|
43
324
|
const AUTH_CREATE_USER_PATH = "/_lunora/admin/auth/users/create";
|
|
@@ -61,24 +342,26 @@ const AUTH_ORG_MEMBERS_PATH = "/_lunora/admin/auth/organizations/members";
|
|
|
61
342
|
const AUTH_ORG_INVITATIONS_PATH = "/_lunora/admin/auth/organizations/invitations";
|
|
62
343
|
const AUTH_REMOVE_MEMBER_PATH = "/_lunora/admin/auth/organizations/members/remove";
|
|
63
344
|
const AUTH_CANCEL_INVITATION_PATH = "/_lunora/admin/auth/organizations/invitations/cancel";
|
|
345
|
+
const AUTH_CONFIG_PATH = "/_lunora/admin/auth/config";
|
|
346
|
+
const AUTH_CREATE_ORG_PATH = "/_lunora/admin/auth/organizations/create";
|
|
347
|
+
const AUTH_UPDATE_ORG_PATH = "/_lunora/admin/auth/organizations/update";
|
|
348
|
+
const AUTH_REMOVE_ORG_PATH = "/_lunora/admin/auth/organizations/remove";
|
|
349
|
+
const AUTH_ADD_MEMBER_PATH = "/_lunora/admin/auth/organizations/members/add";
|
|
350
|
+
const AUTH_INVITE_MEMBER_PATH = "/_lunora/admin/auth/organizations/members/invite";
|
|
351
|
+
const AUTH_MEMBER_ROLE_PATH = "/_lunora/admin/auth/organizations/members/role";
|
|
352
|
+
const AUTH_ORG_TEAMS_PATH = "/_lunora/admin/auth/organizations/teams";
|
|
353
|
+
const AUTH_CREATE_TEAM_PATH = "/_lunora/admin/auth/organizations/teams/create";
|
|
354
|
+
const AUTH_UPDATE_TEAM_PATH = "/_lunora/admin/auth/organizations/teams/update";
|
|
355
|
+
const AUTH_REMOVE_TEAM_PATH = "/_lunora/admin/auth/organizations/teams/remove";
|
|
356
|
+
const AUTH_ORG_TEAM_MEMBERS_PATH = "/_lunora/admin/auth/organizations/teams/members";
|
|
357
|
+
const AUTH_ADD_TEAM_MEMBER_PATH = "/_lunora/admin/auth/organizations/teams/members/add";
|
|
358
|
+
const AUTH_REMOVE_TEAM_MEMBER_PATH = "/_lunora/admin/auth/organizations/teams/members/remove";
|
|
359
|
+
const AUTH_ORG_ROLES_PATH = "/_lunora/admin/auth/organizations/roles";
|
|
360
|
+
const AUTH_CREATE_ROLE_PATH = "/_lunora/admin/auth/organizations/roles/create";
|
|
361
|
+
const AUTH_UPDATE_ROLE_PATH = "/_lunora/admin/auth/organizations/roles/update";
|
|
362
|
+
const AUTH_REMOVE_ROLE_PATH = "/_lunora/admin/auth/organizations/roles/remove";
|
|
64
363
|
const DEFAULT_AUTH_BASE_PATH = "/api/auth";
|
|
65
364
|
const GET_SESSION_PATH = "/get-session";
|
|
66
|
-
const compareEntryKeys = ([a], [b]) => {
|
|
67
|
-
if (a < b) {
|
|
68
|
-
return -1;
|
|
69
|
-
}
|
|
70
|
-
return a > b ? 1 : 0;
|
|
71
|
-
};
|
|
72
|
-
const stableStringify = (value) => {
|
|
73
|
-
if (value === null || typeof value !== "object") {
|
|
74
|
-
return JSON.stringify(value);
|
|
75
|
-
}
|
|
76
|
-
if (Array.isArray(value)) {
|
|
77
|
-
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
|
|
78
|
-
}
|
|
79
|
-
const entries = Object.entries(value).toSorted(compareEntryKeys);
|
|
80
|
-
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
|
|
81
|
-
};
|
|
82
365
|
const deriveWsUrl = (url) => {
|
|
83
366
|
if (url.startsWith("https://")) {
|
|
84
367
|
return `wss://${url.slice("https://".length)}`;
|
|
@@ -103,47 +386,12 @@ const withQuery = (path, params) => {
|
|
|
103
386
|
return query === "" ? path : `${path}?${query}`;
|
|
104
387
|
};
|
|
105
388
|
const connectionKey = (shardKey) => shardKey ?? "";
|
|
106
|
-
const writeOptimisticToState = (state, next) => {
|
|
107
|
-
const previous = state.lastValue;
|
|
108
|
-
const versionAtApply = state.serverVersion;
|
|
109
|
-
state.lastValue = next;
|
|
110
|
-
for (const callback of state.callbacks) {
|
|
111
|
-
try {
|
|
112
|
-
callback(next);
|
|
113
|
-
} catch {
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return () => {
|
|
117
|
-
if (state.serverVersion > versionAtApply) {
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
if (state.lastValue !== next) {
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
state.lastValue = previous;
|
|
124
|
-
for (const callback of state.callbacks) {
|
|
125
|
-
try {
|
|
126
|
-
callback(previous);
|
|
127
|
-
} catch {
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
};
|
|
131
|
-
};
|
|
132
|
-
const applyOptimisticToState = (state, optimistic) => {
|
|
133
|
-
let next;
|
|
134
|
-
try {
|
|
135
|
-
next = optimistic(state.lastValue);
|
|
136
|
-
} catch {
|
|
137
|
-
return void 0;
|
|
138
|
-
}
|
|
139
|
-
return writeOptimisticToState(state, next);
|
|
140
|
-
};
|
|
141
389
|
const buildStreamError = (message) => {
|
|
142
390
|
const errorEnvelope = message.error;
|
|
143
391
|
const code = typeof errorEnvelope?.code === "string" ? errorEnvelope.code : void 0;
|
|
144
392
|
const nestedMessage = typeof errorEnvelope?.message === "string" ? errorEnvelope.message : void 0;
|
|
145
393
|
const messageText = (typeof message.message === "string" ? message.message : void 0) ?? nestedMessage ?? "stream error";
|
|
146
|
-
return
|
|
394
|
+
return code === void 0 ? new Error(messageText) : new LunoraError(code, messageText);
|
|
147
395
|
};
|
|
148
396
|
const buildSubscriptionError = (message) => {
|
|
149
397
|
const errorEnvelope = message.error;
|
|
@@ -152,6 +400,14 @@ const buildSubscriptionError = (message) => {
|
|
|
152
400
|
const messageText = (typeof message.message === "string" ? message.message : void 0) ?? nestedMessage ?? "subscription error";
|
|
153
401
|
return { message: messageText, ...code === void 0 ? {} : { code } };
|
|
154
402
|
};
|
|
403
|
+
const fanSubscriptionError = (callbacks, error) => {
|
|
404
|
+
for (const errorCallback of callbacks) {
|
|
405
|
+
try {
|
|
406
|
+
errorCallback(error);
|
|
407
|
+
} catch {
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
};
|
|
155
411
|
const sharedDecoder = new TextDecoder();
|
|
156
412
|
const decodeServerFrame = (raw) => {
|
|
157
413
|
if (typeof raw === "string") {
|
|
@@ -173,7 +429,43 @@ const sendOn = (conn, message) => {
|
|
|
173
429
|
return false;
|
|
174
430
|
}
|
|
175
431
|
};
|
|
432
|
+
const reconstructError = (errorBody) => {
|
|
433
|
+
const error = new Error(errorBody.message ?? "request failed");
|
|
434
|
+
error.code = errorBody.code;
|
|
435
|
+
if (errorBody.data !== void 0) {
|
|
436
|
+
error.data = decodeWire(errorBody.data);
|
|
437
|
+
}
|
|
438
|
+
if (errorBody.hint !== void 0) {
|
|
439
|
+
error.hint = errorBody.hint;
|
|
440
|
+
}
|
|
441
|
+
if (errorBody.docsUrl !== void 0) {
|
|
442
|
+
error.docsUrl = errorBody.docsUrl;
|
|
443
|
+
}
|
|
444
|
+
return error;
|
|
445
|
+
};
|
|
446
|
+
const encodeCallArgs = (payload, label) => {
|
|
447
|
+
try {
|
|
448
|
+
return encodeWire(payload);
|
|
449
|
+
} catch (error) {
|
|
450
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
451
|
+
throw new TypeError(`LunoraClient: cannot encode ${label} — ${reason}`, error instanceof Error ? { cause: error } : void 0);
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
const demuxBatchResults = (rawResults, count) => {
|
|
455
|
+
const slots = Array.from({ length: count });
|
|
456
|
+
for (const entry of rawResults) {
|
|
457
|
+
if (typeof entry.id !== "number" || entry.id < 0 || entry.id >= count) {
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
const inner = entry.body;
|
|
461
|
+
slots[entry.id] = inner && "error" in inner && inner.error ? { error: reconstructError(inner.error), ok: false } : { ok: true, value: decodeWire(inner?.result) };
|
|
462
|
+
}
|
|
463
|
+
return slots.map((slot) => slot ?? { error: new Error("batch call returned no result"), ok: false });
|
|
464
|
+
};
|
|
465
|
+
const TRANSIENT_BATCH_ERROR_CODES = /* @__PURE__ */ new Set(["SHARD_ERROR", "SHARD_UNAVAILABLE"]);
|
|
176
466
|
class LunoraClient {
|
|
467
|
+
/** 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
|
+
static MAX_POKE_BUFFERS = 256;
|
|
177
469
|
url;
|
|
178
470
|
wsUrl;
|
|
179
471
|
wsToken;
|
|
@@ -183,11 +475,36 @@ class LunoraClient {
|
|
|
183
475
|
WebSocketImpl;
|
|
184
476
|
bookmark;
|
|
185
477
|
reconnectOptions;
|
|
478
|
+
/** WS connect timeout (ms); `0` disables it. See {@link LunoraClientOptions.connectTimeoutMs}. */
|
|
479
|
+
connectTimeoutMs;
|
|
186
480
|
/** Keepalive cadence (ms); `0` disables the heartbeat. See {@link LunoraClientOptions.heartbeatIntervalMs}. */
|
|
187
481
|
heartbeatIntervalMs;
|
|
188
482
|
offlineQueue;
|
|
483
|
+
/**
|
|
484
|
+
* Durable outbox seam (the `@lunora/db` `createExecutorOutboxSink`). When
|
|
485
|
+
* set, offline writes are delegated here and the built-in {@link OfflineQueue}
|
|
486
|
+
* is bypassed, so a db app has exactly one durable write path.
|
|
487
|
+
*/
|
|
488
|
+
outbox;
|
|
489
|
+
/** Stable per-client id stamped onto every `OutboxMutation` (custom-mutator watermark). */
|
|
490
|
+
clientId;
|
|
491
|
+
/**
|
|
492
|
+
* Highest custom-mutator watermark the server has echoed for this client,
|
|
493
|
+
* keyed by shard bucket (`shardKey ?? ""`) since the DO tracks one
|
|
494
|
+
* `__client_watermark` per shard. `callMutator` bumps it from every
|
|
495
|
+
* ack; the `@lunora/db` mutator runtime seeds its `clientSeq` generator from
|
|
496
|
+
* it so a reload (which resets the in-memory counter) never reissues a stale
|
|
497
|
+
* sequence the server would silently swallow as a replay.
|
|
498
|
+
*/
|
|
499
|
+
clientWatermarks = /* @__PURE__ */ new Map();
|
|
500
|
+
/** Monotonic per-client mutation counter backing the server `__client_watermark`. */
|
|
501
|
+
outboxMutationCounter = 0;
|
|
189
502
|
onPersistenceError;
|
|
190
503
|
persistence;
|
|
504
|
+
/** App/schema version stamped on persisted writes + cached reads; mismatches are purged. */
|
|
505
|
+
persistenceVersion;
|
|
506
|
+
/** Releases the multi-tab outbox-leader Web Lock on close (see `hydrateAsOutboxLeader`). */
|
|
507
|
+
outboxLeaderRelease;
|
|
191
508
|
/** Durable read cache (Pillar 2); `undefined` when `queryCache` is omitted or `false`. */
|
|
192
509
|
queryCache;
|
|
193
510
|
/**
|
|
@@ -233,6 +550,14 @@ class LunoraClient {
|
|
|
233
550
|
// setAuthToken / onAuthTokenChange — part of the exported API contract.
|
|
234
551
|
// eslint-disable-next-line unicorn/no-null -- public auth-token contract sentinel
|
|
235
552
|
authToken = null;
|
|
553
|
+
/**
|
|
554
|
+
* Optional STABLE identity subject (a user id), the basis of the offline-queue
|
|
555
|
+
* identity stamp when supplied. Keeps a same-user token *refresh* from looking
|
|
556
|
+
* like an identity change (which would discard queued writes). `undefined` =
|
|
557
|
+
* not supplied, so identity falls back to a hash of the raw token. See
|
|
558
|
+
* `setAuthToken` / `identityFingerprint`.
|
|
559
|
+
*/
|
|
560
|
+
authSubject = void 0;
|
|
236
561
|
/**
|
|
237
562
|
* Identity stamp recorded against each queued offline mutation, keyed by
|
|
238
563
|
* the queue-assigned mutation id. Captured at enqueue from the auth token
|
|
@@ -243,11 +568,15 @@ class LunoraClient {
|
|
|
243
568
|
queuedIdentities = /* @__PURE__ */ new Map();
|
|
244
569
|
closed = false;
|
|
245
570
|
/** Subscribers to auth-token changes (see `onAuthTokenChange`). */
|
|
246
|
-
authTokenListeners =
|
|
571
|
+
authTokenListeners = new Listeners();
|
|
247
572
|
/** Subscribers to aggregate connection-status changes (see `onConnectionStatus`). */
|
|
248
|
-
statusListeners =
|
|
573
|
+
statusListeners = new Listeners();
|
|
249
574
|
/** Subscribers notified when the server drops a socket for an expired token (see `onTokenExpired`). */
|
|
250
|
-
tokenExpiredListeners =
|
|
575
|
+
tokenExpiredListeners = new Listeners();
|
|
576
|
+
/** Subscribers to offline-queued mutation verdicts (see `onMutationSettled`). */
|
|
577
|
+
mutationSettledListeners = new Listeners();
|
|
578
|
+
/** Subscribers to the offline-queue pending-count (see `onPendingChange`). */
|
|
579
|
+
pendingChangeListeners = new Listeners();
|
|
251
580
|
/**
|
|
252
581
|
* Whisper-topic handlers, keyed by `connectionKey(shardKey)` → topic → set
|
|
253
582
|
* of callbacks. Membership doubles as the resubscribe set replayed on every
|
|
@@ -265,6 +594,11 @@ class LunoraClient {
|
|
|
265
594
|
* calls `.cancel()` or the iterator is garbage-collected.
|
|
266
595
|
*/
|
|
267
596
|
streams = /* @__PURE__ */ new Map();
|
|
597
|
+
/** Live shape subscriptions (partial replication), keyed by their wire id. */
|
|
598
|
+
shapeSubscriptions = /* @__PURE__ */ new Map();
|
|
599
|
+
/** In-flight pokes being assembled between `pokeStart` and `pokeEnd`, keyed by `pokeId`. */
|
|
600
|
+
pokeBuffers = /* @__PURE__ */ new Map();
|
|
601
|
+
nextShapeId = 0;
|
|
268
602
|
constructor(options) {
|
|
269
603
|
this.url = options.url;
|
|
270
604
|
this.wsUrl = options.wsUrl ?? joinUrl(deriveWsUrl(options.url), WS_PATH);
|
|
@@ -276,14 +610,27 @@ class LunoraClient {
|
|
|
276
610
|
this.bookmark = options.bookmarkStorage ?? createInMemoryBookmarkStorage();
|
|
277
611
|
this.reconnectOptions = options.reconnect;
|
|
278
612
|
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
613
|
+
this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
279
614
|
this.defaultConnectionContext = options.connectionContext;
|
|
280
|
-
this.persistence = options.persistence;
|
|
281
|
-
this.
|
|
615
|
+
this.persistence = resolvePersistenceAdapter(options.persistence, options.outbox === void 0);
|
|
616
|
+
this.persistenceVersion = options.persistenceVersion;
|
|
617
|
+
this.queryCache = resolveQueryCacheAdapter(options.queryCache);
|
|
282
618
|
this.onPersistenceError = options.offlineQueue?.onPersistenceError;
|
|
283
|
-
this.offlineQueue = new OfflineQueue(options.offlineQueue,
|
|
619
|
+
this.offlineQueue = new OfflineQueue(options.offlineQueue, {
|
|
620
|
+
onEvict: (entry, error) => {
|
|
621
|
+
this.emitItemSettled(entry, "rejected", error);
|
|
622
|
+
},
|
|
623
|
+
onSizeChange: (size) => {
|
|
624
|
+
this.pendingChangeListeners.emit(size);
|
|
625
|
+
},
|
|
626
|
+
persistence: this.persistence,
|
|
627
|
+
version: options.persistenceVersion
|
|
628
|
+
});
|
|
629
|
+
this.outbox = options.outbox;
|
|
630
|
+
this.clientId = options.clientId ?? `client-${nextId()}`;
|
|
284
631
|
if (this.persistence) {
|
|
285
632
|
queueMicrotask(() => {
|
|
286
|
-
this.
|
|
633
|
+
this.hydrateAsOutboxLeader();
|
|
287
634
|
});
|
|
288
635
|
}
|
|
289
636
|
if (this.queryCache) {
|
|
@@ -298,37 +645,113 @@ class LunoraClient {
|
|
|
298
645
|
* {@link onAuthTokenChange} listeners so React hooks like `useAuth` stay in
|
|
299
646
|
* sync across all mounted instances.
|
|
300
647
|
*
|
|
648
|
+
* Pass a STABLE `subject` (the user id) to key the offline-queue identity on
|
|
649
|
+
* it instead of the token bytes, so a token *refresh* (same user, new JWT)
|
|
650
|
+
* doesn't read as an identity change and discard queued writes. The subject is
|
|
651
|
+
* **sticky**: a later call that omits it (or passes `undefined`) keeps the
|
|
652
|
+
* established subject — so `setAuthToken(refreshedToken)` after a prior
|
|
653
|
+
* `setAuthToken(token, user.id)` retains the identity. Pass `null` to clear it
|
|
654
|
+
* (an explicit sign-out). Establishing the subject for the first time on an
|
|
655
|
+
* UNCHANGED token (e.g. the user id resolves a tick after the token was set)
|
|
656
|
+
* re-stamps any in-flight queued writes rather than dropping them — same
|
|
657
|
+
* credential, just a more stable label. A real user switch (the token AND
|
|
658
|
+
* subject both change) still drops the previous user's writes.
|
|
659
|
+
*
|
|
301
660
|
* Does NOT update the WebSocket auth — the WS token is fixed at upgrade
|
|
302
661
|
* time and lives in the URL. To refresh live WS auth, call
|
|
303
662
|
* {@link setWsToken} explicitly, which closes existing shard sockets to
|
|
304
663
|
* force a reconnect with the new credential.
|
|
305
664
|
*/
|
|
306
|
-
setAuthToken(token) {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
665
|
+
setAuthToken(token, subject) {
|
|
666
|
+
const tokenChanged = this.authToken !== token;
|
|
667
|
+
const previousIdentity = this.identityFingerprint();
|
|
310
668
|
this.authToken = token;
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
669
|
+
if (subject !== void 0) {
|
|
670
|
+
this.authSubject = subject;
|
|
671
|
+
}
|
|
672
|
+
const newIdentity = this.identityFingerprint();
|
|
673
|
+
if (newIdentity !== previousIdentity) {
|
|
674
|
+
if (tokenChanged) {
|
|
675
|
+
this.rejectQueuedForIdentityChange();
|
|
676
|
+
} else {
|
|
677
|
+
this.restampQueuedIdentity(previousIdentity, newIdentity);
|
|
316
678
|
}
|
|
317
679
|
}
|
|
680
|
+
if (tokenChanged) {
|
|
681
|
+
this.authTokenListeners.emit(token);
|
|
682
|
+
}
|
|
318
683
|
}
|
|
319
684
|
getAuthToken() {
|
|
320
685
|
return this.authToken;
|
|
321
686
|
}
|
|
687
|
+
/**
|
|
688
|
+
* The current identity fingerprint (the same stamp queued offline writes
|
|
689
|
+
* carry). Exposed so a durable {@link OutboxSink}'s replay handler — which
|
|
690
|
+
* owns its own at-least-once replay outside the built-in `OfflineQueue` —
|
|
691
|
+
* can drop a persisted write whose captured `identity` no longer matches the
|
|
692
|
+
* signed-in user, the guard the queue path applies in `flushOfflineQueue`.
|
|
693
|
+
*/
|
|
694
|
+
currentIdentity() {
|
|
695
|
+
return this.identityFingerprint();
|
|
696
|
+
}
|
|
697
|
+
/** This client's stable identifier — the watermark key the server's custom-mutator protocol advances per `clientSeq`. */
|
|
698
|
+
clientIdentifier() {
|
|
699
|
+
return this.clientId;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* The highest custom-mutator watermark the server has echoed for this client
|
|
703
|
+
* on the given shard (0 if none yet). The `@lunora/db` mutator runtime seeds
|
|
704
|
+
* its `clientSeq` generator from this so a reload never reissues a sequence
|
|
705
|
+
* the server has already applied (which it would swallow as a replay, silently
|
|
706
|
+
* dropping the write).
|
|
707
|
+
*/
|
|
708
|
+
confirmedMutationWatermark(shardKey) {
|
|
709
|
+
return this.clientWatermarks.get(shardKey ?? "") ?? 0;
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Push a custom mutator to its authoritative server impl over the watermark
|
|
713
|
+
* protocol (Phase 4): the request carries `x-lunora-client-id` + a monotonic
|
|
714
|
+
* `x-lunora-client-seq`, so the DO runs it exactly once and advances this
|
|
715
|
+
* client's `__client_watermark`.
|
|
716
|
+
*
|
|
717
|
+
* Returns the server `result` plus `applied`: `true` when the DO ran this push
|
|
718
|
+
* as the next-in-order mutation, `false` when it was a replay ack (`clientSeq`
|
|
719
|
+
* was at or below the stored watermark — e.g. a stale sequence after a reload).
|
|
720
|
+
* A `false` verdict tells the caller to reissue above the now-known watermark
|
|
721
|
+
* (echoed into {@link confirmedMutationWatermark}) rather than treat the benign
|
|
722
|
+
* ack as a confirmed write. Every ack — applied or not — bumps the watermark.
|
|
723
|
+
*
|
|
724
|
+
* This is the online transport for `@lunora/db`'s client-mutator runtime; the
|
|
725
|
+
* optimistic overlay + durable-outbox concerns live in that runtime, not here.
|
|
726
|
+
*/
|
|
727
|
+
async callMutator(functionPath, args, options) {
|
|
728
|
+
const clientSeq = options?.clientSeq;
|
|
729
|
+
if (clientSeq !== void 0 && (!Number.isInteger(clientSeq) || clientSeq <= 0)) {
|
|
730
|
+
throw new LunoraError("INTERNAL", `callMutator: clientSeq must be a positive integer, got ${String(clientSeq)}`);
|
|
731
|
+
}
|
|
732
|
+
const bucket = options?.shardKey ?? "";
|
|
733
|
+
let ackWatermark;
|
|
734
|
+
const result = await this.rpc(functionPath, args, options?.shardKey, {
|
|
735
|
+
captureBookmark: true,
|
|
736
|
+
clientId: this.clientId,
|
|
737
|
+
clientSeq,
|
|
738
|
+
onMutationAck: (lastMutationId) => {
|
|
739
|
+
ackWatermark = lastMutationId;
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
if (ackWatermark !== void 0 && ackWatermark > (this.clientWatermarks.get(bucket) ?? 0)) {
|
|
743
|
+
this.clientWatermarks.set(bucket, ackWatermark);
|
|
744
|
+
}
|
|
745
|
+
const applied = ackWatermark === void 0 || ackWatermark === clientSeq;
|
|
746
|
+
return { applied, result };
|
|
747
|
+
}
|
|
322
748
|
/**
|
|
323
749
|
* Subscribe to auth-token changes. Returns an unsubscribe function. The
|
|
324
750
|
* listener is NOT invoked on registration — use {@link getAuthToken} for
|
|
325
751
|
* the current value.
|
|
326
752
|
*/
|
|
327
753
|
onAuthTokenChange(listener) {
|
|
328
|
-
this.authTokenListeners.add(listener);
|
|
329
|
-
return () => {
|
|
330
|
-
this.authTokenListeners.delete(listener);
|
|
331
|
-
};
|
|
754
|
+
return this.authTokenListeners.add(listener);
|
|
332
755
|
}
|
|
333
756
|
/**
|
|
334
757
|
* Fetch the currently authenticated user from better-auth's `get-session`
|
|
@@ -519,7 +942,7 @@ class LunoraClient {
|
|
|
519
942
|
this.ensureSocket(options.shardKey);
|
|
520
943
|
const conn = this.getConnection(options.shardKey);
|
|
521
944
|
if (conn) {
|
|
522
|
-
sendOn(conn, { data, topic, type: "whisper" });
|
|
945
|
+
sendOn(conn, { data: encodeCallArgs(data ?? null, `whisper data for topic '${topic}'`), topic, type: "whisper" });
|
|
523
946
|
}
|
|
524
947
|
}
|
|
525
948
|
/**
|
|
@@ -531,10 +954,7 @@ class LunoraClient {
|
|
|
531
954
|
* with a freshly minted one. Returns an unsubscribe function.
|
|
532
955
|
*/
|
|
533
956
|
onTokenExpired(listener) {
|
|
534
|
-
this.tokenExpiredListeners.add(listener);
|
|
535
|
-
return () => {
|
|
536
|
-
this.tokenExpiredListeners.delete(listener);
|
|
537
|
-
};
|
|
957
|
+
return this.tokenExpiredListeners.add(listener);
|
|
538
958
|
}
|
|
539
959
|
// --- Connection status --------------------------------------------------
|
|
540
960
|
/**
|
|
@@ -550,19 +970,107 @@ class LunoraClient {
|
|
|
550
970
|
* unsubscribe function.
|
|
551
971
|
*/
|
|
552
972
|
onConnectionStatus(listener) {
|
|
553
|
-
this.statusListeners.add(listener);
|
|
973
|
+
const unsubscribe = this.statusListeners.add(listener);
|
|
554
974
|
listener(this.computeStatus());
|
|
555
|
-
return
|
|
556
|
-
|
|
557
|
-
|
|
975
|
+
return unsubscribe;
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Number of offline writes waiting in the built-in queue to be sent — the
|
|
979
|
+
* depth for a "N changes waiting to sync" indicator. Counts writes that are
|
|
980
|
+
* queued (offline / mid-reconnect), not ones already in flight on the wire.
|
|
981
|
+
* A `@lunora/db` app whose writes ride the unified outbox should read
|
|
982
|
+
* `LunoraDb.pendingCount()` instead (this counts only the built-in queue).
|
|
983
|
+
*/
|
|
984
|
+
pendingCount() {
|
|
985
|
+
return this.offlineQueue.size;
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Subscribe to changes in {@link pendingCount}. Invokes `listener` immediately
|
|
989
|
+
* with the current count, then whenever the queue depth changes (a write is
|
|
990
|
+
* enqueued, flushed, or discarded). Returns an unsubscribe function.
|
|
991
|
+
*/
|
|
992
|
+
onPendingChange(listener) {
|
|
993
|
+
const unsubscribe = this.pendingChangeListeners.add(listener);
|
|
994
|
+
listener(this.offlineQueue.size);
|
|
995
|
+
return unsubscribe;
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Subscribe to terminal verdicts for offline-queued mutations. The listener
|
|
999
|
+
* fires once per queued write that commits or is rejected — including a write
|
|
1000
|
+
* restored from durable storage after a reload, whose original `mutation()`
|
|
1001
|
+
* Promise no longer exists (`hadAwaiter: false`), and a write the queue
|
|
1002
|
+
* evicts on overflow or discards on an identity change. This is the durable
|
|
1003
|
+
* channel for surfacing a rolled-back optimistic write to the UI; an online
|
|
1004
|
+
* mutation that never queued still surfaces through the Promise `mutation()`
|
|
1005
|
+
* returns. The listener is NOT invoked on registration. Returns an
|
|
1006
|
+
* unsubscribe function. See {@link MutationSettledEvent}.
|
|
1007
|
+
*/
|
|
1008
|
+
onMutationSettled(listener) {
|
|
1009
|
+
return this.mutationSettledListeners.add(listener);
|
|
558
1010
|
}
|
|
559
1011
|
// --- RPC ---------------------------------------------------------------
|
|
560
1012
|
async query(function_, args, options = {}) {
|
|
561
1013
|
if (this.closed) {
|
|
562
|
-
throw new
|
|
1014
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
563
1015
|
}
|
|
564
1016
|
return await this.rpc(function_.__lunoraRef, args, options.shardKey, { attachBookmark: true });
|
|
565
1017
|
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Batch several independent calls into ONE round trip (plan 088). Each call is
|
|
1020
|
+
* dispatched server-side exactly as an individual RPC — per-shard
|
|
1021
|
+
* authorization, `(identity, mutationId)` idempotency, and custom-mutator
|
|
1022
|
+
* watermark ordering are all preserved — and the worker splits the batch by
|
|
1023
|
+
* shard so calls to different shards fan out to their own DOs. Results are
|
|
1024
|
+
* demuxed back in input order; a failing call does NOT fail the batch (its
|
|
1025
|
+
* slot carries `{ ok: false, error }`, with `.code`/`.data` reconstructed like
|
|
1026
|
+
* a single call). Args/results ride the value codec (bytes/bigint survive).
|
|
1027
|
+
*
|
|
1028
|
+
* No promise pipelining and no capability passing — a call's args cannot
|
|
1029
|
+
* reference another call's result (see plan 088 §fence; capabilities are
|
|
1030
|
+
* incompatible with DO hibernation).
|
|
1031
|
+
*/
|
|
1032
|
+
async batch(calls) {
|
|
1033
|
+
if (this.closed) {
|
|
1034
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1035
|
+
}
|
|
1036
|
+
if (!this.fetchImpl) {
|
|
1037
|
+
throw new LunoraError("INTERNAL", "LunoraClient: no `fetch` implementation available");
|
|
1038
|
+
}
|
|
1039
|
+
if (calls.length === 0) {
|
|
1040
|
+
return [];
|
|
1041
|
+
}
|
|
1042
|
+
const response = await this.fetchImpl(joinUrl(this.url, RPC_BATCH_PATH), {
|
|
1043
|
+
body: JSON.stringify({
|
|
1044
|
+
calls: calls.map((call, index) => {
|
|
1045
|
+
return {
|
|
1046
|
+
args: encodeCallArgs(call.args ?? {}, `args for batch call '${call.fn.__lunoraRef}'`),
|
|
1047
|
+
functionPath: call.fn.__lunoraRef,
|
|
1048
|
+
id: index,
|
|
1049
|
+
shardKey: call.shardKey
|
|
1050
|
+
};
|
|
1051
|
+
})
|
|
1052
|
+
}),
|
|
1053
|
+
headers: this.rpcRequestHeaders({ attachBookmark: true }),
|
|
1054
|
+
method: "POST"
|
|
1055
|
+
});
|
|
1056
|
+
const bookmark = response.headers.get("x-d1-bookmark");
|
|
1057
|
+
if (bookmark) {
|
|
1058
|
+
this.bookmark.set(bookmark);
|
|
1059
|
+
}
|
|
1060
|
+
let body;
|
|
1061
|
+
try {
|
|
1062
|
+
body = await response.json();
|
|
1063
|
+
} catch {
|
|
1064
|
+
throw new LunoraError("INTERNAL", `LunoraClient: batch response was not JSON (status ${response.status.toString()})`);
|
|
1065
|
+
}
|
|
1066
|
+
if (!response.ok || body.error && !body.results) {
|
|
1067
|
+
if (body.error) {
|
|
1068
|
+
throw reconstructError(body.error);
|
|
1069
|
+
}
|
|
1070
|
+
throw new LunoraError("INTERNAL", `LunoraClient: batch request failed (status ${response.status.toString()})`);
|
|
1071
|
+
}
|
|
1072
|
+
return demuxBatchResults(body.results ?? [], calls.length);
|
|
1073
|
+
}
|
|
566
1074
|
/**
|
|
567
1075
|
* Invoke a mutation. Errors propagate as rejections.
|
|
568
1076
|
*
|
|
@@ -575,13 +1083,18 @@ class LunoraClient {
|
|
|
575
1083
|
*/
|
|
576
1084
|
async mutation(function_, args, options = {}) {
|
|
577
1085
|
if (this.closed) {
|
|
578
|
-
throw new
|
|
1086
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
579
1087
|
}
|
|
580
1088
|
const argsRecord = args;
|
|
581
|
-
const mutationId = nextId();
|
|
582
|
-
const optimisticRollbacks = this.applyOptimisticUpdates(
|
|
1089
|
+
const mutationId = options.mutationId ?? nextId();
|
|
1090
|
+
const { confirms: optimisticConfirms, rollbacks: optimisticRollbacks } = this.applyOptimisticUpdates(
|
|
1091
|
+
function_.__lunoraRef,
|
|
1092
|
+
argsRecord,
|
|
1093
|
+
options.shardKey,
|
|
1094
|
+
options.optimistic
|
|
1095
|
+
);
|
|
583
1096
|
if (options.optimisticUpdate) {
|
|
584
|
-
this.applyOptimisticUpdate(options.optimisticUpdate, args, options.shardKey, optimisticRollbacks);
|
|
1097
|
+
this.applyOptimisticUpdate(options.optimisticUpdate, args, options.shardKey, optimisticRollbacks, optimisticConfirms);
|
|
585
1098
|
}
|
|
586
1099
|
const conn = this.getConnection(options.shardKey);
|
|
587
1100
|
const wsState = conn?.wsState ?? "idle";
|
|
@@ -592,46 +1105,29 @@ class LunoraClient {
|
|
|
592
1105
|
const shouldQueueOffline = this.WebSocketImpl !== void 0 && connectedGate;
|
|
593
1106
|
const midReconnect = wsState === "connecting" && connectedGate;
|
|
594
1107
|
if (wsState !== "open" && !hasSocket && shouldQueueOffline || midReconnect) {
|
|
595
|
-
|
|
596
|
-
return new Promise((resolve, reject) => {
|
|
597
|
-
const entry = {
|
|
598
|
-
args: argsRecord,
|
|
599
|
-
functionPath: function_.__lunoraRef,
|
|
600
|
-
// Reuse the call's idempotency key as the queue id so the
|
|
601
|
-
// replay carries the same `x-lunora-mutation-id` the server
|
|
602
|
-
// dedups on.
|
|
603
|
-
id: mutationId,
|
|
604
|
-
// Persist the stamp alongside the record so a hydrated write
|
|
605
|
-
// can only replay under the identity that queued it.
|
|
606
|
-
identity: issuingIdentity,
|
|
607
|
-
reject: (error) => {
|
|
608
|
-
this.queuedIdentities.delete(mutationId);
|
|
609
|
-
for (let index = optimisticRollbacks.length - 1; index >= 0; index -= 1) {
|
|
610
|
-
optimisticRollbacks[index]?.();
|
|
611
|
-
}
|
|
612
|
-
reject(error instanceof Error ? error : new Error(String(error)));
|
|
613
|
-
},
|
|
614
|
-
resolve,
|
|
615
|
-
shardKey: options.shardKey
|
|
616
|
-
};
|
|
617
|
-
this.offlineQueue.enqueue(entry);
|
|
618
|
-
if (entry.id !== void 0) {
|
|
619
|
-
this.queuedIdentities.set(entry.id, issuingIdentity);
|
|
620
|
-
}
|
|
621
|
-
});
|
|
1108
|
+
return this.enqueueOfflineMutation(function_, argsRecord, options.shardKey, mutationId, optimisticRollbacks, optimisticConfirms);
|
|
622
1109
|
}
|
|
623
1110
|
try {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
1111
|
+
let commitCursor;
|
|
1112
|
+
const result = await this.rpc(function_.__lunoraRef, argsRecord, options.shardKey, {
|
|
1113
|
+
captureBookmark: true,
|
|
1114
|
+
mutationId,
|
|
1115
|
+
onCommitCursor: (cursor) => {
|
|
1116
|
+
commitCursor = cursor;
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
for (const confirm of optimisticConfirms) {
|
|
1120
|
+
confirm(commitCursor);
|
|
628
1121
|
}
|
|
1122
|
+
return result;
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
rollbackOptimistic(optimisticRollbacks);
|
|
629
1125
|
throw error;
|
|
630
1126
|
}
|
|
631
1127
|
}
|
|
632
1128
|
async action(function_, args, options = {}) {
|
|
633
1129
|
if (this.closed) {
|
|
634
|
-
throw new
|
|
1130
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
635
1131
|
}
|
|
636
1132
|
return await this.rpc(function_.__lunoraRef, args, options.shardKey);
|
|
637
1133
|
}
|
|
@@ -648,7 +1144,7 @@ class LunoraClient {
|
|
|
648
1144
|
*/
|
|
649
1145
|
async shardTraffic(table) {
|
|
650
1146
|
if (this.closed) {
|
|
651
|
-
throw new
|
|
1147
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
652
1148
|
}
|
|
653
1149
|
const body = await this.adminFetch(SHARD_TRAFFIC_PATH, "POST", { table });
|
|
654
1150
|
return { failed: body.failed ?? 0, ok: body.ok ?? 0, shards: body.shards ?? [] };
|
|
@@ -663,7 +1159,7 @@ class LunoraClient {
|
|
|
663
1159
|
*/
|
|
664
1160
|
async listScheduledJobs() {
|
|
665
1161
|
if (this.closed) {
|
|
666
|
-
throw new
|
|
1162
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
667
1163
|
}
|
|
668
1164
|
const body = await this.adminFetch(SCHEDULED_PATH, "GET");
|
|
669
1165
|
return body.records ?? [];
|
|
@@ -679,7 +1175,7 @@ class LunoraClient {
|
|
|
679
1175
|
*/
|
|
680
1176
|
async schedulerStatus() {
|
|
681
1177
|
if (this.closed) {
|
|
682
|
-
throw new
|
|
1178
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
683
1179
|
}
|
|
684
1180
|
const body = await this.adminFetch(SCHEDULED_STATUS_PATH, "GET");
|
|
685
1181
|
return {
|
|
@@ -691,7 +1187,7 @@ class LunoraClient {
|
|
|
691
1187
|
/** Cancel a pending scheduled job by id. Returns whether a job was removed. */
|
|
692
1188
|
async cancelScheduledJob(id) {
|
|
693
1189
|
if (this.closed) {
|
|
694
|
-
throw new
|
|
1190
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
695
1191
|
}
|
|
696
1192
|
const body = await this.adminFetch(SCHEDULED_CANCEL_PATH, "POST", { id });
|
|
697
1193
|
return { cancelled: body.cancelled === true };
|
|
@@ -706,7 +1202,7 @@ class LunoraClient {
|
|
|
706
1202
|
*/
|
|
707
1203
|
async listDeadJobs() {
|
|
708
1204
|
if (this.closed) {
|
|
709
|
-
throw new
|
|
1205
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
710
1206
|
}
|
|
711
1207
|
const body = await this.adminFetch(SCHEDULED_DEAD_PATH, "GET");
|
|
712
1208
|
return body.records ?? [];
|
|
@@ -718,7 +1214,7 @@ class LunoraClient {
|
|
|
718
1214
|
*/
|
|
719
1215
|
async retryDeadJob(id) {
|
|
720
1216
|
if (this.closed) {
|
|
721
|
-
throw new
|
|
1217
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
722
1218
|
}
|
|
723
1219
|
const body = await this.adminFetch(SCHEDULED_DEAD_RETRY_PATH, "POST", { id });
|
|
724
1220
|
return { retried: body.retried === true };
|
|
@@ -730,7 +1226,7 @@ class LunoraClient {
|
|
|
730
1226
|
*/
|
|
731
1227
|
async removeDeadJob(id) {
|
|
732
1228
|
if (this.closed) {
|
|
733
|
-
throw new
|
|
1229
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
734
1230
|
}
|
|
735
1231
|
const body = await this.adminFetch(SCHEDULED_DEAD_CANCEL_PATH, "POST", { id });
|
|
736
1232
|
return { removed: body.removed === true };
|
|
@@ -739,12 +1235,16 @@ class LunoraClient {
|
|
|
739
1235
|
* List a workflow's instances via the admin Workflows proxy
|
|
740
1236
|
* (`/_lunora/admin/workflows/instances`) — the Cloudflare control-plane data
|
|
741
1237
|
* the `Workflow` binding can't expose. Requires the worker to be built with a
|
|
742
|
-
* `workflowsClient` (Cloudflare account id + API token)
|
|
743
|
-
*
|
|
1238
|
+
* `workflowsClient` (Cloudflare account id + API token). When one isn't
|
|
1239
|
+
* configured this does NOT reject: the proxy returns a `200 { configured:
|
|
1240
|
+
* false }` sentinel, so the result resolves with `configured === false` and an
|
|
1241
|
+
* empty `instances` list — callers should branch on that flag rather than
|
|
1242
|
+
* try/catch. (The instance-detail / status endpoints still reject with 501.)
|
|
1243
|
+
* `name` is the deployed workflow name.
|
|
744
1244
|
*/
|
|
745
1245
|
async listWorkflowInstances(options) {
|
|
746
1246
|
if (this.closed) {
|
|
747
|
-
throw new
|
|
1247
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
748
1248
|
}
|
|
749
1249
|
const query = new URLSearchParams({ name: options.name });
|
|
750
1250
|
if (options.status !== void 0) {
|
|
@@ -757,12 +1257,18 @@ class LunoraClient {
|
|
|
757
1257
|
query.set("perPage", String(options.perPage));
|
|
758
1258
|
}
|
|
759
1259
|
const body = await this.adminFetch(`${WORKFLOWS_INSTANCES_PATH}?${query.toString()}`, "GET");
|
|
760
|
-
return {
|
|
1260
|
+
return {
|
|
1261
|
+
configured: body.configured,
|
|
1262
|
+
instances: body.instances ?? [],
|
|
1263
|
+
page: body.page ?? 1,
|
|
1264
|
+
perPage: body.perPage ?? options.perPage ?? 0,
|
|
1265
|
+
totalCount: body.totalCount
|
|
1266
|
+
};
|
|
761
1267
|
}
|
|
762
1268
|
/** Read one workflow instance with its step timeline (`/_lunora/admin/workflows/instance`). */
|
|
763
1269
|
async getWorkflowInstance(options) {
|
|
764
1270
|
if (this.closed) {
|
|
765
|
-
throw new
|
|
1271
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
766
1272
|
}
|
|
767
1273
|
const query = new URLSearchParams({ id: options.id, name: options.name });
|
|
768
1274
|
const body = await this.adminFetch(`${WORKFLOWS_INSTANCE_PATH}?${query.toString()}`, "GET");
|
|
@@ -781,7 +1287,7 @@ class LunoraClient {
|
|
|
781
1287
|
/** Pause / resume / terminate a workflow instance (`/_lunora/admin/workflows/status`). Needs an Edit-scoped Cloudflare token. */
|
|
782
1288
|
async setWorkflowInstanceStatus(options) {
|
|
783
1289
|
if (this.closed) {
|
|
784
|
-
throw new
|
|
1290
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
785
1291
|
}
|
|
786
1292
|
const body = await this.adminFetch(WORKFLOWS_STATUS_PATH, "POST", { action: options.action, id: options.id, name: options.name });
|
|
787
1293
|
return { status: body.status ?? "unknown" };
|
|
@@ -796,7 +1302,7 @@ class LunoraClient {
|
|
|
796
1302
|
*/
|
|
797
1303
|
subscribeScheduledJobs(onJobs) {
|
|
798
1304
|
if (this.closed) {
|
|
799
|
-
throw new
|
|
1305
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
800
1306
|
}
|
|
801
1307
|
if (this.WebSocketImpl === void 0) {
|
|
802
1308
|
return () => void 0;
|
|
@@ -852,7 +1358,7 @@ class LunoraClient {
|
|
|
852
1358
|
*/
|
|
853
1359
|
async listFunctions() {
|
|
854
1360
|
if (this.closed) {
|
|
855
|
-
throw new
|
|
1361
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
856
1362
|
}
|
|
857
1363
|
const body = await this.adminFetch(FUNCTIONS_PATH, "GET");
|
|
858
1364
|
return body.functions ?? [];
|
|
@@ -868,7 +1374,7 @@ class LunoraClient {
|
|
|
868
1374
|
*/
|
|
869
1375
|
async getCronJobs() {
|
|
870
1376
|
if (this.closed) {
|
|
871
|
-
throw new
|
|
1377
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
872
1378
|
}
|
|
873
1379
|
const body = await this.adminFetch(CRON_JOBS_PATH, "GET");
|
|
874
1380
|
return body.jobs ?? [];
|
|
@@ -884,7 +1390,7 @@ class LunoraClient {
|
|
|
884
1390
|
*/
|
|
885
1391
|
async runCronJob(name) {
|
|
886
1392
|
if (this.closed) {
|
|
887
|
-
throw new
|
|
1393
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
888
1394
|
}
|
|
889
1395
|
const body = await this.adminFetch(CRON_JOBS_RUN_PATH, "POST", { name });
|
|
890
1396
|
return { name: body.name ?? name, ran: body.ran === true };
|
|
@@ -899,7 +1405,7 @@ class LunoraClient {
|
|
|
899
1405
|
*/
|
|
900
1406
|
async fetchOpenApi() {
|
|
901
1407
|
if (this.closed) {
|
|
902
|
-
throw new
|
|
1408
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
903
1409
|
}
|
|
904
1410
|
return await this.adminFetch(OPENAPI_PATH, "GET");
|
|
905
1411
|
}
|
|
@@ -915,7 +1421,7 @@ class LunoraClient {
|
|
|
915
1421
|
*/
|
|
916
1422
|
async fetchOpenRpc() {
|
|
917
1423
|
if (this.closed) {
|
|
918
|
-
throw new
|
|
1424
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
919
1425
|
}
|
|
920
1426
|
return await this.adminFetch(OPENRPC_PATH, "GET");
|
|
921
1427
|
}
|
|
@@ -929,7 +1435,7 @@ class LunoraClient {
|
|
|
929
1435
|
*/
|
|
930
1436
|
async listStorageObjects(options = {}) {
|
|
931
1437
|
if (this.closed) {
|
|
932
|
-
throw new
|
|
1438
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
933
1439
|
}
|
|
934
1440
|
const params = new URLSearchParams();
|
|
935
1441
|
if (options.prefix !== void 0 && options.prefix !== "") {
|
|
@@ -957,7 +1463,7 @@ class LunoraClient {
|
|
|
957
1463
|
*/
|
|
958
1464
|
async deleteStorageObject(key, options) {
|
|
959
1465
|
if (this.closed) {
|
|
960
|
-
throw new
|
|
1466
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
961
1467
|
}
|
|
962
1468
|
const path = `${STORAGE_PATH}?key=${encodeURIComponent(key)}${bucketQuery(options?.bucket)}`;
|
|
963
1469
|
const body = await this.adminFetch(path, "DELETE");
|
|
@@ -971,7 +1477,7 @@ class LunoraClient {
|
|
|
971
1477
|
*/
|
|
972
1478
|
async listStorageBuckets() {
|
|
973
1479
|
if (this.closed) {
|
|
974
|
-
throw new
|
|
1480
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
975
1481
|
}
|
|
976
1482
|
const body = await this.adminFetch(STORAGE_BUCKETS_PATH, "GET");
|
|
977
1483
|
return body.buckets ?? [];
|
|
@@ -985,7 +1491,7 @@ class LunoraClient {
|
|
|
985
1491
|
*/
|
|
986
1492
|
async uploadStorageObject(options) {
|
|
987
1493
|
if (this.closed) {
|
|
988
|
-
throw new
|
|
1494
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
989
1495
|
}
|
|
990
1496
|
const path = `${STORAGE_PATH}?key=${encodeURIComponent(options.key)}${bucketQuery(options.bucket)}`;
|
|
991
1497
|
const body = await this.adminFetch(path, "PUT", options.body, options.contentType);
|
|
@@ -1004,7 +1510,7 @@ class LunoraClient {
|
|
|
1004
1510
|
*/
|
|
1005
1511
|
async signedStorageUrl(key, options) {
|
|
1006
1512
|
if (this.closed) {
|
|
1007
|
-
throw new
|
|
1513
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1008
1514
|
}
|
|
1009
1515
|
const expiresInSeconds = options?.expiresInSeconds;
|
|
1010
1516
|
const expiryQuery = expiresInSeconds === void 0 ? "" : `&expiresIn=${encodeURIComponent(expiresInSeconds.toString())}`;
|
|
@@ -1024,7 +1530,7 @@ class LunoraClient {
|
|
|
1024
1530
|
*/
|
|
1025
1531
|
async listGlobalTables() {
|
|
1026
1532
|
if (this.closed) {
|
|
1027
|
-
throw new
|
|
1533
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1028
1534
|
}
|
|
1029
1535
|
return await this.adminFetch(GLOBAL_TABLES_PATH, "GET");
|
|
1030
1536
|
}
|
|
@@ -1036,7 +1542,7 @@ class LunoraClient {
|
|
|
1036
1542
|
*/
|
|
1037
1543
|
async readGlobalTablePage(options) {
|
|
1038
1544
|
if (this.closed) {
|
|
1039
|
-
throw new
|
|
1545
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1040
1546
|
}
|
|
1041
1547
|
const params = new URLSearchParams({ table: options.table });
|
|
1042
1548
|
if (options.limit !== void 0) {
|
|
@@ -1059,7 +1565,7 @@ class LunoraClient {
|
|
|
1059
1565
|
*/
|
|
1060
1566
|
async facetGlobalColumn(options) {
|
|
1061
1567
|
if (this.closed) {
|
|
1062
|
-
throw new
|
|
1568
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1063
1569
|
}
|
|
1064
1570
|
const params = new URLSearchParams({ column: options.column, table: options.table });
|
|
1065
1571
|
if (options.limit !== void 0) {
|
|
@@ -1082,7 +1588,7 @@ class LunoraClient {
|
|
|
1082
1588
|
*/
|
|
1083
1589
|
async listVectorIndexes() {
|
|
1084
1590
|
if (this.closed) {
|
|
1085
|
-
throw new
|
|
1591
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1086
1592
|
}
|
|
1087
1593
|
const body = await this.adminFetch(VECTOR_INDEXES_PATH, "GET");
|
|
1088
1594
|
return body.indexes ?? [];
|
|
@@ -1096,11 +1602,77 @@ class LunoraClient {
|
|
|
1096
1602
|
*/
|
|
1097
1603
|
async queryVectorIndex(options) {
|
|
1098
1604
|
if (this.closed) {
|
|
1099
|
-
throw new
|
|
1605
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1100
1606
|
}
|
|
1101
1607
|
const body = await this.adminFetch(VECTOR_QUERY_PATH, "POST", options);
|
|
1102
1608
|
return body.matches ?? [];
|
|
1103
1609
|
}
|
|
1610
|
+
// --- KV namespace admin -------------------------------------------------
|
|
1611
|
+
/**
|
|
1612
|
+
* List the worker's registered Workers KV namespaces (binding names). Hits
|
|
1613
|
+
* the admin-gated `GET /_lunora/admin/kv/namespaces` endpoint — the worker
|
|
1614
|
+
* must be built with a `kvIntrospector` and `adminToken`. Powers the
|
|
1615
|
+
* studio's KV browser.
|
|
1616
|
+
*/
|
|
1617
|
+
async listKvNamespaces() {
|
|
1618
|
+
if (this.closed) {
|
|
1619
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1620
|
+
}
|
|
1621
|
+
const body = await this.adminFetch(KV_NAMESPACES_PATH, "GET");
|
|
1622
|
+
return body.namespaces ?? [];
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* List keys in a KV namespace, optionally filtered by `prefix` and
|
|
1626
|
+
* paginated via `cursor`. Hits the admin-gated
|
|
1627
|
+
* `GET /_lunora/admin/kv/keys` endpoint.
|
|
1628
|
+
*/
|
|
1629
|
+
async listKvKeys(options) {
|
|
1630
|
+
if (this.closed) {
|
|
1631
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1632
|
+
}
|
|
1633
|
+
const path = withQuery(KV_KEYS_PATH, {
|
|
1634
|
+
cursor: options.cursor,
|
|
1635
|
+
limit: options.limit,
|
|
1636
|
+
namespace: options.namespace,
|
|
1637
|
+
prefix: options.prefix
|
|
1638
|
+
});
|
|
1639
|
+
return await this.adminFetch(path, "GET");
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* Read a KV value (as text) and its metadata. Hits the admin-gated
|
|
1643
|
+
* `GET /_lunora/admin/kv/value` endpoint. Returns `{ value: null, metadata: null }`
|
|
1644
|
+
* when the key is absent.
|
|
1645
|
+
*/
|
|
1646
|
+
async getKvValue(options) {
|
|
1647
|
+
if (this.closed) {
|
|
1648
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1649
|
+
}
|
|
1650
|
+
const path = withQuery(KV_VALUE_PATH, { key: options.key, namespace: options.namespace });
|
|
1651
|
+
return await this.adminFetch(path, "GET");
|
|
1652
|
+
}
|
|
1653
|
+
/**
|
|
1654
|
+
* Write a string value to a KV namespace. Accepts an absolute `expiration`
|
|
1655
|
+
* (Unix seconds) or a relative `expirationTtl`, plus optional `metadata` —
|
|
1656
|
+
* re-send the loaded values on edit so a save preserves rather than clears
|
|
1657
|
+
* them. Hits the admin-gated `PUT /_lunora/admin/kv/value` endpoint.
|
|
1658
|
+
*/
|
|
1659
|
+
async putKvValue(options) {
|
|
1660
|
+
if (this.closed) {
|
|
1661
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1662
|
+
}
|
|
1663
|
+
await this.adminFetch(KV_VALUE_PATH, "PUT", options);
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
1666
|
+
* Delete a key from a KV namespace. No-op when the key is absent. Hits the
|
|
1667
|
+
* admin-gated `DELETE /_lunora/admin/kv/value` endpoint.
|
|
1668
|
+
*/
|
|
1669
|
+
async deleteKvKey(options) {
|
|
1670
|
+
if (this.closed) {
|
|
1671
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1672
|
+
}
|
|
1673
|
+
const path = withQuery(KV_VALUE_PATH, { key: options.key, namespace: options.namespace });
|
|
1674
|
+
await this.adminFetch(path, "DELETE");
|
|
1675
|
+
}
|
|
1104
1676
|
// --- Auth admin ---------------------------------------------------------
|
|
1105
1677
|
/**
|
|
1106
1678
|
* List authenticated users, paged and optionally searched / filtered / sorted.
|
|
@@ -1110,7 +1682,7 @@ class LunoraClient {
|
|
|
1110
1682
|
*/
|
|
1111
1683
|
async listAuthUsers(options = {}) {
|
|
1112
1684
|
if (this.closed) {
|
|
1113
|
-
throw new
|
|
1685
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1114
1686
|
}
|
|
1115
1687
|
const path = withQuery(AUTH_USERS_PATH, {
|
|
1116
1688
|
filterField: options.filterField,
|
|
@@ -1222,10 +1794,90 @@ class LunoraClient {
|
|
|
1222
1794
|
async cancelAuthOrgInvitation(input) {
|
|
1223
1795
|
await this.adminFetch(AUTH_CANCEL_INVITATION_PATH, "POST", input);
|
|
1224
1796
|
}
|
|
1797
|
+
/**
|
|
1798
|
+
* Report the deployment's auth configuration — enabled plugins, sign-in
|
|
1799
|
+
* methods, user-settable create-user fields, organization sub-features
|
|
1800
|
+
* (teams / roles), and session / rate-limit policy. Drives the config panel
|
|
1801
|
+
* and the dynamic create-user form. Never carries a secret.
|
|
1802
|
+
*/
|
|
1803
|
+
async getAuthConfig() {
|
|
1804
|
+
return await this.adminFetch(AUTH_CONFIG_PATH, "GET");
|
|
1805
|
+
}
|
|
1806
|
+
/** Create an organization; optionally seed an `owner` member for `ownerId`. */
|
|
1807
|
+
async createAuthOrganization(input) {
|
|
1808
|
+
return await this.adminFetch(AUTH_CREATE_ORG_PATH, "POST", input);
|
|
1809
|
+
}
|
|
1810
|
+
/** Update an organization's name/slug/logo/metadata. */
|
|
1811
|
+
async updateAuthOrganization(input) {
|
|
1812
|
+
return await this.adminFetch(AUTH_UPDATE_ORG_PATH, "POST", input);
|
|
1813
|
+
}
|
|
1814
|
+
/** Delete an organization and cascade its members, invitations, teams, and custom roles. */
|
|
1815
|
+
async deleteAuthOrganization(input) {
|
|
1816
|
+
await this.adminFetch(AUTH_REMOVE_ORG_PATH, "POST", input);
|
|
1817
|
+
}
|
|
1818
|
+
/** Directly add an existing user to an organization (no invitation/acceptance). */
|
|
1819
|
+
async addAuthOrgMember(input) {
|
|
1820
|
+
return await this.adminFetch(AUTH_ADD_MEMBER_PATH, "POST", input);
|
|
1821
|
+
}
|
|
1822
|
+
/** Create a pending email invitation to an organization. */
|
|
1823
|
+
async inviteAuthOrgMember(input) {
|
|
1824
|
+
return await this.adminFetch(AUTH_INVITE_MEMBER_PATH, "POST", input);
|
|
1825
|
+
}
|
|
1826
|
+
/** Change a member's role. */
|
|
1827
|
+
async setAuthOrgMemberRole(input) {
|
|
1828
|
+
return await this.adminFetch(AUTH_MEMBER_ROLE_PATH, "POST", input);
|
|
1829
|
+
}
|
|
1830
|
+
/** List an organization's teams (requires the organization plugin with teams enabled). */
|
|
1831
|
+
async listAuthOrgTeams(input) {
|
|
1832
|
+
const path = withQuery(AUTH_ORG_TEAMS_PATH, { limit: input.limit, offset: input.offset, organizationId: input.organizationId });
|
|
1833
|
+
return await this.adminFetch(path, "GET");
|
|
1834
|
+
}
|
|
1835
|
+
/** Create a team under an organization. */
|
|
1836
|
+
async createAuthOrgTeam(input) {
|
|
1837
|
+
return await this.adminFetch(AUTH_CREATE_TEAM_PATH, "POST", input);
|
|
1838
|
+
}
|
|
1839
|
+
/** Rename a team. */
|
|
1840
|
+
async updateAuthOrgTeam(input) {
|
|
1841
|
+
return await this.adminFetch(AUTH_UPDATE_TEAM_PATH, "POST", input);
|
|
1842
|
+
}
|
|
1843
|
+
/** Delete a team and its memberships. */
|
|
1844
|
+
async removeAuthOrgTeam(input) {
|
|
1845
|
+
await this.adminFetch(AUTH_REMOVE_TEAM_PATH, "POST", input);
|
|
1846
|
+
}
|
|
1847
|
+
/** List a team's members. */
|
|
1848
|
+
async listAuthOrgTeamMembers(input) {
|
|
1849
|
+
const path = withQuery(AUTH_ORG_TEAM_MEMBERS_PATH, { limit: input.limit, offset: input.offset, teamId: input.teamId });
|
|
1850
|
+
return await this.adminFetch(path, "GET");
|
|
1851
|
+
}
|
|
1852
|
+
/** Add a user to a team. */
|
|
1853
|
+
async addAuthOrgTeamMember(input) {
|
|
1854
|
+
return await this.adminFetch(AUTH_ADD_TEAM_MEMBER_PATH, "POST", input);
|
|
1855
|
+
}
|
|
1856
|
+
/** Remove a member from a team. */
|
|
1857
|
+
async removeAuthOrgTeamMember(input) {
|
|
1858
|
+
await this.adminFetch(AUTH_REMOVE_TEAM_MEMBER_PATH, "POST", input);
|
|
1859
|
+
}
|
|
1860
|
+
/** List an organization's custom roles (requires the organization plugin with dynamic access control). */
|
|
1861
|
+
async listAuthOrgRoles(input) {
|
|
1862
|
+
const path = withQuery(AUTH_ORG_ROLES_PATH, { limit: input.limit, offset: input.offset, organizationId: input.organizationId });
|
|
1863
|
+
return await this.adminFetch(path, "GET");
|
|
1864
|
+
}
|
|
1865
|
+
/** Create a custom org role with a permission grant (a `resource -> actions[]` map). */
|
|
1866
|
+
async createAuthOrgRole(input) {
|
|
1867
|
+
return await this.adminFetch(AUTH_CREATE_ROLE_PATH, "POST", input);
|
|
1868
|
+
}
|
|
1869
|
+
/** Replace a custom org role's permission grant. */
|
|
1870
|
+
async updateAuthOrgRole(input) {
|
|
1871
|
+
return await this.adminFetch(AUTH_UPDATE_ROLE_PATH, "POST", input);
|
|
1872
|
+
}
|
|
1873
|
+
/** Delete a custom org role. */
|
|
1874
|
+
async deleteAuthOrgRole(input) {
|
|
1875
|
+
await this.adminFetch(AUTH_REMOVE_ROLE_PATH, "POST", input);
|
|
1876
|
+
}
|
|
1225
1877
|
/** List auth sessions, paged and optionally filtered to one user. */
|
|
1226
1878
|
async listAuthSessions(options = {}) {
|
|
1227
1879
|
if (this.closed) {
|
|
1228
|
-
throw new
|
|
1880
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1229
1881
|
}
|
|
1230
1882
|
const params = new URLSearchParams();
|
|
1231
1883
|
if (options.userId !== void 0 && options.userId !== "") {
|
|
@@ -1243,7 +1895,7 @@ class LunoraClient {
|
|
|
1243
1895
|
// --- Subscriptions ------------------------------------------------------
|
|
1244
1896
|
subscribe(function_, args, callback, options = {}) {
|
|
1245
1897
|
if (this.closed) {
|
|
1246
|
-
throw new
|
|
1898
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1247
1899
|
}
|
|
1248
1900
|
const argsRecord = args ?? {};
|
|
1249
1901
|
const key = SubscriptionRegistry.key(function_.__lunoraRef, argsRecord, options.shardKey);
|
|
@@ -1260,12 +1912,14 @@ class LunoraClient {
|
|
|
1260
1912
|
args: argsRecord,
|
|
1261
1913
|
argsKey,
|
|
1262
1914
|
callbacks: /* @__PURE__ */ new Set(),
|
|
1915
|
+
checkpointCallbacks: /* @__PURE__ */ new Set(),
|
|
1263
1916
|
errorCallbacks: /* @__PURE__ */ new Set(),
|
|
1264
1917
|
fn: function_,
|
|
1265
1918
|
id,
|
|
1266
1919
|
lastValue: cached?.value,
|
|
1920
|
+
optimisticLayers: [],
|
|
1921
|
+
serverBase: cached?.value,
|
|
1267
1922
|
serverCursor: cached?.serverCursor,
|
|
1268
|
-
serverVersion: 0,
|
|
1269
1923
|
shardKey: options.shardKey,
|
|
1270
1924
|
...cached?.serverEpoch === void 0 ? {} : { serverEpoch: cached.serverEpoch }
|
|
1271
1925
|
};
|
|
@@ -1275,6 +1929,9 @@ class LunoraClient {
|
|
|
1275
1929
|
if (errorCallback) {
|
|
1276
1930
|
state.errorCallbacks.add(errorCallback);
|
|
1277
1931
|
}
|
|
1932
|
+
if (options.onCheckpoint) {
|
|
1933
|
+
state.checkpointCallbacks.add(options.onCheckpoint);
|
|
1934
|
+
}
|
|
1278
1935
|
if (state.lastValue !== void 0) {
|
|
1279
1936
|
try {
|
|
1280
1937
|
subscriptionCallback(state.lastValue);
|
|
@@ -1289,16 +1946,59 @@ class LunoraClient {
|
|
|
1289
1946
|
if (errorCallback) {
|
|
1290
1947
|
subscriptionState.errorCallbacks.delete(errorCallback);
|
|
1291
1948
|
}
|
|
1949
|
+
if (options.onCheckpoint) {
|
|
1950
|
+
subscriptionState.checkpointCallbacks.delete(options.onCheckpoint);
|
|
1951
|
+
}
|
|
1292
1952
|
if (subscriptionState.callbacks.size === 0) {
|
|
1293
1953
|
const conn = this.getConnection(subscriptionState.shardKey);
|
|
1294
1954
|
const ok = conn ? sendOn(conn, { id: subscriptionState.id, type: "unsubscribe" }) : false;
|
|
1295
1955
|
if (!ok && conn) {
|
|
1296
|
-
conn.pendingUnsubscribes.push(subscriptionState.id);
|
|
1956
|
+
conn.pendingUnsubscribes.push({ id: subscriptionState.id, type: "unsubscribe" });
|
|
1297
1957
|
}
|
|
1298
1958
|
this.subscriptions.remove(subscriptionState);
|
|
1299
1959
|
}
|
|
1300
1960
|
};
|
|
1301
1961
|
}
|
|
1962
|
+
/**
|
|
1963
|
+
* Subscribe to a declarative **shape** — server-side partial replication
|
|
1964
|
+
* scoped by `shardBy` + the shape's predicate + RLS. The parallel to
|
|
1965
|
+
* {@link subscribe} for the poke protocol: the client sends the shape *name* +
|
|
1966
|
+
* validated `args` (never a `where` the client could forge), the server seeds
|
|
1967
|
+
* the current membership as an insert-poke and streams live membership diffs.
|
|
1968
|
+
* Each applied poke materializes the shape's rowset and invokes `callback`.
|
|
1969
|
+
*
|
|
1970
|
+
* Unlike {@link subscribe}, shape subscriptions are NOT deduped by
|
|
1971
|
+
* (name, args): the server resolves them under the socket's verified identity,
|
|
1972
|
+
* so every call gets its own id + view. The returned function unsubscribes.
|
|
1973
|
+
*/
|
|
1974
|
+
subscribeShape(shape, callback, options = {}) {
|
|
1975
|
+
if (this.closed) {
|
|
1976
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1977
|
+
}
|
|
1978
|
+
this.nextShapeId += 1;
|
|
1979
|
+
const id = `shape_${this.nextShapeId.toString()}`;
|
|
1980
|
+
const state = {
|
|
1981
|
+
args: shape.args,
|
|
1982
|
+
callbacks: /* @__PURE__ */ new Set([callback]),
|
|
1983
|
+
errorCallbacks: options.onError ? /* @__PURE__ */ new Set([options.onError]) : /* @__PURE__ */ new Set(),
|
|
1984
|
+
id,
|
|
1985
|
+
name: shape.name,
|
|
1986
|
+
onCheckpoint: options.onCheckpoint,
|
|
1987
|
+
rows: /* @__PURE__ */ new Map(),
|
|
1988
|
+
shardKey: options.shardKey
|
|
1989
|
+
};
|
|
1990
|
+
this.shapeSubscriptions.set(id, state);
|
|
1991
|
+
this.ensureSocket(options.shardKey);
|
|
1992
|
+
this.sendShapeSubscribeIfOpen(state);
|
|
1993
|
+
return () => {
|
|
1994
|
+
this.shapeSubscriptions.delete(id);
|
|
1995
|
+
const conn = this.getConnection(state.shardKey);
|
|
1996
|
+
const ok = conn ? sendOn(conn, { id, type: "shape_unsubscribe" }) : false;
|
|
1997
|
+
if (!ok && conn) {
|
|
1998
|
+
conn.pendingUnsubscribes.push({ id, type: "shape_unsubscribe" });
|
|
1999
|
+
}
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
1302
2002
|
/**
|
|
1303
2003
|
* Open a streaming query. The function reference must be a
|
|
1304
2004
|
* `kind:"stream"` registration (built with `c.query.input(...).stream(...)`);
|
|
@@ -1319,10 +2019,10 @@ class LunoraClient {
|
|
|
1319
2019
|
*/
|
|
1320
2020
|
stream(function_, args, options = {}) {
|
|
1321
2021
|
if (this.closed) {
|
|
1322
|
-
throw new
|
|
2022
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1323
2023
|
}
|
|
1324
2024
|
if (this.WebSocketImpl === void 0) {
|
|
1325
|
-
throw new
|
|
2025
|
+
throw new LunoraError("INTERNAL", "LunoraClient: streams require a WebSocket implementation");
|
|
1326
2026
|
}
|
|
1327
2027
|
this.nextStreamId += 1;
|
|
1328
2028
|
const id = `stream_${this.nextStreamId.toString()}`;
|
|
@@ -1343,7 +2043,14 @@ class LunoraClient {
|
|
|
1343
2043
|
const conn = this.getConnection(shardKey);
|
|
1344
2044
|
const message = {
|
|
1345
2045
|
id,
|
|
1346
|
-
|
|
2046
|
+
// Wire-encode the stream args so `bigint`/bytes survive the send (raw
|
|
2047
|
+
// `JSON.stringify` throws on a bigint); the shard `decodeWire`s them
|
|
2048
|
+
// before invoking the stream handler.
|
|
2049
|
+
query: {
|
|
2050
|
+
args: encodeCallArgs(argsRecord, `stream args for '${function_.__lunoraRef}'`),
|
|
2051
|
+
functionPath: function_.__lunoraRef,
|
|
2052
|
+
shardKey
|
|
2053
|
+
},
|
|
1347
2054
|
type: "stream"
|
|
1348
2055
|
};
|
|
1349
2056
|
const sentImmediately = conn?.wsState === "open" && sendOn(conn, message);
|
|
@@ -1354,9 +2061,7 @@ class LunoraClient {
|
|
|
1354
2061
|
const droppedId = dropped?.id;
|
|
1355
2062
|
const droppedStream = droppedId ? this.streams.get(droppedId) : void 0;
|
|
1356
2063
|
if (droppedStream) {
|
|
1357
|
-
droppedStream.handle.fail(
|
|
1358
|
-
Object.assign(new Error("stream-start frame evicted while socket was unreachable"), { code: "STREAM_QUEUE_OVERFLOW" })
|
|
1359
|
-
);
|
|
2064
|
+
droppedStream.handle.fail(new LunoraError("STREAM_QUEUE_OVERFLOW", "stream-start frame evicted while socket was unreachable"));
|
|
1360
2065
|
this.streams.delete(droppedId);
|
|
1361
2066
|
}
|
|
1362
2067
|
}
|
|
@@ -1366,8 +2071,10 @@ class LunoraClient {
|
|
|
1366
2071
|
}
|
|
1367
2072
|
close() {
|
|
1368
2073
|
this.closed = true;
|
|
2074
|
+
this.outboxLeaderRelease?.();
|
|
2075
|
+
this.outboxLeaderRelease = void 0;
|
|
1369
2076
|
for (const stream of this.streams.values()) {
|
|
1370
|
-
stream.handle.fail(
|
|
2077
|
+
stream.handle.fail(new LunoraError("CLIENT_CLOSED", "LunoraClient closed"));
|
|
1371
2078
|
}
|
|
1372
2079
|
this.streams.clear();
|
|
1373
2080
|
for (const conn of this.connections.values()) {
|
|
@@ -1375,6 +2082,10 @@ class LunoraClient {
|
|
|
1375
2082
|
clearTimeout(conn.reconnectTimer);
|
|
1376
2083
|
conn.reconnectTimer = void 0;
|
|
1377
2084
|
}
|
|
2085
|
+
if (conn.connectTimer !== void 0) {
|
|
2086
|
+
clearTimeout(conn.connectTimer);
|
|
2087
|
+
conn.connectTimer = void 0;
|
|
2088
|
+
}
|
|
1378
2089
|
this.stopHeartbeat(conn);
|
|
1379
2090
|
if (conn.socket) {
|
|
1380
2091
|
try {
|
|
@@ -1397,9 +2108,83 @@ class LunoraClient {
|
|
|
1397
2108
|
this.authTokenListeners.clear();
|
|
1398
2109
|
this.statusListeners.clear();
|
|
1399
2110
|
this.tokenExpiredListeners.clear();
|
|
2111
|
+
this.mutationSettledListeners.clear();
|
|
2112
|
+
this.pendingChangeListeners.clear();
|
|
1400
2113
|
this.whisperHandlers.clear();
|
|
2114
|
+
this.shapeSubscriptions.clear();
|
|
2115
|
+
this.pokeBuffers.clear();
|
|
1401
2116
|
}
|
|
1402
2117
|
// --- Internals ----------------------------------------------------------
|
|
2118
|
+
/**
|
|
2119
|
+
* Persist a mutation that can't go out on the wire right now (offline, or
|
|
2120
|
+
* mid-reconnect after a prior connect). The optimistic update has already
|
|
2121
|
+
* been applied by `mutation`; this only chooses the durable write path and
|
|
2122
|
+
* rolls the optimistic write back if persistence is rejected.
|
|
2123
|
+
*
|
|
2124
|
+
* Two paths: when an `outbox` sink is wired (the `@lunora/db` executor) it
|
|
2125
|
+
* owns persistence + at-least-once replay, so we delegate and return
|
|
2126
|
+
* optimistically (confirmation rides the synced view). Otherwise the
|
|
2127
|
+
* built-in `OfflineQueue` resolves/rejects the returned promise on replay.
|
|
2128
|
+
*/
|
|
2129
|
+
async enqueueOfflineMutation(function_, argsRecord, shardKey, mutationId, optimisticRollbacks, optimisticConfirms) {
|
|
2130
|
+
const issuingIdentity = this.identityFingerprint();
|
|
2131
|
+
if (this.outbox) {
|
|
2132
|
+
this.outboxMutationCounter += 1;
|
|
2133
|
+
const outboxMutationId = this.outboxMutationCounter;
|
|
2134
|
+
try {
|
|
2135
|
+
await this.outbox.enqueue({
|
|
2136
|
+
args: argsRecord,
|
|
2137
|
+
clientId: this.clientId,
|
|
2138
|
+
functionPath: function_.__lunoraRef,
|
|
2139
|
+
idempotencyKey: `${this.clientId}:${String(outboxMutationId)}`,
|
|
2140
|
+
identity: issuingIdentity,
|
|
2141
|
+
mutationId: outboxMutationId,
|
|
2142
|
+
shardKey
|
|
2143
|
+
});
|
|
2144
|
+
} catch (error) {
|
|
2145
|
+
rollbackOptimistic(optimisticRollbacks);
|
|
2146
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
2147
|
+
}
|
|
2148
|
+
for (const confirm of optimisticConfirms) {
|
|
2149
|
+
confirm(void 0);
|
|
2150
|
+
}
|
|
2151
|
+
return void 0;
|
|
2152
|
+
}
|
|
2153
|
+
return new Promise((resolve, reject) => {
|
|
2154
|
+
const entry = {
|
|
2155
|
+
args: argsRecord,
|
|
2156
|
+
functionPath: function_.__lunoraRef,
|
|
2157
|
+
// A live caller is awaiting this Promise, so a terminal verdict
|
|
2158
|
+
// reaches them directly; the observer event carries
|
|
2159
|
+
// `hadAwaiter: true`. Hydrated replays leave this unset.
|
|
2160
|
+
liveAwaiter: true,
|
|
2161
|
+
// Reuse the call's idempotency key as the queue id so the replay
|
|
2162
|
+
// carries the same `x-lunora-mutation-id` the server dedups on.
|
|
2163
|
+
id: mutationId,
|
|
2164
|
+
// Persist the stamp alongside the record so a hydrated write can
|
|
2165
|
+
// only replay under the identity that queued it.
|
|
2166
|
+
identity: issuingIdentity,
|
|
2167
|
+
// Confirm the per-call optimistic layer(s) against the commit cursor
|
|
2168
|
+
// the flush replay echoes (see flushOfflineQueue).
|
|
2169
|
+
onCommit: (commitCursor) => {
|
|
2170
|
+
for (const confirm of optimisticConfirms) {
|
|
2171
|
+
confirm(commitCursor);
|
|
2172
|
+
}
|
|
2173
|
+
},
|
|
2174
|
+
reject: (error) => {
|
|
2175
|
+
this.queuedIdentities.delete(mutationId);
|
|
2176
|
+
rollbackOptimistic(optimisticRollbacks);
|
|
2177
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
2178
|
+
},
|
|
2179
|
+
resolve,
|
|
2180
|
+
shardKey
|
|
2181
|
+
};
|
|
2182
|
+
this.offlineQueue.enqueue(entry);
|
|
2183
|
+
if (entry.id !== void 0) {
|
|
2184
|
+
this.queuedIdentities.set(entry.id, issuingIdentity);
|
|
2185
|
+
}
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
1403
2188
|
/**
|
|
1404
2189
|
* Restore offline mutations persisted in a prior session and open a socket
|
|
1405
2190
|
* for each shard they target so they flush once the WS reconnects. Failures
|
|
@@ -1414,6 +2199,38 @@ class LunoraClient {
|
|
|
1414
2199
|
} catch {
|
|
1415
2200
|
}
|
|
1416
2201
|
}
|
|
2202
|
+
/**
|
|
2203
|
+
* Re-queue the durable offline writes — but only as the multi-tab LEADER. The
|
|
2204
|
+
* persisted queue is shared across a profile's tabs; without coordination
|
|
2205
|
+
* every tab would re-queue and replay the same writes (correct only because
|
|
2206
|
+
* the server dedups by idempotency key, but wasteful + racy). A Web Lock makes
|
|
2207
|
+
* exactly one tab hydrate; it holds the lock for its lifetime, so when it
|
|
2208
|
+
* closes another tab acquires the lock and takes over. Falls back to
|
|
2209
|
+
* unconditional hydration where Web Locks are unavailable (React Native, older
|
|
2210
|
+
* browsers, SSR) — single-context there, so no coordination is needed.
|
|
2211
|
+
*/
|
|
2212
|
+
hydrateAsOutboxLeader() {
|
|
2213
|
+
const hydrate = () => {
|
|
2214
|
+
this.hydratePersistedQueue().catch(() => void 0);
|
|
2215
|
+
};
|
|
2216
|
+
const locks = globalThis.navigator?.locks;
|
|
2217
|
+
if (!locks) {
|
|
2218
|
+
hydrate();
|
|
2219
|
+
return;
|
|
2220
|
+
}
|
|
2221
|
+
locks.request(`lunora:outbox-leader:${this.url}`, () => {
|
|
2222
|
+
if (!this.closed) {
|
|
2223
|
+
hydrate();
|
|
2224
|
+
}
|
|
2225
|
+
return new Promise((resolve) => {
|
|
2226
|
+
if (this.closed) {
|
|
2227
|
+
resolve();
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
this.outboxLeaderRelease = resolve;
|
|
2231
|
+
});
|
|
2232
|
+
}).catch(hydrate);
|
|
2233
|
+
}
|
|
1417
2234
|
/**
|
|
1418
2235
|
* Load every cached query into {@link hydratedQueryCache} so the next
|
|
1419
2236
|
* `subscribe()` for each key seeds its initial value off disk. A
|
|
@@ -1428,6 +2245,10 @@ class LunoraClient {
|
|
|
1428
2245
|
try {
|
|
1429
2246
|
const entries = await this.queryCache.load();
|
|
1430
2247
|
for (const { key, ...entry } of entries) {
|
|
2248
|
+
if (isStaleVersion(this.persistenceVersion, entry.version)) {
|
|
2249
|
+
this.queryCache.remove(key).catch(() => void 0);
|
|
2250
|
+
continue;
|
|
2251
|
+
}
|
|
1431
2252
|
this.hydratedQueryCache.set(key, entry);
|
|
1432
2253
|
}
|
|
1433
2254
|
} catch {
|
|
@@ -1456,7 +2277,8 @@ class LunoraClient {
|
|
|
1456
2277
|
* (nothing to render offline).
|
|
1457
2278
|
*/
|
|
1458
2279
|
persistQueryValue(state) {
|
|
1459
|
-
|
|
2280
|
+
const authoritative = state.serverBase;
|
|
2281
|
+
if (!this.queryCache || authoritative === void 0) {
|
|
1460
2282
|
return;
|
|
1461
2283
|
}
|
|
1462
2284
|
const key = queryCacheKey(state.fn.__lunoraRef, state.argsKey, state.shardKey);
|
|
@@ -1464,8 +2286,9 @@ class LunoraClient {
|
|
|
1464
2286
|
identity: this.identityFingerprint(),
|
|
1465
2287
|
serverCursor: state.serverCursor,
|
|
1466
2288
|
ts: Date.now(),
|
|
1467
|
-
value:
|
|
1468
|
-
...state.serverEpoch === void 0 ? {} : { serverEpoch: state.serverEpoch }
|
|
2289
|
+
value: authoritative,
|
|
2290
|
+
...state.serverEpoch === void 0 ? {} : { serverEpoch: state.serverEpoch },
|
|
2291
|
+
...this.persistenceVersion === void 0 ? {} : { version: this.persistenceVersion }
|
|
1469
2292
|
});
|
|
1470
2293
|
this.cacheFlushTimer ??= setTimeout(() => {
|
|
1471
2294
|
this.flushQueryCacheWrites().catch(() => void 0);
|
|
@@ -1504,48 +2327,70 @@ class LunoraClient {
|
|
|
1504
2327
|
return;
|
|
1505
2328
|
}
|
|
1506
2329
|
this.lastStatus = next;
|
|
1507
|
-
|
|
1508
|
-
try {
|
|
1509
|
-
listener(next);
|
|
1510
|
-
} catch {
|
|
1511
|
-
}
|
|
1512
|
-
}
|
|
2330
|
+
this.statusListeners.emit(next);
|
|
1513
2331
|
}
|
|
1514
2332
|
/**
|
|
1515
|
-
*
|
|
1516
|
-
*
|
|
1517
|
-
*
|
|
1518
|
-
*
|
|
1519
|
-
|
|
2333
|
+
* Build a {@link MutationSettledEvent} from a queued entry and emit it on the
|
|
2334
|
+
* {@link onMutationSettled} channel. `item.id` is always assigned by the time
|
|
2335
|
+
* a write settles (`enqueue`/`hydrate` guarantee it), so the `?? ""` fallback
|
|
2336
|
+
* is unreachable — present only to satisfy the optional queue-id type.
|
|
2337
|
+
*/
|
|
2338
|
+
emitItemSettled(item, status, error) {
|
|
2339
|
+
this.mutationSettledListeners.emit({
|
|
2340
|
+
args: item.args,
|
|
2341
|
+
code: error === void 0 ? void 0 : error.code,
|
|
2342
|
+
error,
|
|
2343
|
+
functionPath: item.functionPath,
|
|
2344
|
+
hadAwaiter: item.liveAwaiter ?? false,
|
|
2345
|
+
id: item.id ?? "",
|
|
2346
|
+
shardKey: item.shardKey,
|
|
2347
|
+
status
|
|
2348
|
+
});
|
|
2349
|
+
}
|
|
2350
|
+
/**
|
|
2351
|
+
* Apply an optimistic update to the subscription that matches the mutation's
|
|
2352
|
+
* `(functionRef, args, shardKey)` triple, returning the rollback callbacks to
|
|
2353
|
+
* invoke if the mutation later fails.
|
|
2354
|
+
*
|
|
2355
|
+
* The registry is already indexed by exactly this triple via
|
|
2356
|
+
* `SubscriptionRegistry.key`, so at most one subscription can match. A direct
|
|
2357
|
+
* O(1) keyed lookup replaces the former O(N) linear scan over all subscriptions.
|
|
2358
|
+
*
|
|
2359
|
+
* `shardKey` normalization: both `undefined` and `""` map to the empty string
|
|
2360
|
+
* inside `SubscriptionRegistry.key` (via `?? ""`), so a mutation fired without
|
|
2361
|
+
* a shardKey correctly matches a subscription registered without one regardless
|
|
2362
|
+
* of whether the caller passed `undefined` or omitted the field.
|
|
1520
2363
|
*/
|
|
1521
2364
|
applyOptimisticUpdates(functionRef, argsRecord, mutationShardKey, optimistic) {
|
|
1522
|
-
const
|
|
2365
|
+
const confirms = [];
|
|
2366
|
+
const rollbacks = [];
|
|
1523
2367
|
if (!optimistic) {
|
|
1524
|
-
return
|
|
2368
|
+
return { confirms, rollbacks };
|
|
1525
2369
|
}
|
|
1526
|
-
const
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
optimisticRollbacks.push(rollback);
|
|
2370
|
+
const matchKey = SubscriptionRegistry.key(functionRef, argsRecord, mutationShardKey);
|
|
2371
|
+
const state = this.subscriptions.get(matchKey);
|
|
2372
|
+
if (state) {
|
|
2373
|
+
const handle = applyOptimisticLayer(state, optimistic);
|
|
2374
|
+
if (handle) {
|
|
2375
|
+
confirms.push(handle.confirm);
|
|
2376
|
+
rollbacks.push(handle.rollback);
|
|
1534
2377
|
}
|
|
1535
2378
|
}
|
|
1536
|
-
return
|
|
2379
|
+
return { confirms, rollbacks };
|
|
1537
2380
|
}
|
|
1538
2381
|
/**
|
|
1539
2382
|
* Run a Convex-parity `optimisticUpdate` callback against a localStore bound
|
|
1540
|
-
* to the live subscription registry
|
|
1541
|
-
*
|
|
1542
|
-
*
|
|
1543
|
-
*
|
|
1544
|
-
*
|
|
1545
|
-
*
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
2383
|
+
* to the live subscription registry. Each `setQuery` registers a constant
|
|
2384
|
+
* optimistic LAYER on its target subscription (via the same engine the
|
|
2385
|
+
* per-call `optimistic` path uses), so the multi-query patch rebases onto
|
|
2386
|
+
* incoming deltas and drops gaplessly on its commit cursor — its `confirm` /
|
|
2387
|
+
* `rollback` closures are appended to the mutation's settle lists. A throwing
|
|
2388
|
+
* callback unwinds its own partial writes — LIFO over just the rollbacks it
|
|
2389
|
+
* produced — and is swallowed, so a buggy optimistic update can never fail the
|
|
2390
|
+
* mutation or leave a partial patch live.
|
|
2391
|
+
*/
|
|
2392
|
+
applyOptimisticUpdate(optimisticUpdate, args, shardKey, optimisticRollbacks, optimisticConfirms) {
|
|
2393
|
+
const { confirms, rollbacks, store } = createLocalStore(this.subscriptions, shardKey);
|
|
1549
2394
|
try {
|
|
1550
2395
|
optimisticUpdate(store, args);
|
|
1551
2396
|
} catch {
|
|
@@ -1555,6 +2400,7 @@ class LunoraClient {
|
|
|
1555
2400
|
return;
|
|
1556
2401
|
}
|
|
1557
2402
|
optimisticRollbacks.push(...rollbacks);
|
|
2403
|
+
optimisticConfirms.push(...confirms);
|
|
1558
2404
|
}
|
|
1559
2405
|
getConnection(shardKey) {
|
|
1560
2406
|
return this.connections.get(connectionKey(shardKey));
|
|
@@ -1564,6 +2410,7 @@ class LunoraClient {
|
|
|
1564
2410
|
let conn = this.connections.get(key);
|
|
1565
2411
|
if (!conn) {
|
|
1566
2412
|
conn = {
|
|
2413
|
+
connectTimer: void 0,
|
|
1567
2414
|
heartbeatTimer: void 0,
|
|
1568
2415
|
pendingUnsubscribes: [],
|
|
1569
2416
|
reconnect: createReconnect(this.reconnectOptions),
|
|
@@ -1607,6 +2454,12 @@ class LunoraClient {
|
|
|
1607
2454
|
if (flags.mutationId) {
|
|
1608
2455
|
headers["x-lunora-mutation-id"] = flags.mutationId;
|
|
1609
2456
|
}
|
|
2457
|
+
if (flags.clientId !== void 0) {
|
|
2458
|
+
headers["x-lunora-client-id"] = flags.clientId;
|
|
2459
|
+
}
|
|
2460
|
+
if (flags.clientSeq !== void 0) {
|
|
2461
|
+
headers["x-lunora-client-seq"] = flags.clientSeq.toString();
|
|
2462
|
+
}
|
|
1610
2463
|
if (flags.attachBookmark) {
|
|
1611
2464
|
const bookmark = this.bookmark.get();
|
|
1612
2465
|
if (bookmark) {
|
|
@@ -1617,11 +2470,14 @@ class LunoraClient {
|
|
|
1617
2470
|
}
|
|
1618
2471
|
async rpc(functionPath, args, shardKey, flags = {}) {
|
|
1619
2472
|
if (!this.fetchImpl) {
|
|
1620
|
-
throw new
|
|
2473
|
+
throw new LunoraError("INTERNAL", "LunoraClient: no `fetch` implementation available");
|
|
1621
2474
|
}
|
|
1622
2475
|
const headers = this.rpcRequestHeaders(flags);
|
|
1623
2476
|
const response = await this.fetchImpl(joinUrl(this.url, RPC_PATH), {
|
|
1624
|
-
|
|
2477
|
+
// `encodeWire` tags leaves plain JSON can't carry (`bigint`,
|
|
2478
|
+
// `ArrayBuffer`/typed arrays, `NaN`/±Infinity); a pure-JSON `args`
|
|
2479
|
+
// encodes byte-identically, so a pre-codec server still interops.
|
|
2480
|
+
body: JSON.stringify({ args: encodeCallArgs(args, `args for '${functionPath}'`), functionPath, shardKey }),
|
|
1625
2481
|
headers,
|
|
1626
2482
|
method: "POST"
|
|
1627
2483
|
});
|
|
@@ -1636,18 +2492,18 @@ class LunoraClient {
|
|
|
1636
2492
|
body = await response.json();
|
|
1637
2493
|
} catch {
|
|
1638
2494
|
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
1639
|
-
throw new
|
|
2495
|
+
throw new LunoraError("INTERNAL", `LunoraClient: response was not JSON (status ${response.status.toString()}${statusText})`);
|
|
1640
2496
|
}
|
|
1641
2497
|
if ("error" in body) {
|
|
1642
|
-
|
|
1643
|
-
error.code = body.error.code;
|
|
1644
|
-
throw error;
|
|
2498
|
+
throw reconstructError(body.error);
|
|
1645
2499
|
}
|
|
1646
2500
|
if (!response.ok) {
|
|
1647
2501
|
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
1648
|
-
throw new
|
|
2502
|
+
throw new LunoraError("INTERNAL", `LunoraClient: request failed (status ${response.status.toString()}${statusText})`);
|
|
1649
2503
|
}
|
|
1650
|
-
|
|
2504
|
+
flags.onMutationAck?.(body.lastMutationId);
|
|
2505
|
+
flags.onCommitCursor?.(body.commitCursor);
|
|
2506
|
+
return decodeWire(body.result);
|
|
1651
2507
|
}
|
|
1652
2508
|
/**
|
|
1653
2509
|
* Authenticated request to a non-RPC admin endpoint (the scheduler list /
|
|
@@ -1657,7 +2513,7 @@ class LunoraClient {
|
|
|
1657
2513
|
*/
|
|
1658
2514
|
async adminFetch(path, method, payload, contentType) {
|
|
1659
2515
|
if (!this.fetchImpl) {
|
|
1660
|
-
throw new
|
|
2516
|
+
throw new LunoraError("INTERNAL", "LunoraClient: no `fetch` implementation available");
|
|
1661
2517
|
}
|
|
1662
2518
|
const headers = {};
|
|
1663
2519
|
if (this.authToken) {
|
|
@@ -1686,7 +2542,7 @@ class LunoraClient {
|
|
|
1686
2542
|
body = await response.json();
|
|
1687
2543
|
} catch {
|
|
1688
2544
|
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
1689
|
-
throw new
|
|
2545
|
+
throw new LunoraError("INTERNAL", `LunoraClient: response was not JSON (status ${response.status.toString()}${statusText})`);
|
|
1690
2546
|
}
|
|
1691
2547
|
if (typeof body === "object" && body !== null && "error" in body) {
|
|
1692
2548
|
const envelope = body.error;
|
|
@@ -1696,7 +2552,7 @@ class LunoraClient {
|
|
|
1696
2552
|
}
|
|
1697
2553
|
if (!response.ok) {
|
|
1698
2554
|
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
1699
|
-
throw new
|
|
2555
|
+
throw new LunoraError("INTERNAL", `LunoraClient: admin request failed (status ${response.status.toString()}${statusText})`);
|
|
1700
2556
|
}
|
|
1701
2557
|
return body;
|
|
1702
2558
|
}
|
|
@@ -1737,11 +2593,27 @@ class LunoraClient {
|
|
|
1737
2593
|
sendConnectEnvelope(conn) {
|
|
1738
2594
|
const context = this.effectiveConnectionContext(connectionKey(conn.shardKey));
|
|
1739
2595
|
sendOn(conn, {
|
|
2596
|
+
// Lets the server scope this connection's `__client_watermark` so
|
|
2597
|
+
// custom-mutator pokes can echo this client's `lastMutationId`.
|
|
2598
|
+
clientId: this.clientId,
|
|
1740
2599
|
id: "connect",
|
|
1741
2600
|
type: "connect",
|
|
1742
2601
|
...context === void 0 ? {} : { context }
|
|
1743
2602
|
});
|
|
1744
2603
|
}
|
|
2604
|
+
/**
|
|
2605
|
+
* Re-send every shape subscription bound to `shardKey` over its (now open)
|
|
2606
|
+
* socket. Each frame carries the shape's last applied checkpoint, so the
|
|
2607
|
+
* server resumes from it — or re-seeds when the cursor fell below CDC
|
|
2608
|
+
* retention or the epoch forked.
|
|
2609
|
+
*/
|
|
2610
|
+
resendShapeSubscriptions(shardKey) {
|
|
2611
|
+
for (const state of this.shapeSubscriptions.values()) {
|
|
2612
|
+
if (connectionKey(state.shardKey) === connectionKey(shardKey)) {
|
|
2613
|
+
this.sendShapeSubscribeIfOpen(state);
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
1745
2617
|
ensureSocket(shardKey) {
|
|
1746
2618
|
if (this.closed || this.WebSocketImpl === void 0) {
|
|
1747
2619
|
return;
|
|
@@ -1754,7 +2626,27 @@ class LunoraClient {
|
|
|
1754
2626
|
this.emitConnectionStatus();
|
|
1755
2627
|
const socket = new this.WebSocketImpl(this.wsUrlFor(shardKey));
|
|
1756
2628
|
conn.socket = socket;
|
|
2629
|
+
if (this.connectTimeoutMs > 0) {
|
|
2630
|
+
conn.connectTimer = setTimeout(() => {
|
|
2631
|
+
conn.connectTimer = void 0;
|
|
2632
|
+
if (conn.socket !== socket || conn.wsState !== "connecting") {
|
|
2633
|
+
return;
|
|
2634
|
+
}
|
|
2635
|
+
try {
|
|
2636
|
+
socket.close();
|
|
2637
|
+
} catch {
|
|
2638
|
+
}
|
|
2639
|
+
this.handleDisconnect(conn);
|
|
2640
|
+
}, this.connectTimeoutMs);
|
|
2641
|
+
}
|
|
1757
2642
|
socket.addEventListener("open", () => {
|
|
2643
|
+
if (conn.socket !== socket) {
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
if (conn.connectTimer !== void 0) {
|
|
2647
|
+
clearTimeout(conn.connectTimer);
|
|
2648
|
+
conn.connectTimer = void 0;
|
|
2649
|
+
}
|
|
1758
2650
|
conn.wsState = "open";
|
|
1759
2651
|
conn.wasEverConnected = true;
|
|
1760
2652
|
conn.reconnect.reset();
|
|
@@ -1766,11 +2658,12 @@ class LunoraClient {
|
|
|
1766
2658
|
this.sendSubscribeIfOpen(state);
|
|
1767
2659
|
}
|
|
1768
2660
|
}
|
|
2661
|
+
this.resendShapeSubscriptions(shardKey);
|
|
1769
2662
|
if (conn.pendingUnsubscribes.length > 0) {
|
|
1770
2663
|
const pending = conn.pendingUnsubscribes;
|
|
1771
2664
|
conn.pendingUnsubscribes = [];
|
|
1772
|
-
for (const id of pending) {
|
|
1773
|
-
sendOn(conn, { id, type
|
|
2665
|
+
for (const { id, type } of pending) {
|
|
2666
|
+
sendOn(conn, { id, type });
|
|
1774
2667
|
}
|
|
1775
2668
|
}
|
|
1776
2669
|
if (conn.pendingStreams && conn.pendingStreams.length > 0) {
|
|
@@ -1793,12 +2686,18 @@ class LunoraClient {
|
|
|
1793
2686
|
this.handleServerMessage(event.data, shardKey);
|
|
1794
2687
|
});
|
|
1795
2688
|
socket.addEventListener("close", (event) => {
|
|
2689
|
+
if (conn.socket !== socket) {
|
|
2690
|
+
return;
|
|
2691
|
+
}
|
|
1796
2692
|
if (event?.code === 4001) {
|
|
1797
2693
|
this.notifyTokenExpired();
|
|
1798
2694
|
}
|
|
1799
2695
|
this.handleDisconnect(conn);
|
|
1800
2696
|
});
|
|
1801
2697
|
socket.addEventListener("error", () => {
|
|
2698
|
+
if (conn.socket !== socket) {
|
|
2699
|
+
return;
|
|
2700
|
+
}
|
|
1802
2701
|
if (conn.wsState === "connecting" || conn.wsState === "open") {
|
|
1803
2702
|
this.handleDisconnect(conn);
|
|
1804
2703
|
}
|
|
@@ -1812,10 +2711,21 @@ class LunoraClient {
|
|
|
1812
2711
|
return;
|
|
1813
2712
|
}
|
|
1814
2713
|
this.stopHeartbeat(conn);
|
|
2714
|
+
if (conn.connectTimer !== void 0) {
|
|
2715
|
+
clearTimeout(conn.connectTimer);
|
|
2716
|
+
conn.connectTimer = void 0;
|
|
2717
|
+
}
|
|
1815
2718
|
conn.socket = void 0;
|
|
1816
2719
|
conn.wsState = "idle";
|
|
1817
2720
|
this.emitConnectionStatus();
|
|
1818
2721
|
this.markShardPendingAck(conn.shardKey);
|
|
2722
|
+
const pendingStreamIds = new Set((conn.pendingStreams ?? []).map((message) => message.id));
|
|
2723
|
+
for (const [id, stream] of this.streams) {
|
|
2724
|
+
if (connectionKey(stream.shardKey) === connectionKey(conn.shardKey) && !pendingStreamIds.has(id)) {
|
|
2725
|
+
stream.handle.fail(new LunoraError("STREAM_DISCONNECTED", "stream terminated: WebSocket disconnected"));
|
|
2726
|
+
this.streams.delete(id);
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
1819
2729
|
if (this.WebSocketImpl === void 0) {
|
|
1820
2730
|
return;
|
|
1821
2731
|
}
|
|
@@ -1885,6 +2795,21 @@ class LunoraClient {
|
|
|
1885
2795
|
type: "subscribe"
|
|
1886
2796
|
});
|
|
1887
2797
|
}
|
|
2798
|
+
sendShapeSubscribeIfOpen(state) {
|
|
2799
|
+
const conn = this.getConnection(state.shardKey);
|
|
2800
|
+
if (conn?.wsState !== "open") {
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
sendOn(conn, {
|
|
2804
|
+
id: state.id,
|
|
2805
|
+
shape: { name: state.name, ...state.args === void 0 ? {} : { args: state.args } },
|
|
2806
|
+
type: "shape_subscribe",
|
|
2807
|
+
// Resume from the last applied checkpoint when we hold one; a cold
|
|
2808
|
+
// subscribe omits it and the server seeds the full membership.
|
|
2809
|
+
...state.serverCursor === void 0 ? {} : { sinceCheckpoint: state.serverCursor },
|
|
2810
|
+
...state.serverEpoch === void 0 ? {} : { sinceEpoch: state.serverEpoch }
|
|
2811
|
+
});
|
|
2812
|
+
}
|
|
1888
2813
|
handleServerMessage(raw, shardKey) {
|
|
1889
2814
|
const text = decodeServerFrame(raw);
|
|
1890
2815
|
if (text === void 0) {
|
|
@@ -1907,7 +2832,7 @@ class LunoraClient {
|
|
|
1907
2832
|
case "chunk": {
|
|
1908
2833
|
const { data, id } = message;
|
|
1909
2834
|
const stream = this.streams.get(id);
|
|
1910
|
-
stream?.handle.push(data);
|
|
2835
|
+
stream?.handle.push(decodeWire(data));
|
|
1911
2836
|
return;
|
|
1912
2837
|
}
|
|
1913
2838
|
case "complete": {
|
|
@@ -1923,10 +2848,26 @@ class LunoraClient {
|
|
|
1923
2848
|
this.handleErrorMessage(message);
|
|
1924
2849
|
break;
|
|
1925
2850
|
}
|
|
2851
|
+
case "pokeEnd": {
|
|
2852
|
+
this.handlePokeEnd(message);
|
|
2853
|
+
break;
|
|
2854
|
+
}
|
|
2855
|
+
case "pokePart": {
|
|
2856
|
+
this.handlePokePart(message);
|
|
2857
|
+
break;
|
|
2858
|
+
}
|
|
2859
|
+
case "pokeStart": {
|
|
2860
|
+
this.handlePokeStart(message);
|
|
2861
|
+
break;
|
|
2862
|
+
}
|
|
1926
2863
|
case "resume": {
|
|
1927
2864
|
this.handleResumeMessage(message);
|
|
1928
2865
|
break;
|
|
1929
2866
|
}
|
|
2867
|
+
case "settled": {
|
|
2868
|
+
this.handleSettledMessage(message);
|
|
2869
|
+
break;
|
|
2870
|
+
}
|
|
1930
2871
|
case "whisper": {
|
|
1931
2872
|
this.dispatchWhisper(message, shardKey);
|
|
1932
2873
|
break;
|
|
@@ -1948,12 +2889,76 @@ class LunoraClient {
|
|
|
1948
2889
|
}
|
|
1949
2890
|
const state = id === void 0 ? void 0 : this.subscriptions.getById(id);
|
|
1950
2891
|
if (state) {
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
2892
|
+
fanSubscriptionError(state.errorCallbacks, buildSubscriptionError(message));
|
|
2893
|
+
return;
|
|
2894
|
+
}
|
|
2895
|
+
const shapeState = id === void 0 ? void 0 : this.shapeSubscriptions.get(id);
|
|
2896
|
+
if (shapeState) {
|
|
2897
|
+
fanSubscriptionError(shapeState.errorCallbacks, buildSubscriptionError(message));
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
handlePokeStart(message) {
|
|
2901
|
+
evictOldestEntry(this.pokeBuffers, LunoraClient.MAX_POKE_BUFFERS);
|
|
2902
|
+
this.pokeBuffers.set(message.pokeId, { baseCheckpoint: message.baseCheckpoint, epoch: message.epoch, lastMutationId: /* @__PURE__ */ new Map(), parts: /* @__PURE__ */ new Map() });
|
|
2903
|
+
}
|
|
2904
|
+
handlePokePart(message) {
|
|
2905
|
+
const buffer = this.pokeBuffers.get(message.pokeId);
|
|
2906
|
+
if (!buffer) {
|
|
2907
|
+
return;
|
|
2908
|
+
}
|
|
2909
|
+
const existing = buffer.parts.get(message.shapeId) ?? [];
|
|
2910
|
+
for (const op of message.rowsPatch) {
|
|
2911
|
+
existing.push(op.value === void 0 ? op : { ...op, value: decodeWire(op.value) });
|
|
2912
|
+
}
|
|
2913
|
+
buffer.parts.set(message.shapeId, existing);
|
|
2914
|
+
if (message.lastMutationId !== void 0) {
|
|
2915
|
+
buffer.lastMutationId.set(message.shapeId, message.lastMutationId);
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
handlePokeEnd(message) {
|
|
2919
|
+
const buffer = this.pokeBuffers.get(message.pokeId);
|
|
2920
|
+
if (!buffer) {
|
|
2921
|
+
return;
|
|
2922
|
+
}
|
|
2923
|
+
this.pokeBuffers.delete(message.pokeId);
|
|
2924
|
+
for (const [shapeId, ops] of buffer.parts) {
|
|
2925
|
+
const state = this.shapeSubscriptions.get(shapeId);
|
|
2926
|
+
if (!state) {
|
|
2927
|
+
continue;
|
|
2928
|
+
}
|
|
2929
|
+
const epochForked = buffer.epoch !== void 0 && state.serverEpoch !== void 0 && buffer.epoch !== state.serverEpoch;
|
|
2930
|
+
const baseDiverged = buffer.baseCheckpoint !== void 0 && state.serverCursor !== void 0 && state.serverCursor !== buffer.baseCheckpoint;
|
|
2931
|
+
if (epochForked || baseDiverged) {
|
|
2932
|
+
state.rows.clear();
|
|
2933
|
+
state.serverCursor = void 0;
|
|
2934
|
+
state.serverEpoch = void 0;
|
|
2935
|
+
this.emitShapeRows(state);
|
|
2936
|
+
this.sendShapeSubscribeIfOpen(state);
|
|
2937
|
+
continue;
|
|
2938
|
+
}
|
|
2939
|
+
applyRowOpsToView(state.rows, ops);
|
|
2940
|
+
if (message.checkpoint !== void 0) {
|
|
2941
|
+
state.serverCursor = message.checkpoint;
|
|
2942
|
+
}
|
|
2943
|
+
if (message.epoch !== void 0) {
|
|
2944
|
+
state.serverEpoch = message.epoch;
|
|
2945
|
+
}
|
|
2946
|
+
const watermark = buffer.lastMutationId.get(shapeId);
|
|
2947
|
+
if (watermark !== void 0) {
|
|
2948
|
+
state.lastMutationId = watermark;
|
|
2949
|
+
}
|
|
2950
|
+
this.emitShapeRows(state);
|
|
2951
|
+
state.onCheckpoint?.({ checkpoint: state.serverCursor, mutationId: state.lastMutationId });
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
/** Materialize a shape's keyed view to an array and invoke its callbacks. */
|
|
2955
|
+
// eslint-disable-next-line class-methods-use-this -- a pure state→callback fan-out kept beside the shape-subscription pipeline it serves.
|
|
2956
|
+
emitShapeRows(state) {
|
|
2957
|
+
const rows = [...state.rows.values()];
|
|
2958
|
+
for (const shapeCallback of state.callbacks) {
|
|
2959
|
+
try {
|
|
2960
|
+
shapeCallback(rows);
|
|
2961
|
+
} catch {
|
|
1957
2962
|
}
|
|
1958
2963
|
}
|
|
1959
2964
|
}
|
|
@@ -1964,8 +2969,7 @@ class LunoraClient {
|
|
|
1964
2969
|
return;
|
|
1965
2970
|
}
|
|
1966
2971
|
const payload = this.resolveDataPayload(message, state);
|
|
1967
|
-
state.
|
|
1968
|
-
state.serverVersion += 1;
|
|
2972
|
+
state.serverBase = payload;
|
|
1969
2973
|
if (message.cursor !== void 0) {
|
|
1970
2974
|
state.serverCursor = message.cursor;
|
|
1971
2975
|
}
|
|
@@ -1973,12 +2977,8 @@ class LunoraClient {
|
|
|
1973
2977
|
state.serverEpoch = message.epoch;
|
|
1974
2978
|
}
|
|
1975
2979
|
this.persistQueryValue(state);
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
callback(payload);
|
|
1979
|
-
} catch {
|
|
1980
|
-
}
|
|
1981
|
-
}
|
|
2980
|
+
dropConfirmedLayers(state, state.serverCursor);
|
|
2981
|
+
notifySubscription(state, state.optimisticLayers.length === 0 ? payload : foldOptimistic(payload, state.optimisticLayers));
|
|
1982
2982
|
}
|
|
1983
2983
|
/**
|
|
1984
2984
|
* Handle a `resume` frame (Pillar 1b): the server proved nothing the
|
|
@@ -1993,15 +2993,51 @@ class LunoraClient {
|
|
|
1993
2993
|
if (!state) {
|
|
1994
2994
|
return;
|
|
1995
2995
|
}
|
|
2996
|
+
this.ackAndAdvanceCursor(state, message.cursor, message.epoch);
|
|
2997
|
+
}
|
|
2998
|
+
/**
|
|
2999
|
+
* Handle a `settled` frame: a write touched one of this subscription's read
|
|
3000
|
+
* tables but produced a byte-identical result, so the server suppressed the
|
|
3001
|
+
* data frame. Like {@link handleResumeMessage} the value didn't change — we
|
|
3002
|
+
* advance the resume position and re-persist — but we ALSO surface the echoed
|
|
3003
|
+
* custom-mutator watermark via `onCheckpoint` so a `@lunora/db` list
|
|
3004
|
+
* collection drops the optimistic overlay for the confirmed write (otherwise
|
|
3005
|
+
* its checkpoint gate, fed only by data frames, would hang forever). Sent
|
|
3006
|
+
* only to custom-mutator clients; plain `useQuery` subscribers leave
|
|
3007
|
+
* `onCheckpoint` unset and this is a near no-op.
|
|
3008
|
+
*/
|
|
3009
|
+
handleSettledMessage(message) {
|
|
3010
|
+
const state = this.subscriptions.getById(message.id);
|
|
3011
|
+
if (!state) {
|
|
3012
|
+
return;
|
|
3013
|
+
}
|
|
3014
|
+
this.ackAndAdvanceCursor(state, message.cursor, message.epoch);
|
|
3015
|
+
if (message.lastMutationId !== void 0) {
|
|
3016
|
+
state.lastMutationId = message.lastMutationId;
|
|
3017
|
+
}
|
|
3018
|
+
for (const onCheckpoint of state.checkpointCallbacks) {
|
|
3019
|
+
onCheckpoint({ checkpoint: state.serverCursor, mutationId: state.lastMutationId });
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
/**
|
|
3023
|
+
* Mark `state` acked and, when the frame carries a newer cursor/epoch than
|
|
3024
|
+
* the cached position, advance the resume watermark and re-persist. Shared by
|
|
3025
|
+
* the `resume` and `settled` frame handlers — both acknowledge "nothing the
|
|
3026
|
+
* client must re-render changed, but the resume position may have moved".
|
|
3027
|
+
*/
|
|
3028
|
+
ackAndAdvanceCursor(state, cursor, epoch) {
|
|
1996
3029
|
state.acked = true;
|
|
1997
|
-
if (
|
|
1998
|
-
if (
|
|
1999
|
-
state.serverCursor =
|
|
3030
|
+
if (cursor !== void 0 && cursor !== state.serverCursor || epoch !== void 0 && epoch !== state.serverEpoch) {
|
|
3031
|
+
if (cursor !== void 0) {
|
|
3032
|
+
state.serverCursor = cursor;
|
|
2000
3033
|
}
|
|
2001
|
-
if (
|
|
2002
|
-
state.serverEpoch =
|
|
3034
|
+
if (epoch !== void 0) {
|
|
3035
|
+
state.serverEpoch = epoch;
|
|
2003
3036
|
}
|
|
2004
3037
|
this.persistQueryValue(state);
|
|
3038
|
+
if (dropConfirmedLayers(state, state.serverCursor)) {
|
|
3039
|
+
notifySubscription(state, foldOptimistic(state.serverBase, state.optimisticLayers));
|
|
3040
|
+
}
|
|
2005
3041
|
}
|
|
2006
3042
|
}
|
|
2007
3043
|
/**
|
|
@@ -2019,11 +3055,11 @@ class LunoraClient {
|
|
|
2019
3055
|
// eslint-disable-next-line class-methods-use-this -- instance method for symmetry with the other message handlers; reads no shared client state
|
|
2020
3056
|
resolveDataPayload(message, state) {
|
|
2021
3057
|
if ("data" in message && message.data !== void 0) {
|
|
2022
|
-
return message.data;
|
|
3058
|
+
return decodeWire(message.data);
|
|
2023
3059
|
}
|
|
2024
|
-
const
|
|
2025
|
-
if (isMutationDelta(delta) && state.
|
|
2026
|
-
const merged = applyDelta(state.
|
|
3060
|
+
const delta = decodeWire(message.delta);
|
|
3061
|
+
if (isMutationDelta(delta) && state.serverBase !== void 0) {
|
|
3062
|
+
const merged = applyDelta(state.serverBase, delta);
|
|
2027
3063
|
if (merged !== void 0) {
|
|
2028
3064
|
return merged;
|
|
2029
3065
|
}
|
|
@@ -2036,21 +3072,17 @@ class LunoraClient {
|
|
|
2036
3072
|
if (!handlers) {
|
|
2037
3073
|
return;
|
|
2038
3074
|
}
|
|
3075
|
+
const data = decodeWire(message.data);
|
|
2039
3076
|
for (const handler of handlers) {
|
|
2040
3077
|
try {
|
|
2041
|
-
handler(
|
|
3078
|
+
handler(data, message.from);
|
|
2042
3079
|
} catch {
|
|
2043
3080
|
}
|
|
2044
3081
|
}
|
|
2045
3082
|
}
|
|
2046
3083
|
/** Notify every {@link onTokenExpired} listener (best-effort, listener throws swallowed). */
|
|
2047
3084
|
notifyTokenExpired() {
|
|
2048
|
-
|
|
2049
|
-
try {
|
|
2050
|
-
listener();
|
|
2051
|
-
} catch {
|
|
2052
|
-
}
|
|
2053
|
-
}
|
|
3085
|
+
this.tokenExpiredListeners.emit();
|
|
2054
3086
|
}
|
|
2055
3087
|
handleCompleteMessage(id) {
|
|
2056
3088
|
const stream = this.streams.get(id);
|
|
@@ -2081,16 +3113,59 @@ class LunoraClient {
|
|
|
2081
3113
|
// `null` is the distinct "signed out" identity (separate from `undefined`,
|
|
2082
3114
|
// which means "not stamped / hydrated"); the two must not be conflated.
|
|
2083
3115
|
identityFingerprint() {
|
|
3116
|
+
if (this.authSubject !== void 0) {
|
|
3117
|
+
return this.authSubject === null ? null : `subj:${this.authSubject}`;
|
|
3118
|
+
}
|
|
2084
3119
|
const token = this.authToken;
|
|
2085
3120
|
if (token === null) {
|
|
2086
3121
|
return null;
|
|
2087
3122
|
}
|
|
2088
|
-
|
|
3123
|
+
return this.hashToken(token);
|
|
3124
|
+
}
|
|
3125
|
+
/**
|
|
3126
|
+
* Stable token-hash fingerprint of a bearer token (the `<len>:<fnv>:<djb2>`
|
|
3127
|
+
* format a token-stamped queued write carries). Extracted so the replay gate
|
|
3128
|
+
* can recompute the hash of the current credential and recognise a write
|
|
3129
|
+
* stamped under it — even after the fingerprint was relabelled to a subject.
|
|
3130
|
+
*
|
|
3131
|
+
* Two independent 32-bit passes (FNV-1a + djb2) give a ~64-bit digest, so
|
|
3132
|
+
* two distinct equal-length tokens are astronomically unlikely to share a
|
|
3133
|
+
* fingerprint. A single 32-bit hash collides ~1-in-4e9 per equal-length
|
|
3134
|
+
* pair — enough that, on a shared device, user B could hydrate A's cached
|
|
3135
|
+
* reads. Different algorithms (not the same FNV with a different seed, which
|
|
3136
|
+
* would be affine-related) keep the two passes genuinely independent.
|
|
3137
|
+
* Still synchronous (no crypto) and stable across surrogate pairs.
|
|
3138
|
+
*/
|
|
3139
|
+
// eslint-disable-next-line class-methods-use-this -- pure helper; a method for locality with identityFingerprint, reads no shared state
|
|
3140
|
+
hashToken(token) {
|
|
3141
|
+
let fnv = 2166136261;
|
|
3142
|
+
let djb2 = 5381;
|
|
2089
3143
|
for (let index = 0; index < token.length; index += 1) {
|
|
2090
|
-
|
|
2091
|
-
|
|
3144
|
+
const code = token.charCodeAt(index);
|
|
3145
|
+
fnv ^= code;
|
|
3146
|
+
fnv = Math.imul(fnv, 16777619);
|
|
3147
|
+
djb2 = Math.imul(djb2, 33) + code;
|
|
3148
|
+
}
|
|
3149
|
+
return `${token.length.toString(36)}:${(fnv >>> 0).toString(36)}:${(djb2 >>> 0).toString(36)}`;
|
|
3150
|
+
}
|
|
3151
|
+
/**
|
|
3152
|
+
* True when `stamped` is a token-hash of the SAME credential still held now,
|
|
3153
|
+
* even though the live identity has since been relabelled to a subject. Covers
|
|
3154
|
+
* `setAuthToken(token, userId)` where the subject resolved a tick after the
|
|
3155
|
+
* token was set: a write persisted (or requeued) under the token hash must
|
|
3156
|
+
* still replay — the credential never changed, only its label — instead of
|
|
3157
|
+
* being dropped as an identity mismatch. This is the durable counterpart to
|
|
3158
|
+
* {@link restampQueuedIdentity}, which only relabels the in-memory live stamp
|
|
3159
|
+
* (consumed on the first flush) and never touches `item.identity` or the
|
|
3160
|
+
* persisted record, so a reload or a transient-failure requeue would otherwise
|
|
3161
|
+
* fall back to the stale token-hash and wrongly reject the same user's write.
|
|
3162
|
+
*/
|
|
3163
|
+
isSameCredentialUnderTokenHash(stamped) {
|
|
3164
|
+
if (stamped === null || stamped.startsWith("subj:")) {
|
|
3165
|
+
return false;
|
|
2092
3166
|
}
|
|
2093
|
-
|
|
3167
|
+
const token = this.authToken;
|
|
3168
|
+
return token === null ? false : this.hashToken(token) === stamped;
|
|
2094
3169
|
}
|
|
2095
3170
|
/**
|
|
2096
3171
|
* Drain every in-memory offline write and reject it because the auth
|
|
@@ -2107,9 +3182,25 @@ class LunoraClient {
|
|
|
2107
3182
|
const error = new Error("offline mutation discarded: auth identity changed before replay");
|
|
2108
3183
|
error.code = "OFFLINE_IDENTITY_CHANGED";
|
|
2109
3184
|
item.reject(error);
|
|
3185
|
+
this.emitItemSettled(item, "rejected", error);
|
|
2110
3186
|
}
|
|
2111
3187
|
this.clearQueryCacheForIdentityChange();
|
|
2112
3188
|
}
|
|
3189
|
+
/**
|
|
3190
|
+
* Migrate every live identity stamp from `from` to `to` — used when the auth
|
|
3191
|
+
* identity label changes but the underlying credential (token) does NOT, e.g.
|
|
3192
|
+
* the user id resolves a tick after the token was set. The in-memory
|
|
3193
|
+
* `queuedIdentities` map is the flush-time source of truth, so re-stamping it
|
|
3194
|
+
* keeps the in-flight writes replayable under the new (more stable) identity
|
|
3195
|
+
* instead of the flush guard discarding them as a mismatch.
|
|
3196
|
+
*/
|
|
3197
|
+
restampQueuedIdentity(from, to) {
|
|
3198
|
+
for (const [id, stamp] of this.queuedIdentities) {
|
|
3199
|
+
if (stamp === from) {
|
|
3200
|
+
this.queuedIdentities.set(id, to);
|
|
3201
|
+
}
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
2113
3204
|
/**
|
|
2114
3205
|
* Drop the durable read cache on an identity change so a cached value stamped
|
|
2115
3206
|
* under the previous identity can never hydrate into a new session. Clears
|
|
@@ -2128,38 +3219,242 @@ class LunoraClient {
|
|
|
2128
3219
|
async flushOfflineQueue(shardKey) {
|
|
2129
3220
|
const key = connectionKey(shardKey);
|
|
2130
3221
|
const drained = this.offlineQueue.drain((item) => connectionKey(item.shardKey) === key);
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
3222
|
+
if (drained.length === 0) {
|
|
3223
|
+
return;
|
|
3224
|
+
}
|
|
3225
|
+
const currentIdentity = this.identityFingerprint();
|
|
3226
|
+
const sendable = [];
|
|
3227
|
+
for (const item of drained) {
|
|
3228
|
+
if (this.passesReplayIdentityGate(item, currentIdentity)) {
|
|
3229
|
+
sendable.push(item);
|
|
2135
3230
|
}
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
3231
|
+
}
|
|
3232
|
+
if (sendable.length === 0) {
|
|
3233
|
+
return;
|
|
3234
|
+
}
|
|
3235
|
+
const encodable = this.encodableOrSettleTerminal(sendable);
|
|
3236
|
+
if (encodable.length === 0) {
|
|
3237
|
+
return;
|
|
3238
|
+
}
|
|
3239
|
+
if (encodable.length === 1) {
|
|
3240
|
+
await this.replaySequential(encodable);
|
|
3241
|
+
return;
|
|
3242
|
+
}
|
|
3243
|
+
const toRequeue = [];
|
|
3244
|
+
for (let start = 0; start < encodable.length; start += MAX_BATCH_ENTRIES) {
|
|
3245
|
+
const chunk = encodable.slice(start, start + MAX_BATCH_ENTRIES);
|
|
3246
|
+
const outcome = await this.replayBatched(chunk);
|
|
3247
|
+
toRequeue.push(...outcome.requeue);
|
|
3248
|
+
if (outcome.stop) {
|
|
3249
|
+
toRequeue.push(...encodable.slice(start + MAX_BATCH_ENTRIES));
|
|
3250
|
+
break;
|
|
2146
3251
|
}
|
|
3252
|
+
}
|
|
3253
|
+
if (toRequeue.length > 0) {
|
|
3254
|
+
this.offlineQueue.requeue(toRequeue);
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
/**
|
|
3258
|
+
* Partition already-gated writes into the encodable ones (returned) and reject
|
|
3259
|
+
* the rest terminally. A write whose args can't be wire-encoded (e.g. a RegExp
|
|
3260
|
+
* or class instance in a `v.any()` field) can NEVER replay — the codec failure
|
|
3261
|
+
* is deterministic, not transient. Rejecting here is essential: otherwise
|
|
3262
|
+
* `encodeWire` throws mid-flush, is classified as transient (a codec error has
|
|
3263
|
+
* no `.code`), and re-queues forever — a silent hang where the caller's Promise
|
|
3264
|
+
* never settles and the optimistic write never rolls back. Encoding is cheap;
|
|
3265
|
+
* the flush is the slow reconnect path.
|
|
3266
|
+
*/
|
|
3267
|
+
encodableOrSettleTerminal(items) {
|
|
3268
|
+
const encodable = [];
|
|
3269
|
+
for (const item of items) {
|
|
3270
|
+
try {
|
|
3271
|
+
encodeCallArgs(item.args, `args for '${item.functionPath}'`);
|
|
3272
|
+
encodable.push(item);
|
|
3273
|
+
} catch (error) {
|
|
3274
|
+
this.settleReplayTerminal(item, error instanceof Error ? error : new Error(String(error)));
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3277
|
+
return encodable;
|
|
3278
|
+
}
|
|
3279
|
+
/**
|
|
3280
|
+
* Identity guard for one queued write about to replay: a write stamped under
|
|
3281
|
+
* one identity must never replay under another. The live `queuedIdentities`
|
|
3282
|
+
* map is the source of truth for the current session; a hydrated write whose
|
|
3283
|
+
* id isn't in the map falls back to the stamp persisted with the record
|
|
3284
|
+
* (`item.identity`), so a reload can't replay another user's queued writes.
|
|
3285
|
+
* Only legacy records (persisted before stamps were durable —
|
|
3286
|
+
* `item.identity === undefined`) replay under whatever identity is current.
|
|
3287
|
+
*
|
|
3288
|
+
* `Map.get` returns `undefined` for unstamped/hydrated ids and `item.identity`
|
|
3289
|
+
* is `undefined` for legacy records; a persisted `null` (queued while signed
|
|
3290
|
+
* out) is a real value that must not collapse into `undefined` — hence the
|
|
3291
|
+
* explicit `=== undefined` check rather than `??`. Returns `true` when the
|
|
3292
|
+
* write may replay; otherwise settles it `OFFLINE_IDENTITY_CHANGED` and returns
|
|
3293
|
+
* `false`. Either way the live stamp is consumed.
|
|
3294
|
+
*/
|
|
3295
|
+
passesReplayIdentityGate(item, currentIdentity) {
|
|
3296
|
+
const liveStamp = item.id === void 0 ? void 0 : this.queuedIdentities.get(item.id);
|
|
3297
|
+
const stamped = liveStamp === void 0 ? item.identity : liveStamp;
|
|
3298
|
+
if (stamped !== void 0 && stamped !== currentIdentity && !this.isSameCredentialUnderTokenHash(stamped)) {
|
|
2147
3299
|
this.queuedIdentities.delete(item.id ?? "");
|
|
3300
|
+
this.unpersist(item.id);
|
|
3301
|
+
const error = new Error("offline mutation skipped: auth identity changed before replay");
|
|
3302
|
+
error.code = "OFFLINE_IDENTITY_CHANGED";
|
|
3303
|
+
item.reject(error);
|
|
3304
|
+
this.emitItemSettled(item, "rejected", error);
|
|
3305
|
+
return false;
|
|
3306
|
+
}
|
|
3307
|
+
this.queuedIdentities.delete(item.id ?? "");
|
|
3308
|
+
return true;
|
|
3309
|
+
}
|
|
3310
|
+
/** Settle a write that replayed successfully: confirm its optimistic layer against the echoed commit cursor BEFORE resolving, so the gapless drop is in place when the awaiter (and any confirming frame) observes the settle. */
|
|
3311
|
+
settleReplaySuccess(item, value, commitCursor) {
|
|
3312
|
+
this.unpersist(item.id);
|
|
3313
|
+
item.onCommit?.(commitCursor);
|
|
3314
|
+
item.resolve(value);
|
|
3315
|
+
this.emitItemSettled(item, "committed");
|
|
3316
|
+
}
|
|
3317
|
+
/** Settle a write the server reached a coded verdict on: replaying would re-trigger the same failure (a poison-message loop), so drop it. */
|
|
3318
|
+
settleReplayTerminal(item, error) {
|
|
3319
|
+
this.unpersist(item.id);
|
|
3320
|
+
item.reject(error);
|
|
3321
|
+
this.emitItemSettled(item, "rejected", error);
|
|
3322
|
+
}
|
|
3323
|
+
/**
|
|
3324
|
+
* Replay already-identity-gated writes one at a time on the single-call `/rpc`
|
|
3325
|
+
* path, preserving FIFO order (parallel `.then()` chains would race the
|
|
3326
|
+
* ordering callers depend on). Each replays under its stable `mutationId` so
|
|
3327
|
+
* the server dedups a write it already committed (exactly-once). A coded error
|
|
3328
|
+
* is a server verdict (drop it); a codeless (transport/transient) failure stops
|
|
3329
|
+
* the flush and re-queues this write and every unreplayed one for the next
|
|
3330
|
+
* reconnect — their callers stay pending, and the identity guard re-applies on
|
|
3331
|
+
* retry via each record's persisted stamp.
|
|
3332
|
+
*/
|
|
3333
|
+
async replaySequential(items) {
|
|
3334
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
3335
|
+
const item = items[index];
|
|
3336
|
+
if (!item) {
|
|
3337
|
+
continue;
|
|
3338
|
+
}
|
|
2148
3339
|
try {
|
|
2149
|
-
|
|
2150
|
-
this.
|
|
2151
|
-
|
|
3340
|
+
let commitCursor;
|
|
3341
|
+
const value = await this.rpc(item.functionPath, item.args, item.shardKey, {
|
|
3342
|
+
captureBookmark: true,
|
|
3343
|
+
mutationId: item.id,
|
|
3344
|
+
onCommitCursor: (cursor) => {
|
|
3345
|
+
commitCursor = cursor;
|
|
3346
|
+
}
|
|
3347
|
+
});
|
|
3348
|
+
this.settleReplaySuccess(item, value, commitCursor);
|
|
2152
3349
|
} catch (error) {
|
|
2153
3350
|
if (error.code !== void 0) {
|
|
2154
|
-
this.
|
|
2155
|
-
item.reject(error);
|
|
3351
|
+
this.settleReplayTerminal(item, error);
|
|
2156
3352
|
continue;
|
|
2157
3353
|
}
|
|
2158
|
-
this.offlineQueue.requeue(
|
|
3354
|
+
this.offlineQueue.requeue(items.slice(index));
|
|
2159
3355
|
return;
|
|
2160
3356
|
}
|
|
2161
3357
|
}
|
|
2162
3358
|
}
|
|
3359
|
+
/**
|
|
3360
|
+
* Coalesce already-identity-gated writes for a single shard into ONE
|
|
3361
|
+
* `/_lunora/rpc-batch` round trip (plan 088 follow-on). The worker forwards
|
|
3362
|
+
* them to the shard DO, which replays each through its single-call dispatch, so
|
|
3363
|
+
* per-entry `mutationId` idempotency and in-order application are inherited from
|
|
3364
|
+
* the proven path. Per-slot demux mirrors {@link replaySequential}'s
|
|
3365
|
+
* classification: success confirms the optimistic layer against the echoed
|
|
3366
|
+
* `commitCursor`; a coded application verdict is terminal; a transient shard
|
|
3367
|
+
* failure (`SHARD_UNAVAILABLE`/`SHARD_ERROR`), a missing slot, or a whole-batch
|
|
3368
|
+
* transport failure re-queues for the next reconnect (never dropping a durable
|
|
3369
|
+
* write). A whole-batch coded rejection (bad request / authorization denial the
|
|
3370
|
+
* server reached a verdict on) is terminal for every entry.
|
|
3371
|
+
*
|
|
3372
|
+
* Returns the writes that must be re-queued and `stop` — `true` when the whole
|
|
3373
|
+
* chunk failed at the transport level, so the caller leaves later chunks queued
|
|
3374
|
+
* rather than sending on. The caller re-queues once, in order, so requeuing is
|
|
3375
|
+
* NOT done here.
|
|
3376
|
+
*/
|
|
3377
|
+
async replayBatched(items) {
|
|
3378
|
+
if (!this.fetchImpl) {
|
|
3379
|
+
return { requeue: items, stop: true };
|
|
3380
|
+
}
|
|
3381
|
+
let response;
|
|
3382
|
+
try {
|
|
3383
|
+
response = await this.fetchImpl(joinUrl(this.url, RPC_BATCH_PATH), {
|
|
3384
|
+
body: JSON.stringify({
|
|
3385
|
+
calls: items.map((item, index) => {
|
|
3386
|
+
return {
|
|
3387
|
+
args: encodeCallArgs(item.args, `args for '${item.functionPath}'`),
|
|
3388
|
+
functionPath: item.functionPath,
|
|
3389
|
+
id: index,
|
|
3390
|
+
// Stable per-write key so the DO dedups a write it already
|
|
3391
|
+
// committed (exactly-once), exactly as the single-call replay.
|
|
3392
|
+
mutationId: item.id,
|
|
3393
|
+
shardKey: item.shardKey
|
|
3394
|
+
};
|
|
3395
|
+
})
|
|
3396
|
+
}),
|
|
3397
|
+
headers: this.rpcRequestHeaders({ attachBookmark: true }),
|
|
3398
|
+
method: "POST"
|
|
3399
|
+
});
|
|
3400
|
+
} catch {
|
|
3401
|
+
return { requeue: items, stop: true };
|
|
3402
|
+
}
|
|
3403
|
+
const bookmark = response.headers.get("x-d1-bookmark");
|
|
3404
|
+
if (bookmark) {
|
|
3405
|
+
this.bookmark.set(bookmark);
|
|
3406
|
+
}
|
|
3407
|
+
let body;
|
|
3408
|
+
try {
|
|
3409
|
+
body = await response.json();
|
|
3410
|
+
} catch {
|
|
3411
|
+
return { requeue: items, stop: true };
|
|
3412
|
+
}
|
|
3413
|
+
if (!body.results) {
|
|
3414
|
+
if (body.error) {
|
|
3415
|
+
const error = reconstructError(body.error);
|
|
3416
|
+
for (const item of items) {
|
|
3417
|
+
this.settleReplayTerminal(item, error);
|
|
3418
|
+
}
|
|
3419
|
+
return { requeue: [], stop: false };
|
|
3420
|
+
}
|
|
3421
|
+
return { requeue: items, stop: true };
|
|
3422
|
+
}
|
|
3423
|
+
return { requeue: this.settleReplayBatchSlots(items, body.results), stop: false };
|
|
3424
|
+
}
|
|
3425
|
+
/**
|
|
3426
|
+
* Demux a `/_lunora/rpc-batch` reply back onto the queued writes it replayed,
|
|
3427
|
+
* in input order. Each slot's envelope classifies its write the same way
|
|
3428
|
+
* {@link replaySequential} does: a success confirms the optimistic layer
|
|
3429
|
+
* against the echoed `commitCursor`; a coded application verdict is terminal;
|
|
3430
|
+
* a transient shard failure ({@link TRANSIENT_BATCH_ERROR_CODES}) or a slot the
|
|
3431
|
+
* server never returned is returned for the caller to re-queue.
|
|
3432
|
+
* @returns the writes that must be re-queued (transient slots), in input order
|
|
3433
|
+
*/
|
|
3434
|
+
settleReplayBatchSlots(items, results) {
|
|
3435
|
+
const bySlot = /* @__PURE__ */ new Map();
|
|
3436
|
+
for (const entry of results) {
|
|
3437
|
+
if (typeof entry.id === "number" && entry.body !== void 0) {
|
|
3438
|
+
bySlot.set(entry.id, entry.body);
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
3441
|
+
const requeue = [];
|
|
3442
|
+
for (const [index, item] of items.entries()) {
|
|
3443
|
+
const inner = bySlot.get(index);
|
|
3444
|
+
if (inner === void 0) {
|
|
3445
|
+
requeue.push(item);
|
|
3446
|
+
} else if ("error" in inner) {
|
|
3447
|
+
if (TRANSIENT_BATCH_ERROR_CODES.has(inner.error.code)) {
|
|
3448
|
+
requeue.push(item);
|
|
3449
|
+
} else {
|
|
3450
|
+
this.settleReplayTerminal(item, reconstructError(inner.error));
|
|
3451
|
+
}
|
|
3452
|
+
} else {
|
|
3453
|
+
this.settleReplaySuccess(item, decodeWire(inner.result), inner.commitCursor);
|
|
3454
|
+
}
|
|
3455
|
+
}
|
|
3456
|
+
return requeue;
|
|
3457
|
+
}
|
|
2163
3458
|
}
|
|
2164
3459
|
|
|
2165
3460
|
export { LunoraClient };
|