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

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