@lunora/client 1.0.0-alpha.2 → 1.0.0-alpha.20
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 +9 -8
- 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-hrmLXEHd.mjs} +1512 -266
- package/dist/packem_shared/OfflineQueue-GGYJRmhF.mjs +1 -0
- package/dist/packem_shared/SubscriptionRegistry-Cxr70og-.mjs +1 -0
- package/dist/packem_shared/{createInMemoryPersistence-CW82inU5.mjs → createInMemoryPersistence-Ds7z8n8d.mjs} +17 -3
- package/dist/packem_shared/{createInMemoryQueryCache-B1PQ9Twl.mjs → createInMemoryQueryCache-iWtKPrid.mjs} +18 -4
- package/dist/packem_shared/createLocalStore-IOur0jHF.mjs +1 -0
- package/dist/packem_shared/createMutatorRunner-BETvCd0p.mjs +31 -0
- package/dist/packem_shared/{createServerClient-BjZc3gD8.mjs → createServerClient-CKKZrXLc.mjs} +1 -1
- package/dist/packem_shared/local-store-BNgN3Dw3.mjs +111 -0
- package/dist/packem_shared/{lunora-client.d-DGvyuJ_p.d.mts → lunora-client.d-DVdVtJV8.d.mts} +973 -58
- package/dist/packem_shared/{lunora-client.d-DGvyuJ_p.d.ts → lunora-client.d-DVdVtJV8.d.ts} +973 -58
- package/dist/packem_shared/{OfflineQueue-D5p_QgF_.mjs → offline-queue-B9vfdSqp.mjs} +49 -6
- package/dist/packem_shared/{preload.d-dSaRMuhL.d.mts → preload.d-BXFvxpiv.d.mts} +1 -1
- package/dist/packem_shared/{preload.d-BoDmFqSG.d.ts → preload.d-Bb69VRgE.d.ts} +1 -1
- package/dist/packem_shared/subscription-DoyO04-2.mjs +65 -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/createLocalStore-DSUfoLqY.mjs +0 -36
|
@@ -1,17 +1,285 @@
|
|
|
1
|
+
import { LunoraError } from '@lunora/errors';
|
|
2
|
+
import { S as SubscriptionRegistry, s as stableStringify } from './subscription-DoyO04-2.mjs';
|
|
1
3
|
import createInMemoryBookmarkStorage from './createInMemoryBookmarkStorage-BoN7a7TH.mjs';
|
|
2
4
|
import { isMutationDelta, applyDelta } from './applyDelta-4jFGTPA3.mjs';
|
|
3
|
-
import { createLocalStore } from './
|
|
4
|
-
import { OfflineQueue, nextId, reportPersistenceError } from './
|
|
5
|
-
import {
|
|
5
|
+
import { a as applyOptimisticLayer, d as dropConfirmedLayers, n as notifySubscription, f as foldOptimistic, c as createLocalStore } from './local-store-BNgN3Dw3.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-Ds7z8n8d.mjs';
|
|
8
|
+
import { resolveQueryCacheAdapter, queryCacheKey } from './createInMemoryQueryCache-iWtKPrid.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 TAG = "$lunora.wire$";
|
|
15
|
+
const MAX_DEPTH = 64;
|
|
16
|
+
const MAX_BIGINT_DIGITS = 1024;
|
|
17
|
+
const UNSAFE_KEY = "__proto__";
|
|
18
|
+
const TYPED_ARRAY_CTORS = {
|
|
19
|
+
BigInt64Array,
|
|
20
|
+
BigUint64Array,
|
|
21
|
+
Float32Array,
|
|
22
|
+
Float64Array,
|
|
23
|
+
Int8Array,
|
|
24
|
+
Int16Array,
|
|
25
|
+
Int32Array,
|
|
26
|
+
Uint8Array,
|
|
27
|
+
Uint8ClampedArray,
|
|
28
|
+
Uint16Array,
|
|
29
|
+
Uint32Array
|
|
30
|
+
};
|
|
31
|
+
const ERROR_CTORS = {
|
|
32
|
+
Error,
|
|
33
|
+
EvalError,
|
|
34
|
+
RangeError,
|
|
35
|
+
ReferenceError,
|
|
36
|
+
SyntaxError,
|
|
37
|
+
TypeError,
|
|
38
|
+
URIError
|
|
39
|
+
};
|
|
40
|
+
const toBase64 = (bytes) => {
|
|
41
|
+
let binary = "";
|
|
42
|
+
const chunk = 32768;
|
|
43
|
+
for (let index = 0; index < bytes.length; index += chunk) {
|
|
44
|
+
binary += String.fromCharCode(...bytes.subarray(index, index + chunk));
|
|
45
|
+
}
|
|
46
|
+
return btoa(binary);
|
|
47
|
+
};
|
|
48
|
+
const fromBase64 = (base64) => {
|
|
49
|
+
const binary = atob(base64);
|
|
50
|
+
const bytes = new Uint8Array(binary.length);
|
|
51
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
52
|
+
bytes[index] = binary.codePointAt(index) ?? 0;
|
|
53
|
+
}
|
|
54
|
+
return bytes;
|
|
55
|
+
};
|
|
56
|
+
const encodeWire = (value, depth = 0) => {
|
|
57
|
+
if (depth > MAX_DEPTH) {
|
|
58
|
+
throw new RangeError(`wire-codec: value nesting exceeds the ${MAX_DEPTH}-level limit`);
|
|
59
|
+
}
|
|
60
|
+
if (value === void 0) {
|
|
61
|
+
return [TAG, "undefined"];
|
|
62
|
+
}
|
|
63
|
+
if (value === null) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const kind = typeof value;
|
|
67
|
+
if (kind === "bigint") {
|
|
68
|
+
return [TAG, "bigint", value.toString()];
|
|
69
|
+
}
|
|
70
|
+
if (kind === "number") {
|
|
71
|
+
const numeric = value;
|
|
72
|
+
if (Number.isNaN(numeric)) {
|
|
73
|
+
return [TAG, "nan"];
|
|
74
|
+
}
|
|
75
|
+
if (numeric === Infinity) {
|
|
76
|
+
return [TAG, "inf"];
|
|
77
|
+
}
|
|
78
|
+
if (numeric === -Infinity) {
|
|
79
|
+
return [TAG, "-inf"];
|
|
80
|
+
}
|
|
81
|
+
return numeric;
|
|
82
|
+
}
|
|
83
|
+
if (kind !== "object") {
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
if (value instanceof Date) {
|
|
87
|
+
return [TAG, "date", encodeWire(value.getTime(), depth + 1)];
|
|
88
|
+
}
|
|
89
|
+
if (value instanceof Error) {
|
|
90
|
+
const error = value;
|
|
91
|
+
const properties = {};
|
|
92
|
+
for (const key of Object.keys(error)) {
|
|
93
|
+
if (error[key] !== void 0) {
|
|
94
|
+
properties[key] = encodeWire(error[key], depth + 1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const encodedError = [TAG, "error", error.name, error.message, properties];
|
|
98
|
+
if (error.cause !== void 0) {
|
|
99
|
+
encodedError.push(encodeWire(error.cause, depth + 1));
|
|
100
|
+
}
|
|
101
|
+
return encodedError;
|
|
102
|
+
}
|
|
103
|
+
if (value instanceof URL) {
|
|
104
|
+
return [TAG, "url", value.href];
|
|
105
|
+
}
|
|
106
|
+
if (value instanceof Map) {
|
|
107
|
+
return [TAG, "map", [...value.entries()].map(([k, v]) => [encodeWire(k, depth + 1), encodeWire(v, depth + 1)])];
|
|
108
|
+
}
|
|
109
|
+
if (value instanceof Set) {
|
|
110
|
+
return [TAG, "set", [...value].map((item) => encodeWire(item, depth + 1))];
|
|
111
|
+
}
|
|
112
|
+
if (value instanceof ArrayBuffer) {
|
|
113
|
+
return [TAG, "bytes", toBase64(new Uint8Array(value)), "ArrayBuffer"];
|
|
114
|
+
}
|
|
115
|
+
if (ArrayBuffer.isView(value)) {
|
|
116
|
+
const view = value;
|
|
117
|
+
const ctorName = view.constructor.name;
|
|
118
|
+
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
|
119
|
+
return ctorName === "Uint8Array" ? [TAG, "bytes", toBase64(bytes)] : [TAG, "bytes", toBase64(bytes), ctorName];
|
|
120
|
+
}
|
|
121
|
+
if (Array.isArray(value)) {
|
|
122
|
+
const encoded = value.map((item) => encodeWire(item, depth + 1));
|
|
123
|
+
return encoded.length > 0 && encoded[0] === TAG ? [TAG, "arr", encoded] : encoded;
|
|
124
|
+
}
|
|
125
|
+
const proto = Object.getPrototypeOf(value);
|
|
126
|
+
if (proto !== null && proto !== Object.prototype) {
|
|
127
|
+
const name = value.constructor?.name ?? "value";
|
|
128
|
+
throw new TypeError(
|
|
129
|
+
`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`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const source = value;
|
|
133
|
+
const result = {};
|
|
134
|
+
for (const key of Object.keys(source)) {
|
|
135
|
+
const field = source[key];
|
|
136
|
+
if (field !== void 0) {
|
|
137
|
+
result[key] = encodeWire(field, depth + 1);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
};
|
|
142
|
+
const decodeWire = (value, depth = 0) => {
|
|
143
|
+
if (depth > MAX_DEPTH) {
|
|
144
|
+
throw new RangeError(`wire-codec: value nesting exceeds the ${MAX_DEPTH}-level limit`);
|
|
145
|
+
}
|
|
146
|
+
if (value === null || typeof value !== "object") {
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
if (Array.isArray(value)) {
|
|
150
|
+
if (value[0] === TAG) {
|
|
151
|
+
const tag = value[1];
|
|
152
|
+
switch (tag) {
|
|
153
|
+
case "-inf": {
|
|
154
|
+
return -Infinity;
|
|
155
|
+
}
|
|
156
|
+
case "arr": {
|
|
157
|
+
return value[2].map((item) => decodeWire(item, depth + 1));
|
|
158
|
+
}
|
|
159
|
+
case "bigint": {
|
|
160
|
+
const raw = value[2];
|
|
161
|
+
if (typeof raw !== "string" || raw.length > MAX_BIGINT_DIGITS || !/^-?\d+$/.test(raw)) {
|
|
162
|
+
throw new RangeError(`wire-codec: invalid or over-long bigint (max ${MAX_BIGINT_DIGITS} digits)`);
|
|
163
|
+
}
|
|
164
|
+
return BigInt(raw);
|
|
165
|
+
}
|
|
166
|
+
case "date": {
|
|
167
|
+
return new Date(decodeWire(value[2], depth + 1));
|
|
168
|
+
}
|
|
169
|
+
case "map": {
|
|
170
|
+
return new Map(value[2].map(([k, v]) => [decodeWire(k, depth + 1), decodeWire(v, depth + 1)]));
|
|
171
|
+
}
|
|
172
|
+
case "set": {
|
|
173
|
+
return new Set(value[2].map((item) => decodeWire(item, depth + 1)));
|
|
174
|
+
}
|
|
175
|
+
case "url": {
|
|
176
|
+
return new URL(value[2]);
|
|
177
|
+
}
|
|
178
|
+
case "error": {
|
|
179
|
+
const name = value[2];
|
|
180
|
+
const message = value[3];
|
|
181
|
+
const Ctor = (Object.hasOwn(ERROR_CTORS, name) ? ERROR_CTORS[name] : void 0) ?? Error;
|
|
182
|
+
const error = new Ctor(message);
|
|
183
|
+
if (error.name !== name) {
|
|
184
|
+
Object.defineProperty(error, "name", { configurable: true, value: name, writable: true });
|
|
185
|
+
}
|
|
186
|
+
const props = decodeWire(value[4], depth + 1);
|
|
187
|
+
for (const key of Object.keys(props)) {
|
|
188
|
+
if (key === UNSAFE_KEY) {
|
|
189
|
+
Object.defineProperty(error, key, { configurable: true, enumerable: true, value: props[key], writable: true });
|
|
190
|
+
} else {
|
|
191
|
+
error[key] = props[key];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (value.length > 5) {
|
|
195
|
+
Object.defineProperty(error, "cause", { configurable: true, value: decodeWire(value[5], depth + 1), writable: true });
|
|
196
|
+
}
|
|
197
|
+
return error;
|
|
198
|
+
}
|
|
199
|
+
case "bytes": {
|
|
200
|
+
const bytes = fromBase64(value[2]);
|
|
201
|
+
const ctorName = value[3] ?? "Uint8Array";
|
|
202
|
+
if (ctorName === "ArrayBuffer") {
|
|
203
|
+
return bytes.buffer.byteLength === bytes.byteLength ? bytes.buffer : bytes.slice().buffer;
|
|
204
|
+
}
|
|
205
|
+
const Ctor = Object.hasOwn(TYPED_ARRAY_CTORS, ctorName) ? TYPED_ARRAY_CTORS[ctorName] : void 0;
|
|
206
|
+
return Ctor ? new Ctor(bytes.slice().buffer) : bytes;
|
|
207
|
+
}
|
|
208
|
+
case "inf": {
|
|
209
|
+
return Infinity;
|
|
210
|
+
}
|
|
211
|
+
case "nan": {
|
|
212
|
+
return Number.NaN;
|
|
213
|
+
}
|
|
214
|
+
case "undefined": {
|
|
215
|
+
return void 0;
|
|
216
|
+
}
|
|
217
|
+
default: {
|
|
218
|
+
return value.map((item) => decodeWire(item, depth + 1));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return value.map((item) => decodeWire(item, depth + 1));
|
|
223
|
+
}
|
|
224
|
+
const source = value;
|
|
225
|
+
const result = {};
|
|
226
|
+
for (const key of Object.keys(source)) {
|
|
227
|
+
const decoded = decodeWire(source[key], depth + 1);
|
|
228
|
+
if (key === UNSAFE_KEY) {
|
|
229
|
+
Object.defineProperty(result, key, { configurable: true, enumerable: true, value: decoded, writable: true });
|
|
230
|
+
} else {
|
|
231
|
+
result[key] = decoded;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return result;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
class Listeners {
|
|
238
|
+
listeners = /* @__PURE__ */ new Set();
|
|
239
|
+
add(listener) {
|
|
240
|
+
this.listeners.add(listener);
|
|
241
|
+
return () => {
|
|
242
|
+
this.listeners.delete(listener);
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
// The conditional rest tuple makes `emit()` argument-free for a
|
|
246
|
+
// `Listeners<void>` and one-argument for every other payload.
|
|
247
|
+
// 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
|
|
248
|
+
emit(...args) {
|
|
249
|
+
const [value] = args;
|
|
250
|
+
for (const listener of this.listeners) {
|
|
251
|
+
try {
|
|
252
|
+
listener(value);
|
|
253
|
+
} catch {
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
clear() {
|
|
258
|
+
this.listeners.clear();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
9
261
|
|
|
10
262
|
const RPC_PATH = "/_lunora/rpc";
|
|
263
|
+
const RPC_BATCH_PATH = "/_lunora/rpc-batch";
|
|
11
264
|
const WS_PATH = "/_lunora/ws";
|
|
12
265
|
const bucketQuery = (bucket) => bucket === void 0 || bucket === "" ? "" : `&bucket=${encodeURIComponent(bucket)}`;
|
|
266
|
+
const rollbackOptimistic = (optimisticRollbacks) => {
|
|
267
|
+
for (let index = optimisticRollbacks.length - 1; index >= 0; index -= 1) {
|
|
268
|
+
optimisticRollbacks[index]?.();
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
const applyRowOpsToView = (rows, ops) => {
|
|
272
|
+
for (const op of ops) {
|
|
273
|
+
if (op.op === "delete") {
|
|
274
|
+
rows.delete(op.key);
|
|
275
|
+
} else if (op.value !== void 0) {
|
|
276
|
+
rows.set(op.key, op.value);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
};
|
|
13
280
|
const WS_KEEPALIVE_PING = "lunora-ping";
|
|
14
281
|
const DEFAULT_HEARTBEAT_INTERVAL_MS = 3e4;
|
|
282
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
|
|
15
283
|
const QUERY_CACHE_DEBOUNCE_MS = 250;
|
|
16
284
|
const MAX_PENDING_STREAMS = 64;
|
|
17
285
|
const SHARD_TRAFFIC_PATH = "/_lunora/admin/shard-traffic";
|
|
@@ -38,6 +306,9 @@ const GLOBAL_TABLE_PATH = "/_lunora/admin/global/table";
|
|
|
38
306
|
const GLOBAL_FACET_PATH = "/_lunora/admin/global/facet";
|
|
39
307
|
const VECTOR_INDEXES_PATH = "/_lunora/admin/vector/indexes";
|
|
40
308
|
const VECTOR_QUERY_PATH = "/_lunora/admin/vector/query";
|
|
309
|
+
const KV_NAMESPACES_PATH = "/_lunora/admin/kv/namespaces";
|
|
310
|
+
const KV_KEYS_PATH = "/_lunora/admin/kv/keys";
|
|
311
|
+
const KV_VALUE_PATH = "/_lunora/admin/kv/value";
|
|
41
312
|
const AUTH_USERS_PATH = "/_lunora/admin/auth/users";
|
|
42
313
|
const AUTH_SESSIONS_PATH = "/_lunora/admin/auth/sessions";
|
|
43
314
|
const AUTH_CREATE_USER_PATH = "/_lunora/admin/auth/users/create";
|
|
@@ -61,24 +332,26 @@ const AUTH_ORG_MEMBERS_PATH = "/_lunora/admin/auth/organizations/members";
|
|
|
61
332
|
const AUTH_ORG_INVITATIONS_PATH = "/_lunora/admin/auth/organizations/invitations";
|
|
62
333
|
const AUTH_REMOVE_MEMBER_PATH = "/_lunora/admin/auth/organizations/members/remove";
|
|
63
334
|
const AUTH_CANCEL_INVITATION_PATH = "/_lunora/admin/auth/organizations/invitations/cancel";
|
|
335
|
+
const AUTH_CONFIG_PATH = "/_lunora/admin/auth/config";
|
|
336
|
+
const AUTH_CREATE_ORG_PATH = "/_lunora/admin/auth/organizations/create";
|
|
337
|
+
const AUTH_UPDATE_ORG_PATH = "/_lunora/admin/auth/organizations/update";
|
|
338
|
+
const AUTH_REMOVE_ORG_PATH = "/_lunora/admin/auth/organizations/remove";
|
|
339
|
+
const AUTH_ADD_MEMBER_PATH = "/_lunora/admin/auth/organizations/members/add";
|
|
340
|
+
const AUTH_INVITE_MEMBER_PATH = "/_lunora/admin/auth/organizations/members/invite";
|
|
341
|
+
const AUTH_MEMBER_ROLE_PATH = "/_lunora/admin/auth/organizations/members/role";
|
|
342
|
+
const AUTH_ORG_TEAMS_PATH = "/_lunora/admin/auth/organizations/teams";
|
|
343
|
+
const AUTH_CREATE_TEAM_PATH = "/_lunora/admin/auth/organizations/teams/create";
|
|
344
|
+
const AUTH_UPDATE_TEAM_PATH = "/_lunora/admin/auth/organizations/teams/update";
|
|
345
|
+
const AUTH_REMOVE_TEAM_PATH = "/_lunora/admin/auth/organizations/teams/remove";
|
|
346
|
+
const AUTH_ORG_TEAM_MEMBERS_PATH = "/_lunora/admin/auth/organizations/teams/members";
|
|
347
|
+
const AUTH_ADD_TEAM_MEMBER_PATH = "/_lunora/admin/auth/organizations/teams/members/add";
|
|
348
|
+
const AUTH_REMOVE_TEAM_MEMBER_PATH = "/_lunora/admin/auth/organizations/teams/members/remove";
|
|
349
|
+
const AUTH_ORG_ROLES_PATH = "/_lunora/admin/auth/organizations/roles";
|
|
350
|
+
const AUTH_CREATE_ROLE_PATH = "/_lunora/admin/auth/organizations/roles/create";
|
|
351
|
+
const AUTH_UPDATE_ROLE_PATH = "/_lunora/admin/auth/organizations/roles/update";
|
|
352
|
+
const AUTH_REMOVE_ROLE_PATH = "/_lunora/admin/auth/organizations/roles/remove";
|
|
64
353
|
const DEFAULT_AUTH_BASE_PATH = "/api/auth";
|
|
65
354
|
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
355
|
const deriveWsUrl = (url) => {
|
|
83
356
|
if (url.startsWith("https://")) {
|
|
84
357
|
return `wss://${url.slice("https://".length)}`;
|
|
@@ -103,47 +376,12 @@ const withQuery = (path, params) => {
|
|
|
103
376
|
return query === "" ? path : `${path}?${query}`;
|
|
104
377
|
};
|
|
105
378
|
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
379
|
const buildStreamError = (message) => {
|
|
142
380
|
const errorEnvelope = message.error;
|
|
143
381
|
const code = typeof errorEnvelope?.code === "string" ? errorEnvelope.code : void 0;
|
|
144
382
|
const nestedMessage = typeof errorEnvelope?.message === "string" ? errorEnvelope.message : void 0;
|
|
145
383
|
const messageText = (typeof message.message === "string" ? message.message : void 0) ?? nestedMessage ?? "stream error";
|
|
146
|
-
return
|
|
384
|
+
return code === void 0 ? new Error(messageText) : new LunoraError(code, messageText);
|
|
147
385
|
};
|
|
148
386
|
const buildSubscriptionError = (message) => {
|
|
149
387
|
const errorEnvelope = message.error;
|
|
@@ -152,6 +390,14 @@ const buildSubscriptionError = (message) => {
|
|
|
152
390
|
const messageText = (typeof message.message === "string" ? message.message : void 0) ?? nestedMessage ?? "subscription error";
|
|
153
391
|
return { message: messageText, ...code === void 0 ? {} : { code } };
|
|
154
392
|
};
|
|
393
|
+
const fanSubscriptionError = (callbacks, error) => {
|
|
394
|
+
for (const errorCallback of callbacks) {
|
|
395
|
+
try {
|
|
396
|
+
errorCallback(error);
|
|
397
|
+
} catch {
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
};
|
|
155
401
|
const sharedDecoder = new TextDecoder();
|
|
156
402
|
const decodeServerFrame = (raw) => {
|
|
157
403
|
if (typeof raw === "string") {
|
|
@@ -173,7 +419,43 @@ const sendOn = (conn, message) => {
|
|
|
173
419
|
return false;
|
|
174
420
|
}
|
|
175
421
|
};
|
|
422
|
+
const reconstructError = (errorBody) => {
|
|
423
|
+
const error = new Error(errorBody.message ?? "request failed");
|
|
424
|
+
error.code = errorBody.code;
|
|
425
|
+
if (errorBody.data !== void 0) {
|
|
426
|
+
error.data = decodeWire(errorBody.data);
|
|
427
|
+
}
|
|
428
|
+
if (errorBody.hint !== void 0) {
|
|
429
|
+
error.hint = errorBody.hint;
|
|
430
|
+
}
|
|
431
|
+
if (errorBody.docsUrl !== void 0) {
|
|
432
|
+
error.docsUrl = errorBody.docsUrl;
|
|
433
|
+
}
|
|
434
|
+
return error;
|
|
435
|
+
};
|
|
436
|
+
const encodeCallArgs = (payload, label) => {
|
|
437
|
+
try {
|
|
438
|
+
return encodeWire(payload);
|
|
439
|
+
} catch (error) {
|
|
440
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
441
|
+
throw new TypeError(`LunoraClient: cannot encode ${label} — ${reason}`, error instanceof Error ? { cause: error } : void 0);
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
const demuxBatchResults = (rawResults, count) => {
|
|
445
|
+
const slots = Array.from({ length: count });
|
|
446
|
+
for (const entry of rawResults) {
|
|
447
|
+
if (typeof entry.id !== "number" || entry.id < 0 || entry.id >= count) {
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const inner = entry.body;
|
|
451
|
+
slots[entry.id] = inner && "error" in inner && inner.error ? { error: reconstructError(inner.error), ok: false } : { ok: true, value: decodeWire(inner?.result) };
|
|
452
|
+
}
|
|
453
|
+
return slots.map((slot) => slot ?? { error: new Error("batch call returned no result"), ok: false });
|
|
454
|
+
};
|
|
455
|
+
const TRANSIENT_BATCH_ERROR_CODES = /* @__PURE__ */ new Set(["SHARD_ERROR", "SHARD_UNAVAILABLE"]);
|
|
176
456
|
class LunoraClient {
|
|
457
|
+
/** Hard cap on concurrently-buffered pokes — a backstop that reclaims buffers abandoned by a mid-poke disconnect (no `pokeEnd`). Far above any real concurrent-in-flight count. */
|
|
458
|
+
static MAX_POKE_BUFFERS = 256;
|
|
177
459
|
url;
|
|
178
460
|
wsUrl;
|
|
179
461
|
wsToken;
|
|
@@ -183,11 +465,36 @@ class LunoraClient {
|
|
|
183
465
|
WebSocketImpl;
|
|
184
466
|
bookmark;
|
|
185
467
|
reconnectOptions;
|
|
468
|
+
/** WS connect timeout (ms); `0` disables it. See {@link LunoraClientOptions.connectTimeoutMs}. */
|
|
469
|
+
connectTimeoutMs;
|
|
186
470
|
/** Keepalive cadence (ms); `0` disables the heartbeat. See {@link LunoraClientOptions.heartbeatIntervalMs}. */
|
|
187
471
|
heartbeatIntervalMs;
|
|
188
472
|
offlineQueue;
|
|
473
|
+
/**
|
|
474
|
+
* Durable outbox seam (the `@lunora/db` `createExecutorOutboxSink`). When
|
|
475
|
+
* set, offline writes are delegated here and the built-in {@link OfflineQueue}
|
|
476
|
+
* is bypassed, so a db app has exactly one durable write path.
|
|
477
|
+
*/
|
|
478
|
+
outbox;
|
|
479
|
+
/** Stable per-client id stamped onto every `OutboxMutation` (custom-mutator watermark). */
|
|
480
|
+
clientId;
|
|
481
|
+
/**
|
|
482
|
+
* Highest custom-mutator watermark the server has echoed for this client,
|
|
483
|
+
* keyed by shard bucket (`shardKey ?? ""`) since the DO tracks one
|
|
484
|
+
* `__client_watermark` per shard. `callMutator` bumps it from every
|
|
485
|
+
* ack; the `@lunora/db` mutator runtime seeds its `clientSeq` generator from
|
|
486
|
+
* it so a reload (which resets the in-memory counter) never reissues a stale
|
|
487
|
+
* sequence the server would silently swallow as a replay.
|
|
488
|
+
*/
|
|
489
|
+
clientWatermarks = /* @__PURE__ */ new Map();
|
|
490
|
+
/** Monotonic per-client mutation counter backing the server `__client_watermark`. */
|
|
491
|
+
outboxMutationCounter = 0;
|
|
189
492
|
onPersistenceError;
|
|
190
493
|
persistence;
|
|
494
|
+
/** App/schema version stamped on persisted writes + cached reads; mismatches are purged. */
|
|
495
|
+
persistenceVersion;
|
|
496
|
+
/** Releases the multi-tab outbox-leader Web Lock on close (see `hydrateAsOutboxLeader`). */
|
|
497
|
+
outboxLeaderRelease;
|
|
191
498
|
/** Durable read cache (Pillar 2); `undefined` when `queryCache` is omitted or `false`. */
|
|
192
499
|
queryCache;
|
|
193
500
|
/**
|
|
@@ -233,6 +540,14 @@ class LunoraClient {
|
|
|
233
540
|
// setAuthToken / onAuthTokenChange — part of the exported API contract.
|
|
234
541
|
// eslint-disable-next-line unicorn/no-null -- public auth-token contract sentinel
|
|
235
542
|
authToken = null;
|
|
543
|
+
/**
|
|
544
|
+
* Optional STABLE identity subject (a user id), the basis of the offline-queue
|
|
545
|
+
* identity stamp when supplied. Keeps a same-user token *refresh* from looking
|
|
546
|
+
* like an identity change (which would discard queued writes). `undefined` =
|
|
547
|
+
* not supplied, so identity falls back to a hash of the raw token. See
|
|
548
|
+
* `setAuthToken` / `identityFingerprint`.
|
|
549
|
+
*/
|
|
550
|
+
authSubject = void 0;
|
|
236
551
|
/**
|
|
237
552
|
* Identity stamp recorded against each queued offline mutation, keyed by
|
|
238
553
|
* the queue-assigned mutation id. Captured at enqueue from the auth token
|
|
@@ -243,11 +558,15 @@ class LunoraClient {
|
|
|
243
558
|
queuedIdentities = /* @__PURE__ */ new Map();
|
|
244
559
|
closed = false;
|
|
245
560
|
/** Subscribers to auth-token changes (see `onAuthTokenChange`). */
|
|
246
|
-
authTokenListeners =
|
|
561
|
+
authTokenListeners = new Listeners();
|
|
247
562
|
/** Subscribers to aggregate connection-status changes (see `onConnectionStatus`). */
|
|
248
|
-
statusListeners =
|
|
563
|
+
statusListeners = new Listeners();
|
|
249
564
|
/** Subscribers notified when the server drops a socket for an expired token (see `onTokenExpired`). */
|
|
250
|
-
tokenExpiredListeners =
|
|
565
|
+
tokenExpiredListeners = new Listeners();
|
|
566
|
+
/** Subscribers to offline-queued mutation verdicts (see `onMutationSettled`). */
|
|
567
|
+
mutationSettledListeners = new Listeners();
|
|
568
|
+
/** Subscribers to the offline-queue pending-count (see `onPendingChange`). */
|
|
569
|
+
pendingChangeListeners = new Listeners();
|
|
251
570
|
/**
|
|
252
571
|
* Whisper-topic handlers, keyed by `connectionKey(shardKey)` → topic → set
|
|
253
572
|
* of callbacks. Membership doubles as the resubscribe set replayed on every
|
|
@@ -265,6 +584,11 @@ class LunoraClient {
|
|
|
265
584
|
* calls `.cancel()` or the iterator is garbage-collected.
|
|
266
585
|
*/
|
|
267
586
|
streams = /* @__PURE__ */ new Map();
|
|
587
|
+
/** Live shape subscriptions (partial replication), keyed by their wire id. */
|
|
588
|
+
shapeSubscriptions = /* @__PURE__ */ new Map();
|
|
589
|
+
/** In-flight pokes being assembled between `pokeStart` and `pokeEnd`, keyed by `pokeId`. */
|
|
590
|
+
pokeBuffers = /* @__PURE__ */ new Map();
|
|
591
|
+
nextShapeId = 0;
|
|
268
592
|
constructor(options) {
|
|
269
593
|
this.url = options.url;
|
|
270
594
|
this.wsUrl = options.wsUrl ?? joinUrl(deriveWsUrl(options.url), WS_PATH);
|
|
@@ -276,14 +600,27 @@ class LunoraClient {
|
|
|
276
600
|
this.bookmark = options.bookmarkStorage ?? createInMemoryBookmarkStorage();
|
|
277
601
|
this.reconnectOptions = options.reconnect;
|
|
278
602
|
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
603
|
+
this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
279
604
|
this.defaultConnectionContext = options.connectionContext;
|
|
280
|
-
this.persistence = options.persistence;
|
|
281
|
-
this.
|
|
605
|
+
this.persistence = resolvePersistenceAdapter(options.persistence, options.outbox === void 0);
|
|
606
|
+
this.persistenceVersion = options.persistenceVersion;
|
|
607
|
+
this.queryCache = resolveQueryCacheAdapter(options.queryCache);
|
|
282
608
|
this.onPersistenceError = options.offlineQueue?.onPersistenceError;
|
|
283
|
-
this.offlineQueue = new OfflineQueue(options.offlineQueue,
|
|
609
|
+
this.offlineQueue = new OfflineQueue(options.offlineQueue, {
|
|
610
|
+
onEvict: (entry, error) => {
|
|
611
|
+
this.emitItemSettled(entry, "rejected", error);
|
|
612
|
+
},
|
|
613
|
+
onSizeChange: (size) => {
|
|
614
|
+
this.pendingChangeListeners.emit(size);
|
|
615
|
+
},
|
|
616
|
+
persistence: this.persistence,
|
|
617
|
+
version: options.persistenceVersion
|
|
618
|
+
});
|
|
619
|
+
this.outbox = options.outbox;
|
|
620
|
+
this.clientId = options.clientId ?? `client-${nextId()}`;
|
|
284
621
|
if (this.persistence) {
|
|
285
622
|
queueMicrotask(() => {
|
|
286
|
-
this.
|
|
623
|
+
this.hydrateAsOutboxLeader();
|
|
287
624
|
});
|
|
288
625
|
}
|
|
289
626
|
if (this.queryCache) {
|
|
@@ -298,37 +635,113 @@ class LunoraClient {
|
|
|
298
635
|
* {@link onAuthTokenChange} listeners so React hooks like `useAuth` stay in
|
|
299
636
|
* sync across all mounted instances.
|
|
300
637
|
*
|
|
638
|
+
* Pass a STABLE `subject` (the user id) to key the offline-queue identity on
|
|
639
|
+
* it instead of the token bytes, so a token *refresh* (same user, new JWT)
|
|
640
|
+
* doesn't read as an identity change and discard queued writes. The subject is
|
|
641
|
+
* **sticky**: a later call that omits it (or passes `undefined`) keeps the
|
|
642
|
+
* established subject — so `setAuthToken(refreshedToken)` after a prior
|
|
643
|
+
* `setAuthToken(token, user.id)` retains the identity. Pass `null` to clear it
|
|
644
|
+
* (an explicit sign-out). Establishing the subject for the first time on an
|
|
645
|
+
* UNCHANGED token (e.g. the user id resolves a tick after the token was set)
|
|
646
|
+
* re-stamps any in-flight queued writes rather than dropping them — same
|
|
647
|
+
* credential, just a more stable label. A real user switch (the token AND
|
|
648
|
+
* subject both change) still drops the previous user's writes.
|
|
649
|
+
*
|
|
301
650
|
* Does NOT update the WebSocket auth — the WS token is fixed at upgrade
|
|
302
651
|
* time and lives in the URL. To refresh live WS auth, call
|
|
303
652
|
* {@link setWsToken} explicitly, which closes existing shard sockets to
|
|
304
653
|
* force a reconnect with the new credential.
|
|
305
654
|
*/
|
|
306
|
-
setAuthToken(token) {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
655
|
+
setAuthToken(token, subject) {
|
|
656
|
+
const tokenChanged = this.authToken !== token;
|
|
657
|
+
const previousIdentity = this.identityFingerprint();
|
|
310
658
|
this.authToken = token;
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
659
|
+
if (subject !== void 0) {
|
|
660
|
+
this.authSubject = subject;
|
|
661
|
+
}
|
|
662
|
+
const newIdentity = this.identityFingerprint();
|
|
663
|
+
if (newIdentity !== previousIdentity) {
|
|
664
|
+
if (tokenChanged) {
|
|
665
|
+
this.rejectQueuedForIdentityChange();
|
|
666
|
+
} else {
|
|
667
|
+
this.restampQueuedIdentity(previousIdentity, newIdentity);
|
|
316
668
|
}
|
|
317
669
|
}
|
|
670
|
+
if (tokenChanged) {
|
|
671
|
+
this.authTokenListeners.emit(token);
|
|
672
|
+
}
|
|
318
673
|
}
|
|
319
674
|
getAuthToken() {
|
|
320
675
|
return this.authToken;
|
|
321
676
|
}
|
|
677
|
+
/**
|
|
678
|
+
* The current identity fingerprint (the same stamp queued offline writes
|
|
679
|
+
* carry). Exposed so a durable {@link OutboxSink}'s replay handler — which
|
|
680
|
+
* owns its own at-least-once replay outside the built-in `OfflineQueue` —
|
|
681
|
+
* can drop a persisted write whose captured `identity` no longer matches the
|
|
682
|
+
* signed-in user, the guard the queue path applies in `flushOfflineQueue`.
|
|
683
|
+
*/
|
|
684
|
+
currentIdentity() {
|
|
685
|
+
return this.identityFingerprint();
|
|
686
|
+
}
|
|
687
|
+
/** This client's stable identifier — the watermark key the server's custom-mutator protocol advances per `clientSeq`. */
|
|
688
|
+
clientIdentifier() {
|
|
689
|
+
return this.clientId;
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* The highest custom-mutator watermark the server has echoed for this client
|
|
693
|
+
* on the given shard (0 if none yet). The `@lunora/db` mutator runtime seeds
|
|
694
|
+
* its `clientSeq` generator from this so a reload never reissues a sequence
|
|
695
|
+
* the server has already applied (which it would swallow as a replay, silently
|
|
696
|
+
* dropping the write).
|
|
697
|
+
*/
|
|
698
|
+
confirmedMutationWatermark(shardKey) {
|
|
699
|
+
return this.clientWatermarks.get(shardKey ?? "") ?? 0;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Push a custom mutator to its authoritative server impl over the watermark
|
|
703
|
+
* protocol (Phase 4): the request carries `x-lunora-client-id` + a monotonic
|
|
704
|
+
* `x-lunora-client-seq`, so the DO runs it exactly once and advances this
|
|
705
|
+
* client's `__client_watermark`.
|
|
706
|
+
*
|
|
707
|
+
* Returns the server `result` plus `applied`: `true` when the DO ran this push
|
|
708
|
+
* as the next-in-order mutation, `false` when it was a replay ack (`clientSeq`
|
|
709
|
+
* was at or below the stored watermark — e.g. a stale sequence after a reload).
|
|
710
|
+
* A `false` verdict tells the caller to reissue above the now-known watermark
|
|
711
|
+
* (echoed into {@link confirmedMutationWatermark}) rather than treat the benign
|
|
712
|
+
* ack as a confirmed write. Every ack — applied or not — bumps the watermark.
|
|
713
|
+
*
|
|
714
|
+
* This is the online transport for `@lunora/db`'s client-mutator runtime; the
|
|
715
|
+
* optimistic overlay + durable-outbox concerns live in that runtime, not here.
|
|
716
|
+
*/
|
|
717
|
+
async callMutator(functionPath, args, options) {
|
|
718
|
+
const clientSeq = options?.clientSeq;
|
|
719
|
+
if (clientSeq !== void 0 && (!Number.isInteger(clientSeq) || clientSeq <= 0)) {
|
|
720
|
+
throw new LunoraError("INTERNAL", `callMutator: clientSeq must be a positive integer, got ${String(clientSeq)}`);
|
|
721
|
+
}
|
|
722
|
+
const bucket = options?.shardKey ?? "";
|
|
723
|
+
let ackWatermark;
|
|
724
|
+
const result = await this.rpc(functionPath, args, options?.shardKey, {
|
|
725
|
+
captureBookmark: true,
|
|
726
|
+
clientId: this.clientId,
|
|
727
|
+
clientSeq,
|
|
728
|
+
onMutationAck: (lastMutationId) => {
|
|
729
|
+
ackWatermark = lastMutationId;
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
if (ackWatermark !== void 0 && ackWatermark > (this.clientWatermarks.get(bucket) ?? 0)) {
|
|
733
|
+
this.clientWatermarks.set(bucket, ackWatermark);
|
|
734
|
+
}
|
|
735
|
+
const applied = ackWatermark === void 0 || ackWatermark === clientSeq;
|
|
736
|
+
return { applied, result };
|
|
737
|
+
}
|
|
322
738
|
/**
|
|
323
739
|
* Subscribe to auth-token changes. Returns an unsubscribe function. The
|
|
324
740
|
* listener is NOT invoked on registration — use {@link getAuthToken} for
|
|
325
741
|
* the current value.
|
|
326
742
|
*/
|
|
327
743
|
onAuthTokenChange(listener) {
|
|
328
|
-
this.authTokenListeners.add(listener);
|
|
329
|
-
return () => {
|
|
330
|
-
this.authTokenListeners.delete(listener);
|
|
331
|
-
};
|
|
744
|
+
return this.authTokenListeners.add(listener);
|
|
332
745
|
}
|
|
333
746
|
/**
|
|
334
747
|
* Fetch the currently authenticated user from better-auth's `get-session`
|
|
@@ -519,7 +932,7 @@ class LunoraClient {
|
|
|
519
932
|
this.ensureSocket(options.shardKey);
|
|
520
933
|
const conn = this.getConnection(options.shardKey);
|
|
521
934
|
if (conn) {
|
|
522
|
-
sendOn(conn, { data, topic, type: "whisper" });
|
|
935
|
+
sendOn(conn, { data: encodeCallArgs(data ?? null, `whisper data for topic '${topic}'`), topic, type: "whisper" });
|
|
523
936
|
}
|
|
524
937
|
}
|
|
525
938
|
/**
|
|
@@ -531,10 +944,7 @@ class LunoraClient {
|
|
|
531
944
|
* with a freshly minted one. Returns an unsubscribe function.
|
|
532
945
|
*/
|
|
533
946
|
onTokenExpired(listener) {
|
|
534
|
-
this.tokenExpiredListeners.add(listener);
|
|
535
|
-
return () => {
|
|
536
|
-
this.tokenExpiredListeners.delete(listener);
|
|
537
|
-
};
|
|
947
|
+
return this.tokenExpiredListeners.add(listener);
|
|
538
948
|
}
|
|
539
949
|
// --- Connection status --------------------------------------------------
|
|
540
950
|
/**
|
|
@@ -550,19 +960,107 @@ class LunoraClient {
|
|
|
550
960
|
* unsubscribe function.
|
|
551
961
|
*/
|
|
552
962
|
onConnectionStatus(listener) {
|
|
553
|
-
this.statusListeners.add(listener);
|
|
963
|
+
const unsubscribe = this.statusListeners.add(listener);
|
|
554
964
|
listener(this.computeStatus());
|
|
555
|
-
return
|
|
556
|
-
|
|
557
|
-
|
|
965
|
+
return unsubscribe;
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Number of offline writes waiting in the built-in queue to be sent — the
|
|
969
|
+
* depth for a "N changes waiting to sync" indicator. Counts writes that are
|
|
970
|
+
* queued (offline / mid-reconnect), not ones already in flight on the wire.
|
|
971
|
+
* A `@lunora/db` app whose writes ride the unified outbox should read
|
|
972
|
+
* `LunoraDb.pendingCount()` instead (this counts only the built-in queue).
|
|
973
|
+
*/
|
|
974
|
+
pendingCount() {
|
|
975
|
+
return this.offlineQueue.size;
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Subscribe to changes in {@link pendingCount}. Invokes `listener` immediately
|
|
979
|
+
* with the current count, then whenever the queue depth changes (a write is
|
|
980
|
+
* enqueued, flushed, or discarded). Returns an unsubscribe function.
|
|
981
|
+
*/
|
|
982
|
+
onPendingChange(listener) {
|
|
983
|
+
const unsubscribe = this.pendingChangeListeners.add(listener);
|
|
984
|
+
listener(this.offlineQueue.size);
|
|
985
|
+
return unsubscribe;
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Subscribe to terminal verdicts for offline-queued mutations. The listener
|
|
989
|
+
* fires once per queued write that commits or is rejected — including a write
|
|
990
|
+
* restored from durable storage after a reload, whose original `mutation()`
|
|
991
|
+
* Promise no longer exists (`hadAwaiter: false`), and a write the queue
|
|
992
|
+
* evicts on overflow or discards on an identity change. This is the durable
|
|
993
|
+
* channel for surfacing a rolled-back optimistic write to the UI; an online
|
|
994
|
+
* mutation that never queued still surfaces through the Promise `mutation()`
|
|
995
|
+
* returns. The listener is NOT invoked on registration. Returns an
|
|
996
|
+
* unsubscribe function. See {@link MutationSettledEvent}.
|
|
997
|
+
*/
|
|
998
|
+
onMutationSettled(listener) {
|
|
999
|
+
return this.mutationSettledListeners.add(listener);
|
|
558
1000
|
}
|
|
559
1001
|
// --- RPC ---------------------------------------------------------------
|
|
560
1002
|
async query(function_, args, options = {}) {
|
|
561
1003
|
if (this.closed) {
|
|
562
|
-
throw new
|
|
1004
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
563
1005
|
}
|
|
564
1006
|
return await this.rpc(function_.__lunoraRef, args, options.shardKey, { attachBookmark: true });
|
|
565
1007
|
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Batch several independent calls into ONE round trip (plan 088). Each call is
|
|
1010
|
+
* dispatched server-side exactly as an individual RPC — per-shard
|
|
1011
|
+
* authorization, `(identity, mutationId)` idempotency, and custom-mutator
|
|
1012
|
+
* watermark ordering are all preserved — and the worker splits the batch by
|
|
1013
|
+
* shard so calls to different shards fan out to their own DOs. Results are
|
|
1014
|
+
* demuxed back in input order; a failing call does NOT fail the batch (its
|
|
1015
|
+
* slot carries `{ ok: false, error }`, with `.code`/`.data` reconstructed like
|
|
1016
|
+
* a single call). Args/results ride the value codec (bytes/bigint survive).
|
|
1017
|
+
*
|
|
1018
|
+
* No promise pipelining and no capability passing — a call's args cannot
|
|
1019
|
+
* reference another call's result (see plan 088 §fence; capabilities are
|
|
1020
|
+
* incompatible with DO hibernation).
|
|
1021
|
+
*/
|
|
1022
|
+
async batch(calls) {
|
|
1023
|
+
if (this.closed) {
|
|
1024
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1025
|
+
}
|
|
1026
|
+
if (!this.fetchImpl) {
|
|
1027
|
+
throw new LunoraError("INTERNAL", "LunoraClient: no `fetch` implementation available");
|
|
1028
|
+
}
|
|
1029
|
+
if (calls.length === 0) {
|
|
1030
|
+
return [];
|
|
1031
|
+
}
|
|
1032
|
+
const response = await this.fetchImpl(joinUrl(this.url, RPC_BATCH_PATH), {
|
|
1033
|
+
body: JSON.stringify({
|
|
1034
|
+
calls: calls.map((call, index) => {
|
|
1035
|
+
return {
|
|
1036
|
+
args: encodeCallArgs(call.args ?? {}, `args for batch call '${call.fn.__lunoraRef}'`),
|
|
1037
|
+
functionPath: call.fn.__lunoraRef,
|
|
1038
|
+
id: index,
|
|
1039
|
+
shardKey: call.shardKey
|
|
1040
|
+
};
|
|
1041
|
+
})
|
|
1042
|
+
}),
|
|
1043
|
+
headers: this.rpcRequestHeaders({ attachBookmark: true }),
|
|
1044
|
+
method: "POST"
|
|
1045
|
+
});
|
|
1046
|
+
const bookmark = response.headers.get("x-d1-bookmark");
|
|
1047
|
+
if (bookmark) {
|
|
1048
|
+
this.bookmark.set(bookmark);
|
|
1049
|
+
}
|
|
1050
|
+
let body;
|
|
1051
|
+
try {
|
|
1052
|
+
body = await response.json();
|
|
1053
|
+
} catch {
|
|
1054
|
+
throw new LunoraError("INTERNAL", `LunoraClient: batch response was not JSON (status ${response.status.toString()})`);
|
|
1055
|
+
}
|
|
1056
|
+
if (!response.ok || body.error && !body.results) {
|
|
1057
|
+
if (body.error) {
|
|
1058
|
+
throw reconstructError(body.error);
|
|
1059
|
+
}
|
|
1060
|
+
throw new LunoraError("INTERNAL", `LunoraClient: batch request failed (status ${response.status.toString()})`);
|
|
1061
|
+
}
|
|
1062
|
+
return demuxBatchResults(body.results ?? [], calls.length);
|
|
1063
|
+
}
|
|
566
1064
|
/**
|
|
567
1065
|
* Invoke a mutation. Errors propagate as rejections.
|
|
568
1066
|
*
|
|
@@ -575,13 +1073,18 @@ class LunoraClient {
|
|
|
575
1073
|
*/
|
|
576
1074
|
async mutation(function_, args, options = {}) {
|
|
577
1075
|
if (this.closed) {
|
|
578
|
-
throw new
|
|
1076
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
579
1077
|
}
|
|
580
1078
|
const argsRecord = args;
|
|
581
|
-
const mutationId = nextId();
|
|
582
|
-
const optimisticRollbacks = this.applyOptimisticUpdates(
|
|
1079
|
+
const mutationId = options.mutationId ?? nextId();
|
|
1080
|
+
const { confirms: optimisticConfirms, rollbacks: optimisticRollbacks } = this.applyOptimisticUpdates(
|
|
1081
|
+
function_.__lunoraRef,
|
|
1082
|
+
argsRecord,
|
|
1083
|
+
options.shardKey,
|
|
1084
|
+
options.optimistic
|
|
1085
|
+
);
|
|
583
1086
|
if (options.optimisticUpdate) {
|
|
584
|
-
this.applyOptimisticUpdate(options.optimisticUpdate, args, options.shardKey, optimisticRollbacks);
|
|
1087
|
+
this.applyOptimisticUpdate(options.optimisticUpdate, args, options.shardKey, optimisticRollbacks, optimisticConfirms);
|
|
585
1088
|
}
|
|
586
1089
|
const conn = this.getConnection(options.shardKey);
|
|
587
1090
|
const wsState = conn?.wsState ?? "idle";
|
|
@@ -592,46 +1095,29 @@ class LunoraClient {
|
|
|
592
1095
|
const shouldQueueOffline = this.WebSocketImpl !== void 0 && connectedGate;
|
|
593
1096
|
const midReconnect = wsState === "connecting" && connectedGate;
|
|
594
1097
|
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
|
-
});
|
|
1098
|
+
return this.enqueueOfflineMutation(function_, argsRecord, options.shardKey, mutationId, optimisticRollbacks, optimisticConfirms);
|
|
622
1099
|
}
|
|
623
1100
|
try {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
1101
|
+
let commitCursor;
|
|
1102
|
+
const result = await this.rpc(function_.__lunoraRef, argsRecord, options.shardKey, {
|
|
1103
|
+
captureBookmark: true,
|
|
1104
|
+
mutationId,
|
|
1105
|
+
onCommitCursor: (cursor) => {
|
|
1106
|
+
commitCursor = cursor;
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
for (const confirm of optimisticConfirms) {
|
|
1110
|
+
confirm(commitCursor);
|
|
628
1111
|
}
|
|
1112
|
+
return result;
|
|
1113
|
+
} catch (error) {
|
|
1114
|
+
rollbackOptimistic(optimisticRollbacks);
|
|
629
1115
|
throw error;
|
|
630
1116
|
}
|
|
631
1117
|
}
|
|
632
1118
|
async action(function_, args, options = {}) {
|
|
633
1119
|
if (this.closed) {
|
|
634
|
-
throw new
|
|
1120
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
635
1121
|
}
|
|
636
1122
|
return await this.rpc(function_.__lunoraRef, args, options.shardKey);
|
|
637
1123
|
}
|
|
@@ -648,7 +1134,7 @@ class LunoraClient {
|
|
|
648
1134
|
*/
|
|
649
1135
|
async shardTraffic(table) {
|
|
650
1136
|
if (this.closed) {
|
|
651
|
-
throw new
|
|
1137
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
652
1138
|
}
|
|
653
1139
|
const body = await this.adminFetch(SHARD_TRAFFIC_PATH, "POST", { table });
|
|
654
1140
|
return { failed: body.failed ?? 0, ok: body.ok ?? 0, shards: body.shards ?? [] };
|
|
@@ -663,7 +1149,7 @@ class LunoraClient {
|
|
|
663
1149
|
*/
|
|
664
1150
|
async listScheduledJobs() {
|
|
665
1151
|
if (this.closed) {
|
|
666
|
-
throw new
|
|
1152
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
667
1153
|
}
|
|
668
1154
|
const body = await this.adminFetch(SCHEDULED_PATH, "GET");
|
|
669
1155
|
return body.records ?? [];
|
|
@@ -679,7 +1165,7 @@ class LunoraClient {
|
|
|
679
1165
|
*/
|
|
680
1166
|
async schedulerStatus() {
|
|
681
1167
|
if (this.closed) {
|
|
682
|
-
throw new
|
|
1168
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
683
1169
|
}
|
|
684
1170
|
const body = await this.adminFetch(SCHEDULED_STATUS_PATH, "GET");
|
|
685
1171
|
return {
|
|
@@ -691,7 +1177,7 @@ class LunoraClient {
|
|
|
691
1177
|
/** Cancel a pending scheduled job by id. Returns whether a job was removed. */
|
|
692
1178
|
async cancelScheduledJob(id) {
|
|
693
1179
|
if (this.closed) {
|
|
694
|
-
throw new
|
|
1180
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
695
1181
|
}
|
|
696
1182
|
const body = await this.adminFetch(SCHEDULED_CANCEL_PATH, "POST", { id });
|
|
697
1183
|
return { cancelled: body.cancelled === true };
|
|
@@ -706,7 +1192,7 @@ class LunoraClient {
|
|
|
706
1192
|
*/
|
|
707
1193
|
async listDeadJobs() {
|
|
708
1194
|
if (this.closed) {
|
|
709
|
-
throw new
|
|
1195
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
710
1196
|
}
|
|
711
1197
|
const body = await this.adminFetch(SCHEDULED_DEAD_PATH, "GET");
|
|
712
1198
|
return body.records ?? [];
|
|
@@ -718,7 +1204,7 @@ class LunoraClient {
|
|
|
718
1204
|
*/
|
|
719
1205
|
async retryDeadJob(id) {
|
|
720
1206
|
if (this.closed) {
|
|
721
|
-
throw new
|
|
1207
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
722
1208
|
}
|
|
723
1209
|
const body = await this.adminFetch(SCHEDULED_DEAD_RETRY_PATH, "POST", { id });
|
|
724
1210
|
return { retried: body.retried === true };
|
|
@@ -730,7 +1216,7 @@ class LunoraClient {
|
|
|
730
1216
|
*/
|
|
731
1217
|
async removeDeadJob(id) {
|
|
732
1218
|
if (this.closed) {
|
|
733
|
-
throw new
|
|
1219
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
734
1220
|
}
|
|
735
1221
|
const body = await this.adminFetch(SCHEDULED_DEAD_CANCEL_PATH, "POST", { id });
|
|
736
1222
|
return { removed: body.removed === true };
|
|
@@ -739,12 +1225,16 @@ class LunoraClient {
|
|
|
739
1225
|
* List a workflow's instances via the admin Workflows proxy
|
|
740
1226
|
* (`/_lunora/admin/workflows/instances`) — the Cloudflare control-plane data
|
|
741
1227
|
* the `Workflow` binding can't expose. Requires the worker to be built with a
|
|
742
|
-
* `workflowsClient` (Cloudflare account id + API token)
|
|
743
|
-
*
|
|
1228
|
+
* `workflowsClient` (Cloudflare account id + API token). When one isn't
|
|
1229
|
+
* configured this does NOT reject: the proxy returns a `200 { configured:
|
|
1230
|
+
* false }` sentinel, so the result resolves with `configured === false` and an
|
|
1231
|
+
* empty `instances` list — callers should branch on that flag rather than
|
|
1232
|
+
* try/catch. (The instance-detail / status endpoints still reject with 501.)
|
|
1233
|
+
* `name` is the deployed workflow name.
|
|
744
1234
|
*/
|
|
745
1235
|
async listWorkflowInstances(options) {
|
|
746
1236
|
if (this.closed) {
|
|
747
|
-
throw new
|
|
1237
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
748
1238
|
}
|
|
749
1239
|
const query = new URLSearchParams({ name: options.name });
|
|
750
1240
|
if (options.status !== void 0) {
|
|
@@ -757,12 +1247,18 @@ class LunoraClient {
|
|
|
757
1247
|
query.set("perPage", String(options.perPage));
|
|
758
1248
|
}
|
|
759
1249
|
const body = await this.adminFetch(`${WORKFLOWS_INSTANCES_PATH}?${query.toString()}`, "GET");
|
|
760
|
-
return {
|
|
1250
|
+
return {
|
|
1251
|
+
configured: body.configured,
|
|
1252
|
+
instances: body.instances ?? [],
|
|
1253
|
+
page: body.page ?? 1,
|
|
1254
|
+
perPage: body.perPage ?? options.perPage ?? 0,
|
|
1255
|
+
totalCount: body.totalCount
|
|
1256
|
+
};
|
|
761
1257
|
}
|
|
762
1258
|
/** Read one workflow instance with its step timeline (`/_lunora/admin/workflows/instance`). */
|
|
763
1259
|
async getWorkflowInstance(options) {
|
|
764
1260
|
if (this.closed) {
|
|
765
|
-
throw new
|
|
1261
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
766
1262
|
}
|
|
767
1263
|
const query = new URLSearchParams({ id: options.id, name: options.name });
|
|
768
1264
|
const body = await this.adminFetch(`${WORKFLOWS_INSTANCE_PATH}?${query.toString()}`, "GET");
|
|
@@ -781,7 +1277,7 @@ class LunoraClient {
|
|
|
781
1277
|
/** Pause / resume / terminate a workflow instance (`/_lunora/admin/workflows/status`). Needs an Edit-scoped Cloudflare token. */
|
|
782
1278
|
async setWorkflowInstanceStatus(options) {
|
|
783
1279
|
if (this.closed) {
|
|
784
|
-
throw new
|
|
1280
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
785
1281
|
}
|
|
786
1282
|
const body = await this.adminFetch(WORKFLOWS_STATUS_PATH, "POST", { action: options.action, id: options.id, name: options.name });
|
|
787
1283
|
return { status: body.status ?? "unknown" };
|
|
@@ -796,7 +1292,7 @@ class LunoraClient {
|
|
|
796
1292
|
*/
|
|
797
1293
|
subscribeScheduledJobs(onJobs) {
|
|
798
1294
|
if (this.closed) {
|
|
799
|
-
throw new
|
|
1295
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
800
1296
|
}
|
|
801
1297
|
if (this.WebSocketImpl === void 0) {
|
|
802
1298
|
return () => void 0;
|
|
@@ -852,7 +1348,7 @@ class LunoraClient {
|
|
|
852
1348
|
*/
|
|
853
1349
|
async listFunctions() {
|
|
854
1350
|
if (this.closed) {
|
|
855
|
-
throw new
|
|
1351
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
856
1352
|
}
|
|
857
1353
|
const body = await this.adminFetch(FUNCTIONS_PATH, "GET");
|
|
858
1354
|
return body.functions ?? [];
|
|
@@ -868,7 +1364,7 @@ class LunoraClient {
|
|
|
868
1364
|
*/
|
|
869
1365
|
async getCronJobs() {
|
|
870
1366
|
if (this.closed) {
|
|
871
|
-
throw new
|
|
1367
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
872
1368
|
}
|
|
873
1369
|
const body = await this.adminFetch(CRON_JOBS_PATH, "GET");
|
|
874
1370
|
return body.jobs ?? [];
|
|
@@ -884,7 +1380,7 @@ class LunoraClient {
|
|
|
884
1380
|
*/
|
|
885
1381
|
async runCronJob(name) {
|
|
886
1382
|
if (this.closed) {
|
|
887
|
-
throw new
|
|
1383
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
888
1384
|
}
|
|
889
1385
|
const body = await this.adminFetch(CRON_JOBS_RUN_PATH, "POST", { name });
|
|
890
1386
|
return { name: body.name ?? name, ran: body.ran === true };
|
|
@@ -899,7 +1395,7 @@ class LunoraClient {
|
|
|
899
1395
|
*/
|
|
900
1396
|
async fetchOpenApi() {
|
|
901
1397
|
if (this.closed) {
|
|
902
|
-
throw new
|
|
1398
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
903
1399
|
}
|
|
904
1400
|
return await this.adminFetch(OPENAPI_PATH, "GET");
|
|
905
1401
|
}
|
|
@@ -915,7 +1411,7 @@ class LunoraClient {
|
|
|
915
1411
|
*/
|
|
916
1412
|
async fetchOpenRpc() {
|
|
917
1413
|
if (this.closed) {
|
|
918
|
-
throw new
|
|
1414
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
919
1415
|
}
|
|
920
1416
|
return await this.adminFetch(OPENRPC_PATH, "GET");
|
|
921
1417
|
}
|
|
@@ -929,7 +1425,7 @@ class LunoraClient {
|
|
|
929
1425
|
*/
|
|
930
1426
|
async listStorageObjects(options = {}) {
|
|
931
1427
|
if (this.closed) {
|
|
932
|
-
throw new
|
|
1428
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
933
1429
|
}
|
|
934
1430
|
const params = new URLSearchParams();
|
|
935
1431
|
if (options.prefix !== void 0 && options.prefix !== "") {
|
|
@@ -957,7 +1453,7 @@ class LunoraClient {
|
|
|
957
1453
|
*/
|
|
958
1454
|
async deleteStorageObject(key, options) {
|
|
959
1455
|
if (this.closed) {
|
|
960
|
-
throw new
|
|
1456
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
961
1457
|
}
|
|
962
1458
|
const path = `${STORAGE_PATH}?key=${encodeURIComponent(key)}${bucketQuery(options?.bucket)}`;
|
|
963
1459
|
const body = await this.adminFetch(path, "DELETE");
|
|
@@ -971,7 +1467,7 @@ class LunoraClient {
|
|
|
971
1467
|
*/
|
|
972
1468
|
async listStorageBuckets() {
|
|
973
1469
|
if (this.closed) {
|
|
974
|
-
throw new
|
|
1470
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
975
1471
|
}
|
|
976
1472
|
const body = await this.adminFetch(STORAGE_BUCKETS_PATH, "GET");
|
|
977
1473
|
return body.buckets ?? [];
|
|
@@ -985,7 +1481,7 @@ class LunoraClient {
|
|
|
985
1481
|
*/
|
|
986
1482
|
async uploadStorageObject(options) {
|
|
987
1483
|
if (this.closed) {
|
|
988
|
-
throw new
|
|
1484
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
989
1485
|
}
|
|
990
1486
|
const path = `${STORAGE_PATH}?key=${encodeURIComponent(options.key)}${bucketQuery(options.bucket)}`;
|
|
991
1487
|
const body = await this.adminFetch(path, "PUT", options.body, options.contentType);
|
|
@@ -1004,7 +1500,7 @@ class LunoraClient {
|
|
|
1004
1500
|
*/
|
|
1005
1501
|
async signedStorageUrl(key, options) {
|
|
1006
1502
|
if (this.closed) {
|
|
1007
|
-
throw new
|
|
1503
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1008
1504
|
}
|
|
1009
1505
|
const expiresInSeconds = options?.expiresInSeconds;
|
|
1010
1506
|
const expiryQuery = expiresInSeconds === void 0 ? "" : `&expiresIn=${encodeURIComponent(expiresInSeconds.toString())}`;
|
|
@@ -1024,7 +1520,7 @@ class LunoraClient {
|
|
|
1024
1520
|
*/
|
|
1025
1521
|
async listGlobalTables() {
|
|
1026
1522
|
if (this.closed) {
|
|
1027
|
-
throw new
|
|
1523
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1028
1524
|
}
|
|
1029
1525
|
return await this.adminFetch(GLOBAL_TABLES_PATH, "GET");
|
|
1030
1526
|
}
|
|
@@ -1036,7 +1532,7 @@ class LunoraClient {
|
|
|
1036
1532
|
*/
|
|
1037
1533
|
async readGlobalTablePage(options) {
|
|
1038
1534
|
if (this.closed) {
|
|
1039
|
-
throw new
|
|
1535
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1040
1536
|
}
|
|
1041
1537
|
const params = new URLSearchParams({ table: options.table });
|
|
1042
1538
|
if (options.limit !== void 0) {
|
|
@@ -1059,7 +1555,7 @@ class LunoraClient {
|
|
|
1059
1555
|
*/
|
|
1060
1556
|
async facetGlobalColumn(options) {
|
|
1061
1557
|
if (this.closed) {
|
|
1062
|
-
throw new
|
|
1558
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1063
1559
|
}
|
|
1064
1560
|
const params = new URLSearchParams({ column: options.column, table: options.table });
|
|
1065
1561
|
if (options.limit !== void 0) {
|
|
@@ -1082,7 +1578,7 @@ class LunoraClient {
|
|
|
1082
1578
|
*/
|
|
1083
1579
|
async listVectorIndexes() {
|
|
1084
1580
|
if (this.closed) {
|
|
1085
|
-
throw new
|
|
1581
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1086
1582
|
}
|
|
1087
1583
|
const body = await this.adminFetch(VECTOR_INDEXES_PATH, "GET");
|
|
1088
1584
|
return body.indexes ?? [];
|
|
@@ -1096,11 +1592,77 @@ class LunoraClient {
|
|
|
1096
1592
|
*/
|
|
1097
1593
|
async queryVectorIndex(options) {
|
|
1098
1594
|
if (this.closed) {
|
|
1099
|
-
throw new
|
|
1595
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1100
1596
|
}
|
|
1101
1597
|
const body = await this.adminFetch(VECTOR_QUERY_PATH, "POST", options);
|
|
1102
1598
|
return body.matches ?? [];
|
|
1103
1599
|
}
|
|
1600
|
+
// --- KV namespace admin -------------------------------------------------
|
|
1601
|
+
/**
|
|
1602
|
+
* List the worker's registered Workers KV namespaces (binding names). Hits
|
|
1603
|
+
* the admin-gated `GET /_lunora/admin/kv/namespaces` endpoint — the worker
|
|
1604
|
+
* must be built with a `kvIntrospector` and `adminToken`. Powers the
|
|
1605
|
+
* studio's KV browser.
|
|
1606
|
+
*/
|
|
1607
|
+
async listKvNamespaces() {
|
|
1608
|
+
if (this.closed) {
|
|
1609
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1610
|
+
}
|
|
1611
|
+
const body = await this.adminFetch(KV_NAMESPACES_PATH, "GET");
|
|
1612
|
+
return body.namespaces ?? [];
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* List keys in a KV namespace, optionally filtered by `prefix` and
|
|
1616
|
+
* paginated via `cursor`. Hits the admin-gated
|
|
1617
|
+
* `GET /_lunora/admin/kv/keys` endpoint.
|
|
1618
|
+
*/
|
|
1619
|
+
async listKvKeys(options) {
|
|
1620
|
+
if (this.closed) {
|
|
1621
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1622
|
+
}
|
|
1623
|
+
const path = withQuery(KV_KEYS_PATH, {
|
|
1624
|
+
cursor: options.cursor,
|
|
1625
|
+
limit: options.limit,
|
|
1626
|
+
namespace: options.namespace,
|
|
1627
|
+
prefix: options.prefix
|
|
1628
|
+
});
|
|
1629
|
+
return await this.adminFetch(path, "GET");
|
|
1630
|
+
}
|
|
1631
|
+
/**
|
|
1632
|
+
* Read a KV value (as text) and its metadata. Hits the admin-gated
|
|
1633
|
+
* `GET /_lunora/admin/kv/value` endpoint. Returns `{ value: null, metadata: null }`
|
|
1634
|
+
* when the key is absent.
|
|
1635
|
+
*/
|
|
1636
|
+
async getKvValue(options) {
|
|
1637
|
+
if (this.closed) {
|
|
1638
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1639
|
+
}
|
|
1640
|
+
const path = withQuery(KV_VALUE_PATH, { key: options.key, namespace: options.namespace });
|
|
1641
|
+
return await this.adminFetch(path, "GET");
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Write a string value to a KV namespace. Accepts an absolute `expiration`
|
|
1645
|
+
* (Unix seconds) or a relative `expirationTtl`, plus optional `metadata` —
|
|
1646
|
+
* re-send the loaded values on edit so a save preserves rather than clears
|
|
1647
|
+
* them. Hits the admin-gated `PUT /_lunora/admin/kv/value` endpoint.
|
|
1648
|
+
*/
|
|
1649
|
+
async putKvValue(options) {
|
|
1650
|
+
if (this.closed) {
|
|
1651
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1652
|
+
}
|
|
1653
|
+
await this.adminFetch(KV_VALUE_PATH, "PUT", options);
|
|
1654
|
+
}
|
|
1655
|
+
/**
|
|
1656
|
+
* Delete a key from a KV namespace. No-op when the key is absent. Hits the
|
|
1657
|
+
* admin-gated `DELETE /_lunora/admin/kv/value` endpoint.
|
|
1658
|
+
*/
|
|
1659
|
+
async deleteKvKey(options) {
|
|
1660
|
+
if (this.closed) {
|
|
1661
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1662
|
+
}
|
|
1663
|
+
const path = withQuery(KV_VALUE_PATH, { key: options.key, namespace: options.namespace });
|
|
1664
|
+
await this.adminFetch(path, "DELETE");
|
|
1665
|
+
}
|
|
1104
1666
|
// --- Auth admin ---------------------------------------------------------
|
|
1105
1667
|
/**
|
|
1106
1668
|
* List authenticated users, paged and optionally searched / filtered / sorted.
|
|
@@ -1110,7 +1672,7 @@ class LunoraClient {
|
|
|
1110
1672
|
*/
|
|
1111
1673
|
async listAuthUsers(options = {}) {
|
|
1112
1674
|
if (this.closed) {
|
|
1113
|
-
throw new
|
|
1675
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1114
1676
|
}
|
|
1115
1677
|
const path = withQuery(AUTH_USERS_PATH, {
|
|
1116
1678
|
filterField: options.filterField,
|
|
@@ -1222,10 +1784,90 @@ class LunoraClient {
|
|
|
1222
1784
|
async cancelAuthOrgInvitation(input) {
|
|
1223
1785
|
await this.adminFetch(AUTH_CANCEL_INVITATION_PATH, "POST", input);
|
|
1224
1786
|
}
|
|
1787
|
+
/**
|
|
1788
|
+
* Report the deployment's auth configuration — enabled plugins, sign-in
|
|
1789
|
+
* methods, user-settable create-user fields, organization sub-features
|
|
1790
|
+
* (teams / roles), and session / rate-limit policy. Drives the config panel
|
|
1791
|
+
* and the dynamic create-user form. Never carries a secret.
|
|
1792
|
+
*/
|
|
1793
|
+
async getAuthConfig() {
|
|
1794
|
+
return await this.adminFetch(AUTH_CONFIG_PATH, "GET");
|
|
1795
|
+
}
|
|
1796
|
+
/** Create an organization; optionally seed an `owner` member for `ownerId`. */
|
|
1797
|
+
async createAuthOrganization(input) {
|
|
1798
|
+
return await this.adminFetch(AUTH_CREATE_ORG_PATH, "POST", input);
|
|
1799
|
+
}
|
|
1800
|
+
/** Update an organization's name/slug/logo/metadata. */
|
|
1801
|
+
async updateAuthOrganization(input) {
|
|
1802
|
+
return await this.adminFetch(AUTH_UPDATE_ORG_PATH, "POST", input);
|
|
1803
|
+
}
|
|
1804
|
+
/** Delete an organization and cascade its members, invitations, teams, and custom roles. */
|
|
1805
|
+
async deleteAuthOrganization(input) {
|
|
1806
|
+
await this.adminFetch(AUTH_REMOVE_ORG_PATH, "POST", input);
|
|
1807
|
+
}
|
|
1808
|
+
/** Directly add an existing user to an organization (no invitation/acceptance). */
|
|
1809
|
+
async addAuthOrgMember(input) {
|
|
1810
|
+
return await this.adminFetch(AUTH_ADD_MEMBER_PATH, "POST", input);
|
|
1811
|
+
}
|
|
1812
|
+
/** Create a pending email invitation to an organization. */
|
|
1813
|
+
async inviteAuthOrgMember(input) {
|
|
1814
|
+
return await this.adminFetch(AUTH_INVITE_MEMBER_PATH, "POST", input);
|
|
1815
|
+
}
|
|
1816
|
+
/** Change a member's role. */
|
|
1817
|
+
async setAuthOrgMemberRole(input) {
|
|
1818
|
+
return await this.adminFetch(AUTH_MEMBER_ROLE_PATH, "POST", input);
|
|
1819
|
+
}
|
|
1820
|
+
/** List an organization's teams (requires the organization plugin with teams enabled). */
|
|
1821
|
+
async listAuthOrgTeams(input) {
|
|
1822
|
+
const path = withQuery(AUTH_ORG_TEAMS_PATH, { limit: input.limit, offset: input.offset, organizationId: input.organizationId });
|
|
1823
|
+
return await this.adminFetch(path, "GET");
|
|
1824
|
+
}
|
|
1825
|
+
/** Create a team under an organization. */
|
|
1826
|
+
async createAuthOrgTeam(input) {
|
|
1827
|
+
return await this.adminFetch(AUTH_CREATE_TEAM_PATH, "POST", input);
|
|
1828
|
+
}
|
|
1829
|
+
/** Rename a team. */
|
|
1830
|
+
async updateAuthOrgTeam(input) {
|
|
1831
|
+
return await this.adminFetch(AUTH_UPDATE_TEAM_PATH, "POST", input);
|
|
1832
|
+
}
|
|
1833
|
+
/** Delete a team and its memberships. */
|
|
1834
|
+
async removeAuthOrgTeam(input) {
|
|
1835
|
+
await this.adminFetch(AUTH_REMOVE_TEAM_PATH, "POST", input);
|
|
1836
|
+
}
|
|
1837
|
+
/** List a team's members. */
|
|
1838
|
+
async listAuthOrgTeamMembers(input) {
|
|
1839
|
+
const path = withQuery(AUTH_ORG_TEAM_MEMBERS_PATH, { limit: input.limit, offset: input.offset, teamId: input.teamId });
|
|
1840
|
+
return await this.adminFetch(path, "GET");
|
|
1841
|
+
}
|
|
1842
|
+
/** Add a user to a team. */
|
|
1843
|
+
async addAuthOrgTeamMember(input) {
|
|
1844
|
+
return await this.adminFetch(AUTH_ADD_TEAM_MEMBER_PATH, "POST", input);
|
|
1845
|
+
}
|
|
1846
|
+
/** Remove a member from a team. */
|
|
1847
|
+
async removeAuthOrgTeamMember(input) {
|
|
1848
|
+
await this.adminFetch(AUTH_REMOVE_TEAM_MEMBER_PATH, "POST", input);
|
|
1849
|
+
}
|
|
1850
|
+
/** List an organization's custom roles (requires the organization plugin with dynamic access control). */
|
|
1851
|
+
async listAuthOrgRoles(input) {
|
|
1852
|
+
const path = withQuery(AUTH_ORG_ROLES_PATH, { limit: input.limit, offset: input.offset, organizationId: input.organizationId });
|
|
1853
|
+
return await this.adminFetch(path, "GET");
|
|
1854
|
+
}
|
|
1855
|
+
/** Create a custom org role with a permission grant (a `resource -> actions[]` map). */
|
|
1856
|
+
async createAuthOrgRole(input) {
|
|
1857
|
+
return await this.adminFetch(AUTH_CREATE_ROLE_PATH, "POST", input);
|
|
1858
|
+
}
|
|
1859
|
+
/** Replace a custom org role's permission grant. */
|
|
1860
|
+
async updateAuthOrgRole(input) {
|
|
1861
|
+
return await this.adminFetch(AUTH_UPDATE_ROLE_PATH, "POST", input);
|
|
1862
|
+
}
|
|
1863
|
+
/** Delete a custom org role. */
|
|
1864
|
+
async deleteAuthOrgRole(input) {
|
|
1865
|
+
await this.adminFetch(AUTH_REMOVE_ROLE_PATH, "POST", input);
|
|
1866
|
+
}
|
|
1225
1867
|
/** List auth sessions, paged and optionally filtered to one user. */
|
|
1226
1868
|
async listAuthSessions(options = {}) {
|
|
1227
1869
|
if (this.closed) {
|
|
1228
|
-
throw new
|
|
1870
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1229
1871
|
}
|
|
1230
1872
|
const params = new URLSearchParams();
|
|
1231
1873
|
if (options.userId !== void 0 && options.userId !== "") {
|
|
@@ -1243,7 +1885,7 @@ class LunoraClient {
|
|
|
1243
1885
|
// --- Subscriptions ------------------------------------------------------
|
|
1244
1886
|
subscribe(function_, args, callback, options = {}) {
|
|
1245
1887
|
if (this.closed) {
|
|
1246
|
-
throw new
|
|
1888
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1247
1889
|
}
|
|
1248
1890
|
const argsRecord = args ?? {};
|
|
1249
1891
|
const key = SubscriptionRegistry.key(function_.__lunoraRef, argsRecord, options.shardKey);
|
|
@@ -1260,12 +1902,14 @@ class LunoraClient {
|
|
|
1260
1902
|
args: argsRecord,
|
|
1261
1903
|
argsKey,
|
|
1262
1904
|
callbacks: /* @__PURE__ */ new Set(),
|
|
1905
|
+
checkpointCallbacks: /* @__PURE__ */ new Set(),
|
|
1263
1906
|
errorCallbacks: /* @__PURE__ */ new Set(),
|
|
1264
1907
|
fn: function_,
|
|
1265
1908
|
id,
|
|
1266
1909
|
lastValue: cached?.value,
|
|
1910
|
+
optimisticLayers: [],
|
|
1911
|
+
serverBase: cached?.value,
|
|
1267
1912
|
serverCursor: cached?.serverCursor,
|
|
1268
|
-
serverVersion: 0,
|
|
1269
1913
|
shardKey: options.shardKey,
|
|
1270
1914
|
...cached?.serverEpoch === void 0 ? {} : { serverEpoch: cached.serverEpoch }
|
|
1271
1915
|
};
|
|
@@ -1275,6 +1919,9 @@ class LunoraClient {
|
|
|
1275
1919
|
if (errorCallback) {
|
|
1276
1920
|
state.errorCallbacks.add(errorCallback);
|
|
1277
1921
|
}
|
|
1922
|
+
if (options.onCheckpoint) {
|
|
1923
|
+
state.checkpointCallbacks.add(options.onCheckpoint);
|
|
1924
|
+
}
|
|
1278
1925
|
if (state.lastValue !== void 0) {
|
|
1279
1926
|
try {
|
|
1280
1927
|
subscriptionCallback(state.lastValue);
|
|
@@ -1289,16 +1936,59 @@ class LunoraClient {
|
|
|
1289
1936
|
if (errorCallback) {
|
|
1290
1937
|
subscriptionState.errorCallbacks.delete(errorCallback);
|
|
1291
1938
|
}
|
|
1939
|
+
if (options.onCheckpoint) {
|
|
1940
|
+
subscriptionState.checkpointCallbacks.delete(options.onCheckpoint);
|
|
1941
|
+
}
|
|
1292
1942
|
if (subscriptionState.callbacks.size === 0) {
|
|
1293
1943
|
const conn = this.getConnection(subscriptionState.shardKey);
|
|
1294
1944
|
const ok = conn ? sendOn(conn, { id: subscriptionState.id, type: "unsubscribe" }) : false;
|
|
1295
1945
|
if (!ok && conn) {
|
|
1296
|
-
conn.pendingUnsubscribes.push(subscriptionState.id);
|
|
1946
|
+
conn.pendingUnsubscribes.push({ id: subscriptionState.id, type: "unsubscribe" });
|
|
1297
1947
|
}
|
|
1298
1948
|
this.subscriptions.remove(subscriptionState);
|
|
1299
1949
|
}
|
|
1300
1950
|
};
|
|
1301
1951
|
}
|
|
1952
|
+
/**
|
|
1953
|
+
* Subscribe to a declarative **shape** — server-side partial replication
|
|
1954
|
+
* scoped by `shardBy` + the shape's predicate + RLS. The parallel to
|
|
1955
|
+
* {@link subscribe} for the poke protocol: the client sends the shape *name* +
|
|
1956
|
+
* validated `args` (never a `where` the client could forge), the server seeds
|
|
1957
|
+
* the current membership as an insert-poke and streams live membership diffs.
|
|
1958
|
+
* Each applied poke materializes the shape's rowset and invokes `callback`.
|
|
1959
|
+
*
|
|
1960
|
+
* Unlike {@link subscribe}, shape subscriptions are NOT deduped by
|
|
1961
|
+
* (name, args): the server resolves them under the socket's verified identity,
|
|
1962
|
+
* so every call gets its own id + view. The returned function unsubscribes.
|
|
1963
|
+
*/
|
|
1964
|
+
subscribeShape(shape, callback, options = {}) {
|
|
1965
|
+
if (this.closed) {
|
|
1966
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1967
|
+
}
|
|
1968
|
+
this.nextShapeId += 1;
|
|
1969
|
+
const id = `shape_${this.nextShapeId.toString()}`;
|
|
1970
|
+
const state = {
|
|
1971
|
+
args: shape.args,
|
|
1972
|
+
callbacks: /* @__PURE__ */ new Set([callback]),
|
|
1973
|
+
errorCallbacks: options.onError ? /* @__PURE__ */ new Set([options.onError]) : /* @__PURE__ */ new Set(),
|
|
1974
|
+
id,
|
|
1975
|
+
name: shape.name,
|
|
1976
|
+
onCheckpoint: options.onCheckpoint,
|
|
1977
|
+
rows: /* @__PURE__ */ new Map(),
|
|
1978
|
+
shardKey: options.shardKey
|
|
1979
|
+
};
|
|
1980
|
+
this.shapeSubscriptions.set(id, state);
|
|
1981
|
+
this.ensureSocket(options.shardKey);
|
|
1982
|
+
this.sendShapeSubscribeIfOpen(state);
|
|
1983
|
+
return () => {
|
|
1984
|
+
this.shapeSubscriptions.delete(id);
|
|
1985
|
+
const conn = this.getConnection(state.shardKey);
|
|
1986
|
+
const ok = conn ? sendOn(conn, { id, type: "shape_unsubscribe" }) : false;
|
|
1987
|
+
if (!ok && conn) {
|
|
1988
|
+
conn.pendingUnsubscribes.push({ id, type: "shape_unsubscribe" });
|
|
1989
|
+
}
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1302
1992
|
/**
|
|
1303
1993
|
* Open a streaming query. The function reference must be a
|
|
1304
1994
|
* `kind:"stream"` registration (built with `c.query.input(...).stream(...)`);
|
|
@@ -1319,10 +2009,10 @@ class LunoraClient {
|
|
|
1319
2009
|
*/
|
|
1320
2010
|
stream(function_, args, options = {}) {
|
|
1321
2011
|
if (this.closed) {
|
|
1322
|
-
throw new
|
|
2012
|
+
throw new LunoraError("INTERNAL", "LunoraClient is closed");
|
|
1323
2013
|
}
|
|
1324
2014
|
if (this.WebSocketImpl === void 0) {
|
|
1325
|
-
throw new
|
|
2015
|
+
throw new LunoraError("INTERNAL", "LunoraClient: streams require a WebSocket implementation");
|
|
1326
2016
|
}
|
|
1327
2017
|
this.nextStreamId += 1;
|
|
1328
2018
|
const id = `stream_${this.nextStreamId.toString()}`;
|
|
@@ -1343,7 +2033,14 @@ class LunoraClient {
|
|
|
1343
2033
|
const conn = this.getConnection(shardKey);
|
|
1344
2034
|
const message = {
|
|
1345
2035
|
id,
|
|
1346
|
-
|
|
2036
|
+
// Wire-encode the stream args so `bigint`/bytes survive the send (raw
|
|
2037
|
+
// `JSON.stringify` throws on a bigint); the shard `decodeWire`s them
|
|
2038
|
+
// before invoking the stream handler.
|
|
2039
|
+
query: {
|
|
2040
|
+
args: encodeCallArgs(argsRecord, `stream args for '${function_.__lunoraRef}'`),
|
|
2041
|
+
functionPath: function_.__lunoraRef,
|
|
2042
|
+
shardKey
|
|
2043
|
+
},
|
|
1347
2044
|
type: "stream"
|
|
1348
2045
|
};
|
|
1349
2046
|
const sentImmediately = conn?.wsState === "open" && sendOn(conn, message);
|
|
@@ -1354,9 +2051,7 @@ class LunoraClient {
|
|
|
1354
2051
|
const droppedId = dropped?.id;
|
|
1355
2052
|
const droppedStream = droppedId ? this.streams.get(droppedId) : void 0;
|
|
1356
2053
|
if (droppedStream) {
|
|
1357
|
-
droppedStream.handle.fail(
|
|
1358
|
-
Object.assign(new Error("stream-start frame evicted while socket was unreachable"), { code: "STREAM_QUEUE_OVERFLOW" })
|
|
1359
|
-
);
|
|
2054
|
+
droppedStream.handle.fail(new LunoraError("STREAM_QUEUE_OVERFLOW", "stream-start frame evicted while socket was unreachable"));
|
|
1360
2055
|
this.streams.delete(droppedId);
|
|
1361
2056
|
}
|
|
1362
2057
|
}
|
|
@@ -1366,8 +2061,10 @@ class LunoraClient {
|
|
|
1366
2061
|
}
|
|
1367
2062
|
close() {
|
|
1368
2063
|
this.closed = true;
|
|
2064
|
+
this.outboxLeaderRelease?.();
|
|
2065
|
+
this.outboxLeaderRelease = void 0;
|
|
1369
2066
|
for (const stream of this.streams.values()) {
|
|
1370
|
-
stream.handle.fail(
|
|
2067
|
+
stream.handle.fail(new LunoraError("CLIENT_CLOSED", "LunoraClient closed"));
|
|
1371
2068
|
}
|
|
1372
2069
|
this.streams.clear();
|
|
1373
2070
|
for (const conn of this.connections.values()) {
|
|
@@ -1375,6 +2072,10 @@ class LunoraClient {
|
|
|
1375
2072
|
clearTimeout(conn.reconnectTimer);
|
|
1376
2073
|
conn.reconnectTimer = void 0;
|
|
1377
2074
|
}
|
|
2075
|
+
if (conn.connectTimer !== void 0) {
|
|
2076
|
+
clearTimeout(conn.connectTimer);
|
|
2077
|
+
conn.connectTimer = void 0;
|
|
2078
|
+
}
|
|
1378
2079
|
this.stopHeartbeat(conn);
|
|
1379
2080
|
if (conn.socket) {
|
|
1380
2081
|
try {
|
|
@@ -1397,9 +2098,83 @@ class LunoraClient {
|
|
|
1397
2098
|
this.authTokenListeners.clear();
|
|
1398
2099
|
this.statusListeners.clear();
|
|
1399
2100
|
this.tokenExpiredListeners.clear();
|
|
2101
|
+
this.mutationSettledListeners.clear();
|
|
2102
|
+
this.pendingChangeListeners.clear();
|
|
1400
2103
|
this.whisperHandlers.clear();
|
|
2104
|
+
this.shapeSubscriptions.clear();
|
|
2105
|
+
this.pokeBuffers.clear();
|
|
1401
2106
|
}
|
|
1402
2107
|
// --- Internals ----------------------------------------------------------
|
|
2108
|
+
/**
|
|
2109
|
+
* Persist a mutation that can't go out on the wire right now (offline, or
|
|
2110
|
+
* mid-reconnect after a prior connect). The optimistic update has already
|
|
2111
|
+
* been applied by `mutation`; this only chooses the durable write path and
|
|
2112
|
+
* rolls the optimistic write back if persistence is rejected.
|
|
2113
|
+
*
|
|
2114
|
+
* Two paths: when an `outbox` sink is wired (the `@lunora/db` executor) it
|
|
2115
|
+
* owns persistence + at-least-once replay, so we delegate and return
|
|
2116
|
+
* optimistically (confirmation rides the synced view). Otherwise the
|
|
2117
|
+
* built-in `OfflineQueue` resolves/rejects the returned promise on replay.
|
|
2118
|
+
*/
|
|
2119
|
+
async enqueueOfflineMutation(function_, argsRecord, shardKey, mutationId, optimisticRollbacks, optimisticConfirms) {
|
|
2120
|
+
const issuingIdentity = this.identityFingerprint();
|
|
2121
|
+
if (this.outbox) {
|
|
2122
|
+
this.outboxMutationCounter += 1;
|
|
2123
|
+
const outboxMutationId = this.outboxMutationCounter;
|
|
2124
|
+
try {
|
|
2125
|
+
await this.outbox.enqueue({
|
|
2126
|
+
args: argsRecord,
|
|
2127
|
+
clientId: this.clientId,
|
|
2128
|
+
functionPath: function_.__lunoraRef,
|
|
2129
|
+
idempotencyKey: `${this.clientId}:${String(outboxMutationId)}`,
|
|
2130
|
+
identity: issuingIdentity,
|
|
2131
|
+
mutationId: outboxMutationId,
|
|
2132
|
+
shardKey
|
|
2133
|
+
});
|
|
2134
|
+
} catch (error) {
|
|
2135
|
+
rollbackOptimistic(optimisticRollbacks);
|
|
2136
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
2137
|
+
}
|
|
2138
|
+
for (const confirm of optimisticConfirms) {
|
|
2139
|
+
confirm(void 0);
|
|
2140
|
+
}
|
|
2141
|
+
return void 0;
|
|
2142
|
+
}
|
|
2143
|
+
return new Promise((resolve, reject) => {
|
|
2144
|
+
const entry = {
|
|
2145
|
+
args: argsRecord,
|
|
2146
|
+
functionPath: function_.__lunoraRef,
|
|
2147
|
+
// A live caller is awaiting this Promise, so a terminal verdict
|
|
2148
|
+
// reaches them directly; the observer event carries
|
|
2149
|
+
// `hadAwaiter: true`. Hydrated replays leave this unset.
|
|
2150
|
+
liveAwaiter: true,
|
|
2151
|
+
// Reuse the call's idempotency key as the queue id so the replay
|
|
2152
|
+
// carries the same `x-lunora-mutation-id` the server dedups on.
|
|
2153
|
+
id: mutationId,
|
|
2154
|
+
// Persist the stamp alongside the record so a hydrated write can
|
|
2155
|
+
// only replay under the identity that queued it.
|
|
2156
|
+
identity: issuingIdentity,
|
|
2157
|
+
// Confirm the per-call optimistic layer(s) against the commit cursor
|
|
2158
|
+
// the flush replay echoes (see flushOfflineQueue).
|
|
2159
|
+
onCommit: (commitCursor) => {
|
|
2160
|
+
for (const confirm of optimisticConfirms) {
|
|
2161
|
+
confirm(commitCursor);
|
|
2162
|
+
}
|
|
2163
|
+
},
|
|
2164
|
+
reject: (error) => {
|
|
2165
|
+
this.queuedIdentities.delete(mutationId);
|
|
2166
|
+
rollbackOptimistic(optimisticRollbacks);
|
|
2167
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
2168
|
+
},
|
|
2169
|
+
resolve,
|
|
2170
|
+
shardKey
|
|
2171
|
+
};
|
|
2172
|
+
this.offlineQueue.enqueue(entry);
|
|
2173
|
+
if (entry.id !== void 0) {
|
|
2174
|
+
this.queuedIdentities.set(entry.id, issuingIdentity);
|
|
2175
|
+
}
|
|
2176
|
+
});
|
|
2177
|
+
}
|
|
1403
2178
|
/**
|
|
1404
2179
|
* Restore offline mutations persisted in a prior session and open a socket
|
|
1405
2180
|
* for each shard they target so they flush once the WS reconnects. Failures
|
|
@@ -1414,6 +2189,38 @@ class LunoraClient {
|
|
|
1414
2189
|
} catch {
|
|
1415
2190
|
}
|
|
1416
2191
|
}
|
|
2192
|
+
/**
|
|
2193
|
+
* Re-queue the durable offline writes — but only as the multi-tab LEADER. The
|
|
2194
|
+
* persisted queue is shared across a profile's tabs; without coordination
|
|
2195
|
+
* every tab would re-queue and replay the same writes (correct only because
|
|
2196
|
+
* the server dedups by idempotency key, but wasteful + racy). A Web Lock makes
|
|
2197
|
+
* exactly one tab hydrate; it holds the lock for its lifetime, so when it
|
|
2198
|
+
* closes another tab acquires the lock and takes over. Falls back to
|
|
2199
|
+
* unconditional hydration where Web Locks are unavailable (React Native, older
|
|
2200
|
+
* browsers, SSR) — single-context there, so no coordination is needed.
|
|
2201
|
+
*/
|
|
2202
|
+
hydrateAsOutboxLeader() {
|
|
2203
|
+
const hydrate = () => {
|
|
2204
|
+
this.hydratePersistedQueue().catch(() => void 0);
|
|
2205
|
+
};
|
|
2206
|
+
const locks = globalThis.navigator?.locks;
|
|
2207
|
+
if (!locks) {
|
|
2208
|
+
hydrate();
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
locks.request(`lunora:outbox-leader:${this.url}`, () => {
|
|
2212
|
+
if (!this.closed) {
|
|
2213
|
+
hydrate();
|
|
2214
|
+
}
|
|
2215
|
+
return new Promise((resolve) => {
|
|
2216
|
+
if (this.closed) {
|
|
2217
|
+
resolve();
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
2220
|
+
this.outboxLeaderRelease = resolve;
|
|
2221
|
+
});
|
|
2222
|
+
}).catch(hydrate);
|
|
2223
|
+
}
|
|
1417
2224
|
/**
|
|
1418
2225
|
* Load every cached query into {@link hydratedQueryCache} so the next
|
|
1419
2226
|
* `subscribe()` for each key seeds its initial value off disk. A
|
|
@@ -1428,6 +2235,10 @@ class LunoraClient {
|
|
|
1428
2235
|
try {
|
|
1429
2236
|
const entries = await this.queryCache.load();
|
|
1430
2237
|
for (const { key, ...entry } of entries) {
|
|
2238
|
+
if (isStaleVersion(this.persistenceVersion, entry.version)) {
|
|
2239
|
+
this.queryCache.remove(key).catch(() => void 0);
|
|
2240
|
+
continue;
|
|
2241
|
+
}
|
|
1431
2242
|
this.hydratedQueryCache.set(key, entry);
|
|
1432
2243
|
}
|
|
1433
2244
|
} catch {
|
|
@@ -1456,7 +2267,8 @@ class LunoraClient {
|
|
|
1456
2267
|
* (nothing to render offline).
|
|
1457
2268
|
*/
|
|
1458
2269
|
persistQueryValue(state) {
|
|
1459
|
-
|
|
2270
|
+
const authoritative = state.serverBase;
|
|
2271
|
+
if (!this.queryCache || authoritative === void 0) {
|
|
1460
2272
|
return;
|
|
1461
2273
|
}
|
|
1462
2274
|
const key = queryCacheKey(state.fn.__lunoraRef, state.argsKey, state.shardKey);
|
|
@@ -1464,8 +2276,9 @@ class LunoraClient {
|
|
|
1464
2276
|
identity: this.identityFingerprint(),
|
|
1465
2277
|
serverCursor: state.serverCursor,
|
|
1466
2278
|
ts: Date.now(),
|
|
1467
|
-
value:
|
|
1468
|
-
...state.serverEpoch === void 0 ? {} : { serverEpoch: state.serverEpoch }
|
|
2279
|
+
value: authoritative,
|
|
2280
|
+
...state.serverEpoch === void 0 ? {} : { serverEpoch: state.serverEpoch },
|
|
2281
|
+
...this.persistenceVersion === void 0 ? {} : { version: this.persistenceVersion }
|
|
1469
2282
|
});
|
|
1470
2283
|
this.cacheFlushTimer ??= setTimeout(() => {
|
|
1471
2284
|
this.flushQueryCacheWrites().catch(() => void 0);
|
|
@@ -1504,48 +2317,70 @@ class LunoraClient {
|
|
|
1504
2317
|
return;
|
|
1505
2318
|
}
|
|
1506
2319
|
this.lastStatus = next;
|
|
1507
|
-
|
|
1508
|
-
try {
|
|
1509
|
-
listener(next);
|
|
1510
|
-
} catch {
|
|
1511
|
-
}
|
|
1512
|
-
}
|
|
2320
|
+
this.statusListeners.emit(next);
|
|
1513
2321
|
}
|
|
1514
2322
|
/**
|
|
1515
|
-
*
|
|
1516
|
-
*
|
|
1517
|
-
*
|
|
1518
|
-
*
|
|
1519
|
-
|
|
2323
|
+
* Build a {@link MutationSettledEvent} from a queued entry and emit it on the
|
|
2324
|
+
* {@link onMutationSettled} channel. `item.id` is always assigned by the time
|
|
2325
|
+
* a write settles (`enqueue`/`hydrate` guarantee it), so the `?? ""` fallback
|
|
2326
|
+
* is unreachable — present only to satisfy the optional queue-id type.
|
|
2327
|
+
*/
|
|
2328
|
+
emitItemSettled(item, status, error) {
|
|
2329
|
+
this.mutationSettledListeners.emit({
|
|
2330
|
+
args: item.args,
|
|
2331
|
+
code: error === void 0 ? void 0 : error.code,
|
|
2332
|
+
error,
|
|
2333
|
+
functionPath: item.functionPath,
|
|
2334
|
+
hadAwaiter: item.liveAwaiter ?? false,
|
|
2335
|
+
id: item.id ?? "",
|
|
2336
|
+
shardKey: item.shardKey,
|
|
2337
|
+
status
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
/**
|
|
2341
|
+
* Apply an optimistic update to the subscription that matches the mutation's
|
|
2342
|
+
* `(functionRef, args, shardKey)` triple, returning the rollback callbacks to
|
|
2343
|
+
* invoke if the mutation later fails.
|
|
2344
|
+
*
|
|
2345
|
+
* The registry is already indexed by exactly this triple via
|
|
2346
|
+
* `SubscriptionRegistry.key`, so at most one subscription can match. A direct
|
|
2347
|
+
* O(1) keyed lookup replaces the former O(N) linear scan over all subscriptions.
|
|
2348
|
+
*
|
|
2349
|
+
* `shardKey` normalization: both `undefined` and `""` map to the empty string
|
|
2350
|
+
* inside `SubscriptionRegistry.key` (via `?? ""`), so a mutation fired without
|
|
2351
|
+
* a shardKey correctly matches a subscription registered without one regardless
|
|
2352
|
+
* of whether the caller passed `undefined` or omitted the field.
|
|
1520
2353
|
*/
|
|
1521
2354
|
applyOptimisticUpdates(functionRef, argsRecord, mutationShardKey, optimistic) {
|
|
1522
|
-
const
|
|
2355
|
+
const confirms = [];
|
|
2356
|
+
const rollbacks = [];
|
|
1523
2357
|
if (!optimistic) {
|
|
1524
|
-
return
|
|
2358
|
+
return { confirms, rollbacks };
|
|
1525
2359
|
}
|
|
1526
|
-
const
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
optimisticRollbacks.push(rollback);
|
|
2360
|
+
const matchKey = SubscriptionRegistry.key(functionRef, argsRecord, mutationShardKey);
|
|
2361
|
+
const state = this.subscriptions.get(matchKey);
|
|
2362
|
+
if (state) {
|
|
2363
|
+
const handle = applyOptimisticLayer(state, optimistic);
|
|
2364
|
+
if (handle) {
|
|
2365
|
+
confirms.push(handle.confirm);
|
|
2366
|
+
rollbacks.push(handle.rollback);
|
|
1534
2367
|
}
|
|
1535
2368
|
}
|
|
1536
|
-
return
|
|
2369
|
+
return { confirms, rollbacks };
|
|
1537
2370
|
}
|
|
1538
2371
|
/**
|
|
1539
2372
|
* Run a Convex-parity `optimisticUpdate` callback against a localStore bound
|
|
1540
|
-
* to the live subscription registry
|
|
1541
|
-
*
|
|
1542
|
-
*
|
|
1543
|
-
*
|
|
1544
|
-
*
|
|
1545
|
-
*
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
2373
|
+
* to the live subscription registry. Each `setQuery` registers a constant
|
|
2374
|
+
* optimistic LAYER on its target subscription (via the same engine the
|
|
2375
|
+
* per-call `optimistic` path uses), so the multi-query patch rebases onto
|
|
2376
|
+
* incoming deltas and drops gaplessly on its commit cursor — its `confirm` /
|
|
2377
|
+
* `rollback` closures are appended to the mutation's settle lists. A throwing
|
|
2378
|
+
* callback unwinds its own partial writes — LIFO over just the rollbacks it
|
|
2379
|
+
* produced — and is swallowed, so a buggy optimistic update can never fail the
|
|
2380
|
+
* mutation or leave a partial patch live.
|
|
2381
|
+
*/
|
|
2382
|
+
applyOptimisticUpdate(optimisticUpdate, args, shardKey, optimisticRollbacks, optimisticConfirms) {
|
|
2383
|
+
const { confirms, rollbacks, store } = createLocalStore(this.subscriptions, shardKey, stableStringify);
|
|
1549
2384
|
try {
|
|
1550
2385
|
optimisticUpdate(store, args);
|
|
1551
2386
|
} catch {
|
|
@@ -1555,6 +2390,7 @@ class LunoraClient {
|
|
|
1555
2390
|
return;
|
|
1556
2391
|
}
|
|
1557
2392
|
optimisticRollbacks.push(...rollbacks);
|
|
2393
|
+
optimisticConfirms.push(...confirms);
|
|
1558
2394
|
}
|
|
1559
2395
|
getConnection(shardKey) {
|
|
1560
2396
|
return this.connections.get(connectionKey(shardKey));
|
|
@@ -1564,6 +2400,7 @@ class LunoraClient {
|
|
|
1564
2400
|
let conn = this.connections.get(key);
|
|
1565
2401
|
if (!conn) {
|
|
1566
2402
|
conn = {
|
|
2403
|
+
connectTimer: void 0,
|
|
1567
2404
|
heartbeatTimer: void 0,
|
|
1568
2405
|
pendingUnsubscribes: [],
|
|
1569
2406
|
reconnect: createReconnect(this.reconnectOptions),
|
|
@@ -1607,6 +2444,12 @@ class LunoraClient {
|
|
|
1607
2444
|
if (flags.mutationId) {
|
|
1608
2445
|
headers["x-lunora-mutation-id"] = flags.mutationId;
|
|
1609
2446
|
}
|
|
2447
|
+
if (flags.clientId !== void 0) {
|
|
2448
|
+
headers["x-lunora-client-id"] = flags.clientId;
|
|
2449
|
+
}
|
|
2450
|
+
if (flags.clientSeq !== void 0) {
|
|
2451
|
+
headers["x-lunora-client-seq"] = flags.clientSeq.toString();
|
|
2452
|
+
}
|
|
1610
2453
|
if (flags.attachBookmark) {
|
|
1611
2454
|
const bookmark = this.bookmark.get();
|
|
1612
2455
|
if (bookmark) {
|
|
@@ -1617,11 +2460,14 @@ class LunoraClient {
|
|
|
1617
2460
|
}
|
|
1618
2461
|
async rpc(functionPath, args, shardKey, flags = {}) {
|
|
1619
2462
|
if (!this.fetchImpl) {
|
|
1620
|
-
throw new
|
|
2463
|
+
throw new LunoraError("INTERNAL", "LunoraClient: no `fetch` implementation available");
|
|
1621
2464
|
}
|
|
1622
2465
|
const headers = this.rpcRequestHeaders(flags);
|
|
1623
2466
|
const response = await this.fetchImpl(joinUrl(this.url, RPC_PATH), {
|
|
1624
|
-
|
|
2467
|
+
// `encodeWire` tags leaves plain JSON can't carry (`bigint`,
|
|
2468
|
+
// `ArrayBuffer`/typed arrays, `NaN`/±Infinity); a pure-JSON `args`
|
|
2469
|
+
// encodes byte-identically, so a pre-codec server still interops.
|
|
2470
|
+
body: JSON.stringify({ args: encodeCallArgs(args, `args for '${functionPath}'`), functionPath, shardKey }),
|
|
1625
2471
|
headers,
|
|
1626
2472
|
method: "POST"
|
|
1627
2473
|
});
|
|
@@ -1636,18 +2482,18 @@ class LunoraClient {
|
|
|
1636
2482
|
body = await response.json();
|
|
1637
2483
|
} catch {
|
|
1638
2484
|
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
1639
|
-
throw new
|
|
2485
|
+
throw new LunoraError("INTERNAL", `LunoraClient: response was not JSON (status ${response.status.toString()}${statusText})`);
|
|
1640
2486
|
}
|
|
1641
2487
|
if ("error" in body) {
|
|
1642
|
-
|
|
1643
|
-
error.code = body.error.code;
|
|
1644
|
-
throw error;
|
|
2488
|
+
throw reconstructError(body.error);
|
|
1645
2489
|
}
|
|
1646
2490
|
if (!response.ok) {
|
|
1647
2491
|
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
1648
|
-
throw new
|
|
2492
|
+
throw new LunoraError("INTERNAL", `LunoraClient: request failed (status ${response.status.toString()}${statusText})`);
|
|
1649
2493
|
}
|
|
1650
|
-
|
|
2494
|
+
flags.onMutationAck?.(body.lastMutationId);
|
|
2495
|
+
flags.onCommitCursor?.(body.commitCursor);
|
|
2496
|
+
return decodeWire(body.result);
|
|
1651
2497
|
}
|
|
1652
2498
|
/**
|
|
1653
2499
|
* Authenticated request to a non-RPC admin endpoint (the scheduler list /
|
|
@@ -1657,7 +2503,7 @@ class LunoraClient {
|
|
|
1657
2503
|
*/
|
|
1658
2504
|
async adminFetch(path, method, payload, contentType) {
|
|
1659
2505
|
if (!this.fetchImpl) {
|
|
1660
|
-
throw new
|
|
2506
|
+
throw new LunoraError("INTERNAL", "LunoraClient: no `fetch` implementation available");
|
|
1661
2507
|
}
|
|
1662
2508
|
const headers = {};
|
|
1663
2509
|
if (this.authToken) {
|
|
@@ -1686,7 +2532,7 @@ class LunoraClient {
|
|
|
1686
2532
|
body = await response.json();
|
|
1687
2533
|
} catch {
|
|
1688
2534
|
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
1689
|
-
throw new
|
|
2535
|
+
throw new LunoraError("INTERNAL", `LunoraClient: response was not JSON (status ${response.status.toString()}${statusText})`);
|
|
1690
2536
|
}
|
|
1691
2537
|
if (typeof body === "object" && body !== null && "error" in body) {
|
|
1692
2538
|
const envelope = body.error;
|
|
@@ -1696,7 +2542,7 @@ class LunoraClient {
|
|
|
1696
2542
|
}
|
|
1697
2543
|
if (!response.ok) {
|
|
1698
2544
|
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
1699
|
-
throw new
|
|
2545
|
+
throw new LunoraError("INTERNAL", `LunoraClient: admin request failed (status ${response.status.toString()}${statusText})`);
|
|
1700
2546
|
}
|
|
1701
2547
|
return body;
|
|
1702
2548
|
}
|
|
@@ -1737,11 +2583,27 @@ class LunoraClient {
|
|
|
1737
2583
|
sendConnectEnvelope(conn) {
|
|
1738
2584
|
const context = this.effectiveConnectionContext(connectionKey(conn.shardKey));
|
|
1739
2585
|
sendOn(conn, {
|
|
2586
|
+
// Lets the server scope this connection's `__client_watermark` so
|
|
2587
|
+
// custom-mutator pokes can echo this client's `lastMutationId`.
|
|
2588
|
+
clientId: this.clientId,
|
|
1740
2589
|
id: "connect",
|
|
1741
2590
|
type: "connect",
|
|
1742
2591
|
...context === void 0 ? {} : { context }
|
|
1743
2592
|
});
|
|
1744
2593
|
}
|
|
2594
|
+
/**
|
|
2595
|
+
* Re-send every shape subscription bound to `shardKey` over its (now open)
|
|
2596
|
+
* socket. Each frame carries the shape's last applied checkpoint, so the
|
|
2597
|
+
* server resumes from it — or re-seeds when the cursor fell below CDC
|
|
2598
|
+
* retention or the epoch forked.
|
|
2599
|
+
*/
|
|
2600
|
+
resendShapeSubscriptions(shardKey) {
|
|
2601
|
+
for (const state of this.shapeSubscriptions.values()) {
|
|
2602
|
+
if (connectionKey(state.shardKey) === connectionKey(shardKey)) {
|
|
2603
|
+
this.sendShapeSubscribeIfOpen(state);
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
1745
2607
|
ensureSocket(shardKey) {
|
|
1746
2608
|
if (this.closed || this.WebSocketImpl === void 0) {
|
|
1747
2609
|
return;
|
|
@@ -1754,7 +2616,27 @@ class LunoraClient {
|
|
|
1754
2616
|
this.emitConnectionStatus();
|
|
1755
2617
|
const socket = new this.WebSocketImpl(this.wsUrlFor(shardKey));
|
|
1756
2618
|
conn.socket = socket;
|
|
2619
|
+
if (this.connectTimeoutMs > 0) {
|
|
2620
|
+
conn.connectTimer = setTimeout(() => {
|
|
2621
|
+
conn.connectTimer = void 0;
|
|
2622
|
+
if (conn.socket !== socket || conn.wsState !== "connecting") {
|
|
2623
|
+
return;
|
|
2624
|
+
}
|
|
2625
|
+
try {
|
|
2626
|
+
socket.close();
|
|
2627
|
+
} catch {
|
|
2628
|
+
}
|
|
2629
|
+
this.handleDisconnect(conn);
|
|
2630
|
+
}, this.connectTimeoutMs);
|
|
2631
|
+
}
|
|
1757
2632
|
socket.addEventListener("open", () => {
|
|
2633
|
+
if (conn.socket !== socket) {
|
|
2634
|
+
return;
|
|
2635
|
+
}
|
|
2636
|
+
if (conn.connectTimer !== void 0) {
|
|
2637
|
+
clearTimeout(conn.connectTimer);
|
|
2638
|
+
conn.connectTimer = void 0;
|
|
2639
|
+
}
|
|
1758
2640
|
conn.wsState = "open";
|
|
1759
2641
|
conn.wasEverConnected = true;
|
|
1760
2642
|
conn.reconnect.reset();
|
|
@@ -1766,11 +2648,12 @@ class LunoraClient {
|
|
|
1766
2648
|
this.sendSubscribeIfOpen(state);
|
|
1767
2649
|
}
|
|
1768
2650
|
}
|
|
2651
|
+
this.resendShapeSubscriptions(shardKey);
|
|
1769
2652
|
if (conn.pendingUnsubscribes.length > 0) {
|
|
1770
2653
|
const pending = conn.pendingUnsubscribes;
|
|
1771
2654
|
conn.pendingUnsubscribes = [];
|
|
1772
|
-
for (const id of pending) {
|
|
1773
|
-
sendOn(conn, { id, type
|
|
2655
|
+
for (const { id, type } of pending) {
|
|
2656
|
+
sendOn(conn, { id, type });
|
|
1774
2657
|
}
|
|
1775
2658
|
}
|
|
1776
2659
|
if (conn.pendingStreams && conn.pendingStreams.length > 0) {
|
|
@@ -1793,12 +2676,18 @@ class LunoraClient {
|
|
|
1793
2676
|
this.handleServerMessage(event.data, shardKey);
|
|
1794
2677
|
});
|
|
1795
2678
|
socket.addEventListener("close", (event) => {
|
|
2679
|
+
if (conn.socket !== socket) {
|
|
2680
|
+
return;
|
|
2681
|
+
}
|
|
1796
2682
|
if (event?.code === 4001) {
|
|
1797
2683
|
this.notifyTokenExpired();
|
|
1798
2684
|
}
|
|
1799
2685
|
this.handleDisconnect(conn);
|
|
1800
2686
|
});
|
|
1801
2687
|
socket.addEventListener("error", () => {
|
|
2688
|
+
if (conn.socket !== socket) {
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
1802
2691
|
if (conn.wsState === "connecting" || conn.wsState === "open") {
|
|
1803
2692
|
this.handleDisconnect(conn);
|
|
1804
2693
|
}
|
|
@@ -1812,6 +2701,10 @@ class LunoraClient {
|
|
|
1812
2701
|
return;
|
|
1813
2702
|
}
|
|
1814
2703
|
this.stopHeartbeat(conn);
|
|
2704
|
+
if (conn.connectTimer !== void 0) {
|
|
2705
|
+
clearTimeout(conn.connectTimer);
|
|
2706
|
+
conn.connectTimer = void 0;
|
|
2707
|
+
}
|
|
1815
2708
|
conn.socket = void 0;
|
|
1816
2709
|
conn.wsState = "idle";
|
|
1817
2710
|
this.emitConnectionStatus();
|
|
@@ -1885,6 +2778,21 @@ class LunoraClient {
|
|
|
1885
2778
|
type: "subscribe"
|
|
1886
2779
|
});
|
|
1887
2780
|
}
|
|
2781
|
+
sendShapeSubscribeIfOpen(state) {
|
|
2782
|
+
const conn = this.getConnection(state.shardKey);
|
|
2783
|
+
if (conn?.wsState !== "open") {
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2786
|
+
sendOn(conn, {
|
|
2787
|
+
id: state.id,
|
|
2788
|
+
shape: { name: state.name, ...state.args === void 0 ? {} : { args: state.args } },
|
|
2789
|
+
type: "shape_subscribe",
|
|
2790
|
+
// Resume from the last applied checkpoint when we hold one; a cold
|
|
2791
|
+
// subscribe omits it and the server seeds the full membership.
|
|
2792
|
+
...state.serverCursor === void 0 ? {} : { sinceCheckpoint: state.serverCursor },
|
|
2793
|
+
...state.serverEpoch === void 0 ? {} : { sinceEpoch: state.serverEpoch }
|
|
2794
|
+
});
|
|
2795
|
+
}
|
|
1888
2796
|
handleServerMessage(raw, shardKey) {
|
|
1889
2797
|
const text = decodeServerFrame(raw);
|
|
1890
2798
|
if (text === void 0) {
|
|
@@ -1907,7 +2815,7 @@ class LunoraClient {
|
|
|
1907
2815
|
case "chunk": {
|
|
1908
2816
|
const { data, id } = message;
|
|
1909
2817
|
const stream = this.streams.get(id);
|
|
1910
|
-
stream?.handle.push(data);
|
|
2818
|
+
stream?.handle.push(decodeWire(data));
|
|
1911
2819
|
return;
|
|
1912
2820
|
}
|
|
1913
2821
|
case "complete": {
|
|
@@ -1923,10 +2831,26 @@ class LunoraClient {
|
|
|
1923
2831
|
this.handleErrorMessage(message);
|
|
1924
2832
|
break;
|
|
1925
2833
|
}
|
|
2834
|
+
case "pokeEnd": {
|
|
2835
|
+
this.handlePokeEnd(message);
|
|
2836
|
+
break;
|
|
2837
|
+
}
|
|
2838
|
+
case "pokePart": {
|
|
2839
|
+
this.handlePokePart(message);
|
|
2840
|
+
break;
|
|
2841
|
+
}
|
|
2842
|
+
case "pokeStart": {
|
|
2843
|
+
this.handlePokeStart(message);
|
|
2844
|
+
break;
|
|
2845
|
+
}
|
|
1926
2846
|
case "resume": {
|
|
1927
2847
|
this.handleResumeMessage(message);
|
|
1928
2848
|
break;
|
|
1929
2849
|
}
|
|
2850
|
+
case "settled": {
|
|
2851
|
+
this.handleSettledMessage(message);
|
|
2852
|
+
break;
|
|
2853
|
+
}
|
|
1930
2854
|
case "whisper": {
|
|
1931
2855
|
this.dispatchWhisper(message, shardKey);
|
|
1932
2856
|
break;
|
|
@@ -1948,12 +2872,81 @@ class LunoraClient {
|
|
|
1948
2872
|
}
|
|
1949
2873
|
const state = id === void 0 ? void 0 : this.subscriptions.getById(id);
|
|
1950
2874
|
if (state) {
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
2875
|
+
fanSubscriptionError(state.errorCallbacks, buildSubscriptionError(message));
|
|
2876
|
+
return;
|
|
2877
|
+
}
|
|
2878
|
+
const shapeState = id === void 0 ? void 0 : this.shapeSubscriptions.get(id);
|
|
2879
|
+
if (shapeState) {
|
|
2880
|
+
fanSubscriptionError(shapeState.errorCallbacks, buildSubscriptionError(message));
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
handlePokeStart(message) {
|
|
2884
|
+
if (this.pokeBuffers.size >= LunoraClient.MAX_POKE_BUFFERS) {
|
|
2885
|
+
const oldest = this.pokeBuffers.keys().next().value;
|
|
2886
|
+
if (oldest !== void 0) {
|
|
2887
|
+
this.pokeBuffers.delete(oldest);
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
this.pokeBuffers.set(message.pokeId, { baseCheckpoint: message.baseCheckpoint, epoch: message.epoch, lastMutationId: /* @__PURE__ */ new Map(), parts: /* @__PURE__ */ new Map() });
|
|
2891
|
+
}
|
|
2892
|
+
handlePokePart(message) {
|
|
2893
|
+
const buffer = this.pokeBuffers.get(message.pokeId);
|
|
2894
|
+
if (!buffer) {
|
|
2895
|
+
return;
|
|
2896
|
+
}
|
|
2897
|
+
const existing = buffer.parts.get(message.shapeId) ?? [];
|
|
2898
|
+
for (const op of message.rowsPatch) {
|
|
2899
|
+
existing.push(op.value === void 0 ? op : { ...op, value: decodeWire(op.value) });
|
|
2900
|
+
}
|
|
2901
|
+
buffer.parts.set(message.shapeId, existing);
|
|
2902
|
+
if (message.lastMutationId !== void 0) {
|
|
2903
|
+
buffer.lastMutationId.set(message.shapeId, message.lastMutationId);
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
handlePokeEnd(message) {
|
|
2907
|
+
const buffer = this.pokeBuffers.get(message.pokeId);
|
|
2908
|
+
if (!buffer) {
|
|
2909
|
+
return;
|
|
2910
|
+
}
|
|
2911
|
+
this.pokeBuffers.delete(message.pokeId);
|
|
2912
|
+
for (const [shapeId, ops] of buffer.parts) {
|
|
2913
|
+
const state = this.shapeSubscriptions.get(shapeId);
|
|
2914
|
+
if (!state) {
|
|
2915
|
+
continue;
|
|
2916
|
+
}
|
|
2917
|
+
const epochForked = buffer.epoch !== void 0 && state.serverEpoch !== void 0 && buffer.epoch !== state.serverEpoch;
|
|
2918
|
+
const baseDiverged = buffer.baseCheckpoint !== void 0 && state.serverCursor !== void 0 && state.serverCursor !== buffer.baseCheckpoint;
|
|
2919
|
+
if (epochForked || baseDiverged) {
|
|
2920
|
+
state.rows.clear();
|
|
2921
|
+
state.serverCursor = void 0;
|
|
2922
|
+
state.serverEpoch = void 0;
|
|
2923
|
+
this.emitShapeRows(state);
|
|
2924
|
+
this.sendShapeSubscribeIfOpen(state);
|
|
2925
|
+
continue;
|
|
2926
|
+
}
|
|
2927
|
+
applyRowOpsToView(state.rows, ops);
|
|
2928
|
+
if (message.checkpoint !== void 0) {
|
|
2929
|
+
state.serverCursor = message.checkpoint;
|
|
2930
|
+
}
|
|
2931
|
+
if (message.epoch !== void 0) {
|
|
2932
|
+
state.serverEpoch = message.epoch;
|
|
2933
|
+
}
|
|
2934
|
+
const watermark = buffer.lastMutationId.get(shapeId);
|
|
2935
|
+
if (watermark !== void 0) {
|
|
2936
|
+
state.lastMutationId = watermark;
|
|
2937
|
+
}
|
|
2938
|
+
this.emitShapeRows(state);
|
|
2939
|
+
state.onCheckpoint?.({ checkpoint: state.serverCursor, mutationId: state.lastMutationId });
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
/** Materialize a shape's keyed view to an array and invoke its callbacks. */
|
|
2943
|
+
// eslint-disable-next-line class-methods-use-this -- a pure state→callback fan-out kept beside the shape-subscription pipeline it serves.
|
|
2944
|
+
emitShapeRows(state) {
|
|
2945
|
+
const rows = [...state.rows.values()];
|
|
2946
|
+
for (const shapeCallback of state.callbacks) {
|
|
2947
|
+
try {
|
|
2948
|
+
shapeCallback(rows);
|
|
2949
|
+
} catch {
|
|
1957
2950
|
}
|
|
1958
2951
|
}
|
|
1959
2952
|
}
|
|
@@ -1964,8 +2957,7 @@ class LunoraClient {
|
|
|
1964
2957
|
return;
|
|
1965
2958
|
}
|
|
1966
2959
|
const payload = this.resolveDataPayload(message, state);
|
|
1967
|
-
state.
|
|
1968
|
-
state.serverVersion += 1;
|
|
2960
|
+
state.serverBase = payload;
|
|
1969
2961
|
if (message.cursor !== void 0) {
|
|
1970
2962
|
state.serverCursor = message.cursor;
|
|
1971
2963
|
}
|
|
@@ -1973,12 +2965,8 @@ class LunoraClient {
|
|
|
1973
2965
|
state.serverEpoch = message.epoch;
|
|
1974
2966
|
}
|
|
1975
2967
|
this.persistQueryValue(state);
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
callback(payload);
|
|
1979
|
-
} catch {
|
|
1980
|
-
}
|
|
1981
|
-
}
|
|
2968
|
+
dropConfirmedLayers(state, state.serverCursor);
|
|
2969
|
+
notifySubscription(state, state.optimisticLayers.length === 0 ? payload : foldOptimistic(payload, state.optimisticLayers));
|
|
1982
2970
|
}
|
|
1983
2971
|
/**
|
|
1984
2972
|
* Handle a `resume` frame (Pillar 1b): the server proved nothing the
|
|
@@ -1993,15 +2981,51 @@ class LunoraClient {
|
|
|
1993
2981
|
if (!state) {
|
|
1994
2982
|
return;
|
|
1995
2983
|
}
|
|
2984
|
+
this.ackAndAdvanceCursor(state, message.cursor, message.epoch);
|
|
2985
|
+
}
|
|
2986
|
+
/**
|
|
2987
|
+
* Handle a `settled` frame: a write touched one of this subscription's read
|
|
2988
|
+
* tables but produced a byte-identical result, so the server suppressed the
|
|
2989
|
+
* data frame. Like {@link handleResumeMessage} the value didn't change — we
|
|
2990
|
+
* advance the resume position and re-persist — but we ALSO surface the echoed
|
|
2991
|
+
* custom-mutator watermark via `onCheckpoint` so a `@lunora/db` list
|
|
2992
|
+
* collection drops the optimistic overlay for the confirmed write (otherwise
|
|
2993
|
+
* its checkpoint gate, fed only by data frames, would hang forever). Sent
|
|
2994
|
+
* only to custom-mutator clients; plain `useQuery` subscribers leave
|
|
2995
|
+
* `onCheckpoint` unset and this is a near no-op.
|
|
2996
|
+
*/
|
|
2997
|
+
handleSettledMessage(message) {
|
|
2998
|
+
const state = this.subscriptions.getById(message.id);
|
|
2999
|
+
if (!state) {
|
|
3000
|
+
return;
|
|
3001
|
+
}
|
|
3002
|
+
this.ackAndAdvanceCursor(state, message.cursor, message.epoch);
|
|
3003
|
+
if (message.lastMutationId !== void 0) {
|
|
3004
|
+
state.lastMutationId = message.lastMutationId;
|
|
3005
|
+
}
|
|
3006
|
+
for (const onCheckpoint of state.checkpointCallbacks) {
|
|
3007
|
+
onCheckpoint({ checkpoint: state.serverCursor, mutationId: state.lastMutationId });
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
/**
|
|
3011
|
+
* Mark `state` acked and, when the frame carries a newer cursor/epoch than
|
|
3012
|
+
* the cached position, advance the resume watermark and re-persist. Shared by
|
|
3013
|
+
* the `resume` and `settled` frame handlers — both acknowledge "nothing the
|
|
3014
|
+
* client must re-render changed, but the resume position may have moved".
|
|
3015
|
+
*/
|
|
3016
|
+
ackAndAdvanceCursor(state, cursor, epoch) {
|
|
1996
3017
|
state.acked = true;
|
|
1997
|
-
if (
|
|
1998
|
-
if (
|
|
1999
|
-
state.serverCursor =
|
|
3018
|
+
if (cursor !== void 0 && cursor !== state.serverCursor || epoch !== void 0 && epoch !== state.serverEpoch) {
|
|
3019
|
+
if (cursor !== void 0) {
|
|
3020
|
+
state.serverCursor = cursor;
|
|
2000
3021
|
}
|
|
2001
|
-
if (
|
|
2002
|
-
state.serverEpoch =
|
|
3022
|
+
if (epoch !== void 0) {
|
|
3023
|
+
state.serverEpoch = epoch;
|
|
2003
3024
|
}
|
|
2004
3025
|
this.persistQueryValue(state);
|
|
3026
|
+
if (dropConfirmedLayers(state, state.serverCursor)) {
|
|
3027
|
+
notifySubscription(state, foldOptimistic(state.serverBase, state.optimisticLayers));
|
|
3028
|
+
}
|
|
2005
3029
|
}
|
|
2006
3030
|
}
|
|
2007
3031
|
/**
|
|
@@ -2019,11 +3043,11 @@ class LunoraClient {
|
|
|
2019
3043
|
// eslint-disable-next-line class-methods-use-this -- instance method for symmetry with the other message handlers; reads no shared client state
|
|
2020
3044
|
resolveDataPayload(message, state) {
|
|
2021
3045
|
if ("data" in message && message.data !== void 0) {
|
|
2022
|
-
return message.data;
|
|
3046
|
+
return decodeWire(message.data);
|
|
2023
3047
|
}
|
|
2024
|
-
const
|
|
2025
|
-
if (isMutationDelta(delta) && state.
|
|
2026
|
-
const merged = applyDelta(state.
|
|
3048
|
+
const delta = decodeWire(message.delta);
|
|
3049
|
+
if (isMutationDelta(delta) && state.serverBase !== void 0) {
|
|
3050
|
+
const merged = applyDelta(state.serverBase, delta);
|
|
2027
3051
|
if (merged !== void 0) {
|
|
2028
3052
|
return merged;
|
|
2029
3053
|
}
|
|
@@ -2036,21 +3060,17 @@ class LunoraClient {
|
|
|
2036
3060
|
if (!handlers) {
|
|
2037
3061
|
return;
|
|
2038
3062
|
}
|
|
3063
|
+
const data = decodeWire(message.data);
|
|
2039
3064
|
for (const handler of handlers) {
|
|
2040
3065
|
try {
|
|
2041
|
-
handler(
|
|
3066
|
+
handler(data, message.from);
|
|
2042
3067
|
} catch {
|
|
2043
3068
|
}
|
|
2044
3069
|
}
|
|
2045
3070
|
}
|
|
2046
3071
|
/** Notify every {@link onTokenExpired} listener (best-effort, listener throws swallowed). */
|
|
2047
3072
|
notifyTokenExpired() {
|
|
2048
|
-
|
|
2049
|
-
try {
|
|
2050
|
-
listener();
|
|
2051
|
-
} catch {
|
|
2052
|
-
}
|
|
2053
|
-
}
|
|
3073
|
+
this.tokenExpiredListeners.emit();
|
|
2054
3074
|
}
|
|
2055
3075
|
handleCompleteMessage(id) {
|
|
2056
3076
|
const stream = this.streams.get(id);
|
|
@@ -2081,16 +3101,22 @@ class LunoraClient {
|
|
|
2081
3101
|
// `null` is the distinct "signed out" identity (separate from `undefined`,
|
|
2082
3102
|
// which means "not stamped / hydrated"); the two must not be conflated.
|
|
2083
3103
|
identityFingerprint() {
|
|
3104
|
+
if (this.authSubject !== void 0) {
|
|
3105
|
+
return this.authSubject === null ? null : `subj:${this.authSubject}`;
|
|
3106
|
+
}
|
|
2084
3107
|
const token = this.authToken;
|
|
2085
3108
|
if (token === null) {
|
|
2086
3109
|
return null;
|
|
2087
3110
|
}
|
|
2088
|
-
let
|
|
3111
|
+
let fnv = 2166136261;
|
|
3112
|
+
let djb2 = 5381;
|
|
2089
3113
|
for (let index = 0; index < token.length; index += 1) {
|
|
2090
|
-
|
|
2091
|
-
|
|
3114
|
+
const code = token.charCodeAt(index);
|
|
3115
|
+
fnv ^= code;
|
|
3116
|
+
fnv = Math.imul(fnv, 16777619);
|
|
3117
|
+
djb2 = Math.imul(djb2, 33) + code;
|
|
2092
3118
|
}
|
|
2093
|
-
return `${token.length.toString(36)}:${(
|
|
3119
|
+
return `${token.length.toString(36)}:${(fnv >>> 0).toString(36)}:${(djb2 >>> 0).toString(36)}`;
|
|
2094
3120
|
}
|
|
2095
3121
|
/**
|
|
2096
3122
|
* Drain every in-memory offline write and reject it because the auth
|
|
@@ -2107,9 +3133,25 @@ class LunoraClient {
|
|
|
2107
3133
|
const error = new Error("offline mutation discarded: auth identity changed before replay");
|
|
2108
3134
|
error.code = "OFFLINE_IDENTITY_CHANGED";
|
|
2109
3135
|
item.reject(error);
|
|
3136
|
+
this.emitItemSettled(item, "rejected", error);
|
|
2110
3137
|
}
|
|
2111
3138
|
this.clearQueryCacheForIdentityChange();
|
|
2112
3139
|
}
|
|
3140
|
+
/**
|
|
3141
|
+
* Migrate every live identity stamp from `from` to `to` — used when the auth
|
|
3142
|
+
* identity label changes but the underlying credential (token) does NOT, e.g.
|
|
3143
|
+
* the user id resolves a tick after the token was set. The in-memory
|
|
3144
|
+
* `queuedIdentities` map is the flush-time source of truth, so re-stamping it
|
|
3145
|
+
* keeps the in-flight writes replayable under the new (more stable) identity
|
|
3146
|
+
* instead of the flush guard discarding them as a mismatch.
|
|
3147
|
+
*/
|
|
3148
|
+
restampQueuedIdentity(from, to) {
|
|
3149
|
+
for (const [id, stamp] of this.queuedIdentities) {
|
|
3150
|
+
if (stamp === from) {
|
|
3151
|
+
this.queuedIdentities.set(id, to);
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
2113
3155
|
/**
|
|
2114
3156
|
* Drop the durable read cache on an identity change so a cached value stamped
|
|
2115
3157
|
* under the previous identity can never hydrate into a new session. Clears
|
|
@@ -2128,38 +3170,242 @@ class LunoraClient {
|
|
|
2128
3170
|
async flushOfflineQueue(shardKey) {
|
|
2129
3171
|
const key = connectionKey(shardKey);
|
|
2130
3172
|
const drained = this.offlineQueue.drain((item) => connectionKey(item.shardKey) === key);
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
3173
|
+
if (drained.length === 0) {
|
|
3174
|
+
return;
|
|
3175
|
+
}
|
|
3176
|
+
const currentIdentity = this.identityFingerprint();
|
|
3177
|
+
const sendable = [];
|
|
3178
|
+
for (const item of drained) {
|
|
3179
|
+
if (this.passesReplayIdentityGate(item, currentIdentity)) {
|
|
3180
|
+
sendable.push(item);
|
|
2135
3181
|
}
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
3182
|
+
}
|
|
3183
|
+
if (sendable.length === 0) {
|
|
3184
|
+
return;
|
|
3185
|
+
}
|
|
3186
|
+
const encodable = this.encodableOrSettleTerminal(sendable);
|
|
3187
|
+
if (encodable.length === 0) {
|
|
3188
|
+
return;
|
|
3189
|
+
}
|
|
3190
|
+
if (encodable.length === 1) {
|
|
3191
|
+
await this.replaySequential(encodable);
|
|
3192
|
+
return;
|
|
3193
|
+
}
|
|
3194
|
+
const toRequeue = [];
|
|
3195
|
+
for (let start = 0; start < encodable.length; start += MAX_BATCH_ENTRIES) {
|
|
3196
|
+
const chunk = encodable.slice(start, start + MAX_BATCH_ENTRIES);
|
|
3197
|
+
const outcome = await this.replayBatched(chunk);
|
|
3198
|
+
toRequeue.push(...outcome.requeue);
|
|
3199
|
+
if (outcome.stop) {
|
|
3200
|
+
toRequeue.push(...encodable.slice(start + MAX_BATCH_ENTRIES));
|
|
3201
|
+
break;
|
|
2146
3202
|
}
|
|
3203
|
+
}
|
|
3204
|
+
if (toRequeue.length > 0) {
|
|
3205
|
+
this.offlineQueue.requeue(toRequeue);
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
/**
|
|
3209
|
+
* Partition already-gated writes into the encodable ones (returned) and reject
|
|
3210
|
+
* the rest terminally. A write whose args can't be wire-encoded (e.g. a RegExp
|
|
3211
|
+
* or class instance in a `v.any()` field) can NEVER replay — the codec failure
|
|
3212
|
+
* is deterministic, not transient. Rejecting here is essential: otherwise
|
|
3213
|
+
* `encodeWire` throws mid-flush, is classified as transient (a codec error has
|
|
3214
|
+
* no `.code`), and re-queues forever — a silent hang where the caller's Promise
|
|
3215
|
+
* never settles and the optimistic write never rolls back. Encoding is cheap;
|
|
3216
|
+
* the flush is the slow reconnect path.
|
|
3217
|
+
*/
|
|
3218
|
+
encodableOrSettleTerminal(items) {
|
|
3219
|
+
const encodable = [];
|
|
3220
|
+
for (const item of items) {
|
|
3221
|
+
try {
|
|
3222
|
+
encodeCallArgs(item.args, `args for '${item.functionPath}'`);
|
|
3223
|
+
encodable.push(item);
|
|
3224
|
+
} catch (error) {
|
|
3225
|
+
this.settleReplayTerminal(item, error instanceof Error ? error : new Error(String(error)));
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
return encodable;
|
|
3229
|
+
}
|
|
3230
|
+
/**
|
|
3231
|
+
* Identity guard for one queued write about to replay: a write stamped under
|
|
3232
|
+
* one identity must never replay under another. The live `queuedIdentities`
|
|
3233
|
+
* map is the source of truth for the current session; a hydrated write whose
|
|
3234
|
+
* id isn't in the map falls back to the stamp persisted with the record
|
|
3235
|
+
* (`item.identity`), so a reload can't replay another user's queued writes.
|
|
3236
|
+
* Only legacy records (persisted before stamps were durable —
|
|
3237
|
+
* `item.identity === undefined`) replay under whatever identity is current.
|
|
3238
|
+
*
|
|
3239
|
+
* `Map.get` returns `undefined` for unstamped/hydrated ids and `item.identity`
|
|
3240
|
+
* is `undefined` for legacy records; a persisted `null` (queued while signed
|
|
3241
|
+
* out) is a real value that must not collapse into `undefined` — hence the
|
|
3242
|
+
* explicit `=== undefined` check rather than `??`. Returns `true` when the
|
|
3243
|
+
* write may replay; otherwise settles it `OFFLINE_IDENTITY_CHANGED` and returns
|
|
3244
|
+
* `false`. Either way the live stamp is consumed.
|
|
3245
|
+
*/
|
|
3246
|
+
passesReplayIdentityGate(item, currentIdentity) {
|
|
3247
|
+
const liveStamp = item.id === void 0 ? void 0 : this.queuedIdentities.get(item.id);
|
|
3248
|
+
const stamped = liveStamp === void 0 ? item.identity : liveStamp;
|
|
3249
|
+
if (stamped !== void 0 && stamped !== currentIdentity) {
|
|
2147
3250
|
this.queuedIdentities.delete(item.id ?? "");
|
|
3251
|
+
this.unpersist(item.id);
|
|
3252
|
+
const error = new Error("offline mutation skipped: auth identity changed before replay");
|
|
3253
|
+
error.code = "OFFLINE_IDENTITY_CHANGED";
|
|
3254
|
+
item.reject(error);
|
|
3255
|
+
this.emitItemSettled(item, "rejected", error);
|
|
3256
|
+
return false;
|
|
3257
|
+
}
|
|
3258
|
+
this.queuedIdentities.delete(item.id ?? "");
|
|
3259
|
+
return true;
|
|
3260
|
+
}
|
|
3261
|
+
/** 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. */
|
|
3262
|
+
settleReplaySuccess(item, value, commitCursor) {
|
|
3263
|
+
this.unpersist(item.id);
|
|
3264
|
+
item.onCommit?.(commitCursor);
|
|
3265
|
+
item.resolve(value);
|
|
3266
|
+
this.emitItemSettled(item, "committed");
|
|
3267
|
+
}
|
|
3268
|
+
/** Settle a write the server reached a coded verdict on: replaying would re-trigger the same failure (a poison-message loop), so drop it. */
|
|
3269
|
+
settleReplayTerminal(item, error) {
|
|
3270
|
+
this.unpersist(item.id);
|
|
3271
|
+
item.reject(error);
|
|
3272
|
+
this.emitItemSettled(item, "rejected", error);
|
|
3273
|
+
}
|
|
3274
|
+
/**
|
|
3275
|
+
* Replay already-identity-gated writes one at a time on the single-call `/rpc`
|
|
3276
|
+
* path, preserving FIFO order (parallel `.then()` chains would race the
|
|
3277
|
+
* ordering callers depend on). Each replays under its stable `mutationId` so
|
|
3278
|
+
* the server dedups a write it already committed (exactly-once). A coded error
|
|
3279
|
+
* is a server verdict (drop it); a codeless (transport/transient) failure stops
|
|
3280
|
+
* the flush and re-queues this write and every unreplayed one for the next
|
|
3281
|
+
* reconnect — their callers stay pending, and the identity guard re-applies on
|
|
3282
|
+
* retry via each record's persisted stamp.
|
|
3283
|
+
*/
|
|
3284
|
+
async replaySequential(items) {
|
|
3285
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
3286
|
+
const item = items[index];
|
|
3287
|
+
if (!item) {
|
|
3288
|
+
continue;
|
|
3289
|
+
}
|
|
2148
3290
|
try {
|
|
2149
|
-
|
|
2150
|
-
this.
|
|
2151
|
-
|
|
3291
|
+
let commitCursor;
|
|
3292
|
+
const value = await this.rpc(item.functionPath, item.args, item.shardKey, {
|
|
3293
|
+
captureBookmark: true,
|
|
3294
|
+
mutationId: item.id,
|
|
3295
|
+
onCommitCursor: (cursor) => {
|
|
3296
|
+
commitCursor = cursor;
|
|
3297
|
+
}
|
|
3298
|
+
});
|
|
3299
|
+
this.settleReplaySuccess(item, value, commitCursor);
|
|
2152
3300
|
} catch (error) {
|
|
2153
3301
|
if (error.code !== void 0) {
|
|
2154
|
-
this.
|
|
2155
|
-
item.reject(error);
|
|
3302
|
+
this.settleReplayTerminal(item, error);
|
|
2156
3303
|
continue;
|
|
2157
3304
|
}
|
|
2158
|
-
this.offlineQueue.requeue(
|
|
3305
|
+
this.offlineQueue.requeue(items.slice(index));
|
|
2159
3306
|
return;
|
|
2160
3307
|
}
|
|
2161
3308
|
}
|
|
2162
3309
|
}
|
|
3310
|
+
/**
|
|
3311
|
+
* Coalesce already-identity-gated writes for a single shard into ONE
|
|
3312
|
+
* `/_lunora/rpc-batch` round trip (plan 088 follow-on). The worker forwards
|
|
3313
|
+
* them to the shard DO, which replays each through its single-call dispatch, so
|
|
3314
|
+
* per-entry `mutationId` idempotency and in-order application are inherited from
|
|
3315
|
+
* the proven path. Per-slot demux mirrors {@link replaySequential}'s
|
|
3316
|
+
* classification: success confirms the optimistic layer against the echoed
|
|
3317
|
+
* `commitCursor`; a coded application verdict is terminal; a transient shard
|
|
3318
|
+
* failure (`SHARD_UNAVAILABLE`/`SHARD_ERROR`), a missing slot, or a whole-batch
|
|
3319
|
+
* transport failure re-queues for the next reconnect (never dropping a durable
|
|
3320
|
+
* write). A whole-batch coded rejection (bad request / authorization denial the
|
|
3321
|
+
* server reached a verdict on) is terminal for every entry.
|
|
3322
|
+
*
|
|
3323
|
+
* Returns the writes that must be re-queued and `stop` — `true` when the whole
|
|
3324
|
+
* chunk failed at the transport level, so the caller leaves later chunks queued
|
|
3325
|
+
* rather than sending on. The caller re-queues once, in order, so requeuing is
|
|
3326
|
+
* NOT done here.
|
|
3327
|
+
*/
|
|
3328
|
+
async replayBatched(items) {
|
|
3329
|
+
if (!this.fetchImpl) {
|
|
3330
|
+
return { requeue: items, stop: true };
|
|
3331
|
+
}
|
|
3332
|
+
let response;
|
|
3333
|
+
try {
|
|
3334
|
+
response = await this.fetchImpl(joinUrl(this.url, RPC_BATCH_PATH), {
|
|
3335
|
+
body: JSON.stringify({
|
|
3336
|
+
calls: items.map((item, index) => {
|
|
3337
|
+
return {
|
|
3338
|
+
args: encodeCallArgs(item.args, `args for '${item.functionPath}'`),
|
|
3339
|
+
functionPath: item.functionPath,
|
|
3340
|
+
id: index,
|
|
3341
|
+
// Stable per-write key so the DO dedups a write it already
|
|
3342
|
+
// committed (exactly-once), exactly as the single-call replay.
|
|
3343
|
+
mutationId: item.id,
|
|
3344
|
+
shardKey: item.shardKey
|
|
3345
|
+
};
|
|
3346
|
+
})
|
|
3347
|
+
}),
|
|
3348
|
+
headers: this.rpcRequestHeaders({ attachBookmark: true }),
|
|
3349
|
+
method: "POST"
|
|
3350
|
+
});
|
|
3351
|
+
} catch {
|
|
3352
|
+
return { requeue: items, stop: true };
|
|
3353
|
+
}
|
|
3354
|
+
const bookmark = response.headers.get("x-d1-bookmark");
|
|
3355
|
+
if (bookmark) {
|
|
3356
|
+
this.bookmark.set(bookmark);
|
|
3357
|
+
}
|
|
3358
|
+
let body;
|
|
3359
|
+
try {
|
|
3360
|
+
body = await response.json();
|
|
3361
|
+
} catch {
|
|
3362
|
+
return { requeue: items, stop: true };
|
|
3363
|
+
}
|
|
3364
|
+
if (!body.results) {
|
|
3365
|
+
if (body.error) {
|
|
3366
|
+
const error = reconstructError(body.error);
|
|
3367
|
+
for (const item of items) {
|
|
3368
|
+
this.settleReplayTerminal(item, error);
|
|
3369
|
+
}
|
|
3370
|
+
return { requeue: [], stop: false };
|
|
3371
|
+
}
|
|
3372
|
+
return { requeue: items, stop: true };
|
|
3373
|
+
}
|
|
3374
|
+
return { requeue: this.settleReplayBatchSlots(items, body.results), stop: false };
|
|
3375
|
+
}
|
|
3376
|
+
/**
|
|
3377
|
+
* Demux a `/_lunora/rpc-batch` reply back onto the queued writes it replayed,
|
|
3378
|
+
* in input order. Each slot's envelope classifies its write the same way
|
|
3379
|
+
* {@link replaySequential} does: a success confirms the optimistic layer
|
|
3380
|
+
* against the echoed `commitCursor`; a coded application verdict is terminal;
|
|
3381
|
+
* a transient shard failure ({@link TRANSIENT_BATCH_ERROR_CODES}) or a slot the
|
|
3382
|
+
* server never returned is returned for the caller to re-queue.
|
|
3383
|
+
* @returns the writes that must be re-queued (transient slots), in input order
|
|
3384
|
+
*/
|
|
3385
|
+
settleReplayBatchSlots(items, results) {
|
|
3386
|
+
const bySlot = /* @__PURE__ */ new Map();
|
|
3387
|
+
for (const entry of results) {
|
|
3388
|
+
if (typeof entry.id === "number" && entry.body !== void 0) {
|
|
3389
|
+
bySlot.set(entry.id, entry.body);
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
const requeue = [];
|
|
3393
|
+
for (const [index, item] of items.entries()) {
|
|
3394
|
+
const inner = bySlot.get(index);
|
|
3395
|
+
if (inner === void 0) {
|
|
3396
|
+
requeue.push(item);
|
|
3397
|
+
} else if ("error" in inner) {
|
|
3398
|
+
if (TRANSIENT_BATCH_ERROR_CODES.has(inner.error.code)) {
|
|
3399
|
+
requeue.push(item);
|
|
3400
|
+
} else {
|
|
3401
|
+
this.settleReplayTerminal(item, reconstructError(inner.error));
|
|
3402
|
+
}
|
|
3403
|
+
} else {
|
|
3404
|
+
this.settleReplaySuccess(item, decodeWire(inner.result), inner.commitCursor);
|
|
3405
|
+
}
|
|
3406
|
+
}
|
|
3407
|
+
return requeue;
|
|
3408
|
+
}
|
|
2163
3409
|
}
|
|
2164
3410
|
|
|
2165
3411
|
export { LunoraClient };
|