@lunora/client 1.0.0-alpha.2 → 1.0.0-alpha.21

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