@lunora/client 1.0.0-alpha.10 → 1.0.0-alpha.12

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.
@@ -1,12 +1,231 @@
1
- import { S as SubscriptionRegistry, s as stableStringify } from './subscription-C1Jy7HiF.mjs';
1
+ import { S as SubscriptionRegistry, s as stableStringify } from './subscription-DoyO04-2.mjs';
2
2
  import createInMemoryBookmarkStorage from './createInMemoryBookmarkStorage-BoN7a7TH.mjs';
3
3
  import { isMutationDelta, applyDelta } from './applyDelta-4jFGTPA3.mjs';
4
4
  import { a as applyOptimisticLayer, d as dropConfirmedLayers, n as notifySubscription, f as foldOptimistic, c as createLocalStore } from './local-store-BNgN3Dw3.mjs';
5
5
  import { O as OfflineQueue, n as nextId, i as isStaleVersion, r as reportPersistenceError } from './offline-queue-7Wc4onA0.mjs';
6
- import { queryCacheKey } from './createInMemoryQueryCache-B1PQ9Twl.mjs';
6
+ import { resolvePersistenceAdapter } from './createInMemoryPersistence-DlFjWtOm.mjs';
7
+ import { resolveQueryCacheAdapter, queryCacheKey } from './createInMemoryQueryCache-4AkTV38-.mjs';
7
8
  import { createReconnect } from './createReconnect-Di_-oHH7.mjs';
8
9
  import { createStream } from './DEFAULT_MAX_BUFFER-BDkqO5PW.mjs';
9
10
 
11
+ const MAX_BATCH_ENTRIES = 500;
12
+
13
+ const TAG = "$lunora.wire$";
14
+ const MAX_DEPTH = 64;
15
+ const MAX_BIGINT_DIGITS = 1024;
16
+ const UNSAFE_KEY = "__proto__";
17
+ const TYPED_ARRAY_CTORS = {
18
+ BigInt64Array,
19
+ BigUint64Array,
20
+ Float32Array,
21
+ Float64Array,
22
+ Int8Array,
23
+ Int16Array,
24
+ Int32Array,
25
+ Uint8Array,
26
+ Uint8ClampedArray,
27
+ Uint16Array,
28
+ Uint32Array
29
+ };
30
+ const ERROR_CTORS = {
31
+ Error,
32
+ EvalError,
33
+ RangeError,
34
+ ReferenceError,
35
+ SyntaxError,
36
+ TypeError,
37
+ URIError
38
+ };
39
+ const toBase64 = (bytes) => {
40
+ let binary = "";
41
+ const chunk = 32768;
42
+ for (let index = 0; index < bytes.length; index += chunk) {
43
+ binary += String.fromCharCode(...bytes.subarray(index, index + chunk));
44
+ }
45
+ return btoa(binary);
46
+ };
47
+ const fromBase64 = (base64) => {
48
+ const binary = atob(base64);
49
+ const bytes = new Uint8Array(binary.length);
50
+ for (let index = 0; index < binary.length; index += 1) {
51
+ bytes[index] = binary.codePointAt(index) ?? 0;
52
+ }
53
+ return bytes;
54
+ };
55
+ const encodeWire = (value, depth = 0) => {
56
+ if (depth > MAX_DEPTH) {
57
+ throw new RangeError(`wire-codec: value nesting exceeds the ${MAX_DEPTH}-level limit`);
58
+ }
59
+ if (value === void 0) {
60
+ return [TAG, "undefined"];
61
+ }
62
+ if (value === null) {
63
+ return null;
64
+ }
65
+ const kind = typeof value;
66
+ if (kind === "bigint") {
67
+ return [TAG, "bigint", value.toString()];
68
+ }
69
+ if (kind === "number") {
70
+ const numeric = value;
71
+ if (Number.isNaN(numeric)) {
72
+ return [TAG, "nan"];
73
+ }
74
+ if (numeric === Infinity) {
75
+ return [TAG, "inf"];
76
+ }
77
+ if (numeric === -Infinity) {
78
+ return [TAG, "-inf"];
79
+ }
80
+ return numeric;
81
+ }
82
+ if (kind !== "object") {
83
+ return value;
84
+ }
85
+ if (value instanceof Date) {
86
+ return [TAG, "date", encodeWire(value.getTime(), depth + 1)];
87
+ }
88
+ if (value instanceof Error) {
89
+ const error = value;
90
+ const properties = {};
91
+ for (const key of Object.keys(error)) {
92
+ if (error[key] !== void 0) {
93
+ properties[key] = encodeWire(error[key], depth + 1);
94
+ }
95
+ }
96
+ const encodedError = [TAG, "error", error.name, error.message, properties];
97
+ if (error.cause !== void 0) {
98
+ encodedError.push(encodeWire(error.cause, depth + 1));
99
+ }
100
+ return encodedError;
101
+ }
102
+ if (value instanceof URL) {
103
+ return [TAG, "url", value.href];
104
+ }
105
+ if (value instanceof Map) {
106
+ return [TAG, "map", [...value.entries()].map(([k, v]) => [encodeWire(k, depth + 1), encodeWire(v, depth + 1)])];
107
+ }
108
+ if (value instanceof Set) {
109
+ return [TAG, "set", [...value].map((item) => encodeWire(item, depth + 1))];
110
+ }
111
+ if (value instanceof ArrayBuffer) {
112
+ return [TAG, "bytes", toBase64(new Uint8Array(value)), "ArrayBuffer"];
113
+ }
114
+ if (ArrayBuffer.isView(value)) {
115
+ const view = value;
116
+ const ctorName = view.constructor.name;
117
+ const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
118
+ return ctorName === "Uint8Array" ? [TAG, "bytes", toBase64(bytes)] : [TAG, "bytes", toBase64(bytes), ctorName];
119
+ }
120
+ if (Array.isArray(value)) {
121
+ const encoded = value.map((item) => encodeWire(item, depth + 1));
122
+ return encoded.length > 0 && encoded[0] === TAG ? [TAG, "arr", encoded] : encoded;
123
+ }
124
+ const proto = Object.getPrototypeOf(value);
125
+ if (proto !== null && proto !== Object.prototype) {
126
+ const name = value.constructor?.name ?? "value";
127
+ throw new TypeError(
128
+ `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`
129
+ );
130
+ }
131
+ const source = value;
132
+ const result = {};
133
+ for (const key of Object.keys(source)) {
134
+ const field = source[key];
135
+ if (field !== void 0) {
136
+ result[key] = encodeWire(field, depth + 1);
137
+ }
138
+ }
139
+ return result;
140
+ };
141
+ const decodeWire = (value, depth = 0) => {
142
+ if (depth > MAX_DEPTH) {
143
+ throw new RangeError(`wire-codec: value nesting exceeds the ${MAX_DEPTH}-level limit`);
144
+ }
145
+ if (value === null || typeof value !== "object") {
146
+ return value;
147
+ }
148
+ if (Array.isArray(value)) {
149
+ if (value[0] === TAG) {
150
+ const tag = value[1];
151
+ switch (tag) {
152
+ case "-inf": {
153
+ return -Infinity;
154
+ }
155
+ case "arr": {
156
+ return value[2].map((item) => decodeWire(item, depth + 1));
157
+ }
158
+ case "bigint": {
159
+ const raw = value[2];
160
+ if (typeof raw !== "string" || raw.length > MAX_BIGINT_DIGITS || !/^-?\d+$/.test(raw)) {
161
+ throw new RangeError(`wire-codec: invalid or over-long bigint (max ${MAX_BIGINT_DIGITS} digits)`);
162
+ }
163
+ return BigInt(raw);
164
+ }
165
+ case "date": {
166
+ return new Date(decodeWire(value[2], depth + 1));
167
+ }
168
+ case "map": {
169
+ return new Map(value[2].map(([k, v]) => [decodeWire(k, depth + 1), decodeWire(v, depth + 1)]));
170
+ }
171
+ case "set": {
172
+ return new Set(value[2].map((item) => decodeWire(item, depth + 1)));
173
+ }
174
+ case "url": {
175
+ return new URL(value[2]);
176
+ }
177
+ case "error": {
178
+ const name = value[2];
179
+ const message = value[3];
180
+ const Ctor = (Object.hasOwn(ERROR_CTORS, name) ? ERROR_CTORS[name] : void 0) ?? Error;
181
+ const error = new Ctor(message);
182
+ if (error.name !== name) {
183
+ Object.defineProperty(error, "name", { configurable: true, value: name, writable: true });
184
+ }
185
+ Object.assign(error, decodeWire(value[4], depth + 1));
186
+ if (value.length > 5) {
187
+ Object.defineProperty(error, "cause", { configurable: true, value: decodeWire(value[5], depth + 1), writable: true });
188
+ }
189
+ return error;
190
+ }
191
+ case "bytes": {
192
+ const bytes = fromBase64(value[2]);
193
+ const ctorName = value[3] ?? "Uint8Array";
194
+ if (ctorName === "ArrayBuffer") {
195
+ return bytes.buffer.byteLength === bytes.byteLength ? bytes.buffer : bytes.slice().buffer;
196
+ }
197
+ const Ctor = Object.hasOwn(TYPED_ARRAY_CTORS, ctorName) ? TYPED_ARRAY_CTORS[ctorName] : void 0;
198
+ return Ctor ? new Ctor(bytes.slice().buffer) : bytes;
199
+ }
200
+ case "inf": {
201
+ return Infinity;
202
+ }
203
+ case "nan": {
204
+ return Number.NaN;
205
+ }
206
+ case "undefined": {
207
+ return void 0;
208
+ }
209
+ default: {
210
+ return value.map((item) => decodeWire(item, depth + 1));
211
+ }
212
+ }
213
+ }
214
+ return value.map((item) => decodeWire(item, depth + 1));
215
+ }
216
+ const source = value;
217
+ const result = {};
218
+ for (const key of Object.keys(source)) {
219
+ const decoded = decodeWire(source[key], depth + 1);
220
+ if (key === UNSAFE_KEY) {
221
+ Object.defineProperty(result, key, { configurable: true, enumerable: true, value: decoded, writable: true });
222
+ } else {
223
+ result[key] = decoded;
224
+ }
225
+ }
226
+ return result;
227
+ };
228
+
10
229
  class Listeners {
11
230
  listeners = /* @__PURE__ */ new Set();
12
231
  add(listener) {
@@ -33,6 +252,7 @@ class Listeners {
33
252
  }
34
253
 
35
254
  const RPC_PATH = "/_lunora/rpc";
255
+ const RPC_BATCH_PATH = "/_lunora/rpc-batch";
36
256
  const WS_PATH = "/_lunora/ws";
37
257
  const bucketQuery = (bucket) => bucket === void 0 || bucket === "" ? "" : `&bucket=${encodeURIComponent(bucket)}`;
38
258
  const rollbackOptimistic = (optimisticRollbacks) => {
@@ -170,6 +390,34 @@ const sendOn = (conn, message) => {
170
390
  return false;
171
391
  }
172
392
  };
393
+ const reconstructError = (errorBody) => {
394
+ const error = new Error(errorBody.message ?? "request failed");
395
+ error.code = errorBody.code;
396
+ if (errorBody.data !== void 0) {
397
+ error.data = decodeWire(errorBody.data);
398
+ }
399
+ return error;
400
+ };
401
+ const encodeCallArgs = (payload, label) => {
402
+ try {
403
+ return encodeWire(payload);
404
+ } catch (error) {
405
+ const reason = error instanceof Error ? error.message : String(error);
406
+ throw new TypeError(`LunoraClient: cannot encode ${label} — ${reason}`, error instanceof Error ? { cause: error } : void 0);
407
+ }
408
+ };
409
+ const demuxBatchResults = (rawResults, count) => {
410
+ const slots = Array.from({ length: count });
411
+ for (const entry of rawResults) {
412
+ if (typeof entry.id !== "number" || entry.id < 0 || entry.id >= count) {
413
+ continue;
414
+ }
415
+ const inner = entry.body;
416
+ slots[entry.id] = inner && "error" in inner && inner.error ? { error: reconstructError(inner.error), ok: false } : { ok: true, value: decodeWire(inner?.result) };
417
+ }
418
+ return slots.map((slot) => slot ?? { error: new Error("batch call returned no result"), ok: false });
419
+ };
420
+ const TRANSIENT_BATCH_ERROR_CODES = /* @__PURE__ */ new Set(["SHARD_ERROR", "SHARD_UNAVAILABLE"]);
173
421
  class LunoraClient {
174
422
  /** 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. */
175
423
  static MAX_POKE_BUFFERS = 256;
@@ -319,9 +567,9 @@ class LunoraClient {
319
567
  this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
320
568
  this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
321
569
  this.defaultConnectionContext = options.connectionContext;
322
- this.persistence = options.persistence;
570
+ this.persistence = resolvePersistenceAdapter(options.persistence, options.outbox === void 0);
323
571
  this.persistenceVersion = options.persistenceVersion;
324
- this.queryCache = options.queryCache === false ? void 0 : options.queryCache;
572
+ this.queryCache = resolveQueryCacheAdapter(options.queryCache);
325
573
  this.onPersistenceError = options.offlineQueue?.onPersistenceError;
326
574
  this.offlineQueue = new OfflineQueue(options.offlineQueue, {
327
575
  onEvict: (entry, error) => {
@@ -330,7 +578,7 @@ class LunoraClient {
330
578
  onSizeChange: (size) => {
331
579
  this.pendingChangeListeners.emit(size);
332
580
  },
333
- persistence: options.persistence,
581
+ persistence: this.persistence,
334
582
  version: options.persistenceVersion
335
583
  });
336
584
  this.outbox = options.outbox;
@@ -649,7 +897,7 @@ class LunoraClient {
649
897
  this.ensureSocket(options.shardKey);
650
898
  const conn = this.getConnection(options.shardKey);
651
899
  if (conn) {
652
- sendOn(conn, { data, topic, type: "whisper" });
900
+ sendOn(conn, { data: encodeCallArgs(data ?? null, `whisper data for topic '${topic}'`), topic, type: "whisper" });
653
901
  }
654
902
  }
655
903
  /**
@@ -722,6 +970,62 @@ class LunoraClient {
722
970
  }
723
971
  return await this.rpc(function_.__lunoraRef, args, options.shardKey, { attachBookmark: true });
724
972
  }
973
+ /**
974
+ * Batch several independent calls into ONE round trip (plan 088). Each call is
975
+ * dispatched server-side exactly as an individual RPC — per-shard
976
+ * authorization, `(identity, mutationId)` idempotency, and custom-mutator
977
+ * watermark ordering are all preserved — and the worker splits the batch by
978
+ * shard so calls to different shards fan out to their own DOs. Results are
979
+ * demuxed back in input order; a failing call does NOT fail the batch (its
980
+ * slot carries `{ ok: false, error }`, with `.code`/`.data` reconstructed like
981
+ * a single call). Args/results ride the value codec (bytes/bigint survive).
982
+ *
983
+ * No promise pipelining and no capability passing — a call's args cannot
984
+ * reference another call's result (see plan 088 §fence; capabilities are
985
+ * incompatible with DO hibernation).
986
+ */
987
+ async batch(calls) {
988
+ if (this.closed) {
989
+ throw new Error("LunoraClient is closed");
990
+ }
991
+ if (!this.fetchImpl) {
992
+ throw new Error("LunoraClient: no `fetch` implementation available");
993
+ }
994
+ if (calls.length === 0) {
995
+ return [];
996
+ }
997
+ const response = await this.fetchImpl(joinUrl(this.url, RPC_BATCH_PATH), {
998
+ body: JSON.stringify({
999
+ calls: calls.map((call, index) => {
1000
+ return {
1001
+ args: encodeCallArgs(call.args ?? {}, `args for batch call '${call.fn.__lunoraRef}'`),
1002
+ functionPath: call.fn.__lunoraRef,
1003
+ id: index,
1004
+ shardKey: call.shardKey
1005
+ };
1006
+ })
1007
+ }),
1008
+ headers: this.rpcRequestHeaders({ attachBookmark: true }),
1009
+ method: "POST"
1010
+ });
1011
+ const bookmark = response.headers.get("x-d1-bookmark");
1012
+ if (bookmark) {
1013
+ this.bookmark.set(bookmark);
1014
+ }
1015
+ let body;
1016
+ try {
1017
+ body = await response.json();
1018
+ } catch {
1019
+ throw new Error(`LunoraClient: batch response was not JSON (status ${response.status.toString()})`);
1020
+ }
1021
+ if (!response.ok || body.error && !body.results) {
1022
+ if (body.error) {
1023
+ throw reconstructError(body.error);
1024
+ }
1025
+ throw new Error(`LunoraClient: batch request failed (status ${response.status.toString()})`);
1026
+ }
1027
+ return demuxBatchResults(body.results ?? [], calls.length);
1028
+ }
725
1029
  /**
726
1030
  * Invoke a mutation. Errors propagate as rejections.
727
1031
  *
@@ -1548,7 +1852,14 @@ class LunoraClient {
1548
1852
  const conn = this.getConnection(shardKey);
1549
1853
  const message = {
1550
1854
  id,
1551
- query: { args: argsRecord, functionPath: function_.__lunoraRef, shardKey },
1855
+ // Wire-encode the stream args so `bigint`/bytes survive the send (raw
1856
+ // `JSON.stringify` throws on a bigint); the shard `decodeWire`s them
1857
+ // before invoking the stream handler.
1858
+ query: {
1859
+ args: encodeCallArgs(argsRecord, `stream args for '${function_.__lunoraRef}'`),
1860
+ functionPath: function_.__lunoraRef,
1861
+ shardKey
1862
+ },
1552
1863
  type: "stream"
1553
1864
  };
1554
1865
  const sentImmediately = conn?.wsState === "open" && sendOn(conn, message);
@@ -1974,7 +2285,10 @@ class LunoraClient {
1974
2285
  }
1975
2286
  const headers = this.rpcRequestHeaders(flags);
1976
2287
  const response = await this.fetchImpl(joinUrl(this.url, RPC_PATH), {
1977
- body: JSON.stringify({ args, functionPath, shardKey }),
2288
+ // `encodeWire` tags leaves plain JSON can't carry (`bigint`,
2289
+ // `ArrayBuffer`/typed arrays, `NaN`/±Infinity); a pure-JSON `args`
2290
+ // encodes byte-identically, so a pre-codec server still interops.
2291
+ body: JSON.stringify({ args: encodeCallArgs(args, `args for '${functionPath}'`), functionPath, shardKey }),
1978
2292
  headers,
1979
2293
  method: "POST"
1980
2294
  });
@@ -1992,9 +2306,7 @@ class LunoraClient {
1992
2306
  throw new Error(`LunoraClient: response was not JSON (status ${response.status.toString()}${statusText})`);
1993
2307
  }
1994
2308
  if ("error" in body) {
1995
- const error = new Error(body.error.message);
1996
- error.code = body.error.code;
1997
- throw error;
2309
+ throw reconstructError(body.error);
1998
2310
  }
1999
2311
  if (!response.ok) {
2000
2312
  const statusText = response.statusText ? ` ${response.statusText}` : "";
@@ -2002,7 +2314,7 @@ class LunoraClient {
2002
2314
  }
2003
2315
  flags.onMutationAck?.(body.lastMutationId);
2004
2316
  flags.onCommitCursor?.(body.commitCursor);
2005
- return body.result;
2317
+ return decodeWire(body.result);
2006
2318
  }
2007
2319
  /**
2008
2320
  * Authenticated request to a non-RPC admin endpoint (the scheduler list /
@@ -2324,7 +2636,7 @@ class LunoraClient {
2324
2636
  case "chunk": {
2325
2637
  const { data, id } = message;
2326
2638
  const stream = this.streams.get(id);
2327
- stream?.handle.push(data);
2639
+ stream?.handle.push(decodeWire(data));
2328
2640
  return;
2329
2641
  }
2330
2642
  case "complete": {
@@ -2404,7 +2716,9 @@ class LunoraClient {
2404
2716
  return;
2405
2717
  }
2406
2718
  const existing = buffer.parts.get(message.shapeId) ?? [];
2407
- existing.push(...message.rowsPatch);
2719
+ for (const op of message.rowsPatch) {
2720
+ existing.push(op.value === void 0 ? op : { ...op, value: decodeWire(op.value) });
2721
+ }
2408
2722
  buffer.parts.set(message.shapeId, existing);
2409
2723
  if (message.lastMutationId !== void 0) {
2410
2724
  buffer.lastMutationId.set(message.shapeId, message.lastMutationId);
@@ -2550,9 +2864,9 @@ class LunoraClient {
2550
2864
  // eslint-disable-next-line class-methods-use-this -- instance method for symmetry with the other message handlers; reads no shared client state
2551
2865
  resolveDataPayload(message, state) {
2552
2866
  if ("data" in message && message.data !== void 0) {
2553
- return message.data;
2867
+ return decodeWire(message.data);
2554
2868
  }
2555
- const { delta } = message;
2869
+ const delta = decodeWire(message.delta);
2556
2870
  if (isMutationDelta(delta) && state.serverBase !== void 0) {
2557
2871
  const merged = applyDelta(state.serverBase, delta);
2558
2872
  if (merged !== void 0) {
@@ -2567,9 +2881,10 @@ class LunoraClient {
2567
2881
  if (!handlers) {
2568
2882
  return;
2569
2883
  }
2884
+ const data = decodeWire(message.data);
2570
2885
  for (const handler of handlers) {
2571
2886
  try {
2572
- handler(message.data, message.from);
2887
+ handler(data, message.from);
2573
2888
  } catch {
2574
2889
  }
2575
2890
  }
@@ -2673,24 +2988,123 @@ class LunoraClient {
2673
2988
  async flushOfflineQueue(shardKey) {
2674
2989
  const key = connectionKey(shardKey);
2675
2990
  const drained = this.offlineQueue.drain((item) => connectionKey(item.shardKey) === key);
2676
- for (let index = 0; index < drained.length; index += 1) {
2677
- const item = drained[index];
2678
- if (!item) {
2679
- continue;
2991
+ if (drained.length === 0) {
2992
+ return;
2993
+ }
2994
+ const currentIdentity = this.identityFingerprint();
2995
+ const sendable = [];
2996
+ for (const item of drained) {
2997
+ if (this.passesReplayIdentityGate(item, currentIdentity)) {
2998
+ sendable.push(item);
2680
2999
  }
2681
- const currentIdentity = this.identityFingerprint();
2682
- const liveStamp = item.id === void 0 ? void 0 : this.queuedIdentities.get(item.id);
2683
- const stamped = liveStamp === void 0 ? item.identity : liveStamp;
2684
- if (stamped !== void 0 && stamped !== currentIdentity) {
2685
- this.queuedIdentities.delete(item.id ?? "");
2686
- this.unpersist(item.id);
2687
- const error = new Error("offline mutation skipped: auth identity changed before replay");
2688
- error.code = "OFFLINE_IDENTITY_CHANGED";
2689
- item.reject(error);
2690
- this.emitItemSettled(item, "rejected", error);
2691
- continue;
3000
+ }
3001
+ if (sendable.length === 0) {
3002
+ return;
3003
+ }
3004
+ const encodable = this.encodableOrSettleTerminal(sendable);
3005
+ if (encodable.length === 0) {
3006
+ return;
3007
+ }
3008
+ if (encodable.length === 1) {
3009
+ await this.replaySequential(encodable);
3010
+ return;
3011
+ }
3012
+ const toRequeue = [];
3013
+ for (let start = 0; start < encodable.length; start += MAX_BATCH_ENTRIES) {
3014
+ const chunk = encodable.slice(start, start + MAX_BATCH_ENTRIES);
3015
+ const outcome = await this.replayBatched(chunk);
3016
+ toRequeue.push(...outcome.requeue);
3017
+ if (outcome.stop) {
3018
+ toRequeue.push(...encodable.slice(start + MAX_BATCH_ENTRIES));
3019
+ break;
3020
+ }
3021
+ }
3022
+ if (toRequeue.length > 0) {
3023
+ this.offlineQueue.requeue(toRequeue);
3024
+ }
3025
+ }
3026
+ /**
3027
+ * Partition already-gated writes into the encodable ones (returned) and reject
3028
+ * the rest terminally. A write whose args can't be wire-encoded (e.g. a RegExp
3029
+ * or class instance in a `v.any()` field) can NEVER replay — the codec failure
3030
+ * is deterministic, not transient. Rejecting here is essential: otherwise
3031
+ * `encodeWire` throws mid-flush, is classified as transient (a codec error has
3032
+ * no `.code`), and re-queues forever — a silent hang where the caller's Promise
3033
+ * never settles and the optimistic write never rolls back. Encoding is cheap;
3034
+ * the flush is the slow reconnect path.
3035
+ */
3036
+ encodableOrSettleTerminal(items) {
3037
+ const encodable = [];
3038
+ for (const item of items) {
3039
+ try {
3040
+ encodeCallArgs(item.args, `args for '${item.functionPath}'`);
3041
+ encodable.push(item);
3042
+ } catch (error) {
3043
+ this.settleReplayTerminal(item, error instanceof Error ? error : new Error(String(error)));
2692
3044
  }
3045
+ }
3046
+ return encodable;
3047
+ }
3048
+ /**
3049
+ * Identity guard for one queued write about to replay: a write stamped under
3050
+ * one identity must never replay under another. The live `queuedIdentities`
3051
+ * map is the source of truth for the current session; a hydrated write whose
3052
+ * id isn't in the map falls back to the stamp persisted with the record
3053
+ * (`item.identity`), so a reload can't replay another user's queued writes.
3054
+ * Only legacy records (persisted before stamps were durable —
3055
+ * `item.identity === undefined`) replay under whatever identity is current.
3056
+ *
3057
+ * `Map.get` returns `undefined` for unstamped/hydrated ids and `item.identity`
3058
+ * is `undefined` for legacy records; a persisted `null` (queued while signed
3059
+ * out) is a real value that must not collapse into `undefined` — hence the
3060
+ * explicit `=== undefined` check rather than `??`. Returns `true` when the
3061
+ * write may replay; otherwise settles it `OFFLINE_IDENTITY_CHANGED` and returns
3062
+ * `false`. Either way the live stamp is consumed.
3063
+ */
3064
+ passesReplayIdentityGate(item, currentIdentity) {
3065
+ const liveStamp = item.id === void 0 ? void 0 : this.queuedIdentities.get(item.id);
3066
+ const stamped = liveStamp === void 0 ? item.identity : liveStamp;
3067
+ if (stamped !== void 0 && stamped !== currentIdentity) {
2693
3068
  this.queuedIdentities.delete(item.id ?? "");
3069
+ this.unpersist(item.id);
3070
+ const error = new Error("offline mutation skipped: auth identity changed before replay");
3071
+ error.code = "OFFLINE_IDENTITY_CHANGED";
3072
+ item.reject(error);
3073
+ this.emitItemSettled(item, "rejected", error);
3074
+ return false;
3075
+ }
3076
+ this.queuedIdentities.delete(item.id ?? "");
3077
+ return true;
3078
+ }
3079
+ /** 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. */
3080
+ settleReplaySuccess(item, value, commitCursor) {
3081
+ this.unpersist(item.id);
3082
+ item.onCommit?.(commitCursor);
3083
+ item.resolve(value);
3084
+ this.emitItemSettled(item, "committed");
3085
+ }
3086
+ /** Settle a write the server reached a coded verdict on: replaying would re-trigger the same failure (a poison-message loop), so drop it. */
3087
+ settleReplayTerminal(item, error) {
3088
+ this.unpersist(item.id);
3089
+ item.reject(error);
3090
+ this.emitItemSettled(item, "rejected", error);
3091
+ }
3092
+ /**
3093
+ * Replay already-identity-gated writes one at a time on the single-call `/rpc`
3094
+ * path, preserving FIFO order (parallel `.then()` chains would race the
3095
+ * ordering callers depend on). Each replays under its stable `mutationId` so
3096
+ * the server dedups a write it already committed (exactly-once). A coded error
3097
+ * is a server verdict (drop it); a codeless (transport/transient) failure stops
3098
+ * the flush and re-queues this write and every unreplayed one for the next
3099
+ * reconnect — their callers stay pending, and the identity guard re-applies on
3100
+ * retry via each record's persisted stamp.
3101
+ */
3102
+ async replaySequential(items) {
3103
+ for (let index = 0; index < items.length; index += 1) {
3104
+ const item = items[index];
3105
+ if (!item) {
3106
+ continue;
3107
+ }
2694
3108
  try {
2695
3109
  let commitCursor;
2696
3110
  const value = await this.rpc(item.functionPath, item.args, item.shardKey, {
@@ -2700,22 +3114,116 @@ class LunoraClient {
2700
3114
  commitCursor = cursor;
2701
3115
  }
2702
3116
  });
2703
- this.unpersist(item.id);
2704
- item.onCommit?.(commitCursor);
2705
- item.resolve(value);
2706
- this.emitItemSettled(item, "committed");
3117
+ this.settleReplaySuccess(item, value, commitCursor);
2707
3118
  } catch (error) {
2708
3119
  if (error.code !== void 0) {
2709
- this.unpersist(item.id);
2710
- item.reject(error);
2711
- this.emitItemSettled(item, "rejected", error);
3120
+ this.settleReplayTerminal(item, error);
2712
3121
  continue;
2713
3122
  }
2714
- this.offlineQueue.requeue(drained.slice(index));
3123
+ this.offlineQueue.requeue(items.slice(index));
2715
3124
  return;
2716
3125
  }
2717
3126
  }
2718
3127
  }
3128
+ /**
3129
+ * Coalesce already-identity-gated writes for a single shard into ONE
3130
+ * `/_lunora/rpc-batch` round trip (plan 088 follow-on). The worker forwards
3131
+ * them to the shard DO, which replays each through its single-call dispatch, so
3132
+ * per-entry `mutationId` idempotency and in-order application are inherited from
3133
+ * the proven path. Per-slot demux mirrors {@link replaySequential}'s
3134
+ * classification: success confirms the optimistic layer against the echoed
3135
+ * `commitCursor`; a coded application verdict is terminal; a transient shard
3136
+ * failure (`SHARD_UNAVAILABLE`/`SHARD_ERROR`), a missing slot, or a whole-batch
3137
+ * transport failure re-queues for the next reconnect (never dropping a durable
3138
+ * write). A whole-batch coded rejection (bad request / authorization denial the
3139
+ * server reached a verdict on) is terminal for every entry.
3140
+ *
3141
+ * Returns the writes that must be re-queued and `stop` — `true` when the whole
3142
+ * chunk failed at the transport level, so the caller leaves later chunks queued
3143
+ * rather than sending on. The caller re-queues once, in order, so requeuing is
3144
+ * NOT done here.
3145
+ */
3146
+ async replayBatched(items) {
3147
+ if (!this.fetchImpl) {
3148
+ return { requeue: items, stop: true };
3149
+ }
3150
+ let response;
3151
+ try {
3152
+ response = await this.fetchImpl(joinUrl(this.url, RPC_BATCH_PATH), {
3153
+ body: JSON.stringify({
3154
+ calls: items.map((item, index) => {
3155
+ return {
3156
+ args: encodeCallArgs(item.args, `args for '${item.functionPath}'`),
3157
+ functionPath: item.functionPath,
3158
+ id: index,
3159
+ // Stable per-write key so the DO dedups a write it already
3160
+ // committed (exactly-once), exactly as the single-call replay.
3161
+ mutationId: item.id,
3162
+ shardKey: item.shardKey
3163
+ };
3164
+ })
3165
+ }),
3166
+ headers: this.rpcRequestHeaders({ attachBookmark: true }),
3167
+ method: "POST"
3168
+ });
3169
+ } catch {
3170
+ return { requeue: items, stop: true };
3171
+ }
3172
+ const bookmark = response.headers.get("x-d1-bookmark");
3173
+ if (bookmark) {
3174
+ this.bookmark.set(bookmark);
3175
+ }
3176
+ let body;
3177
+ try {
3178
+ body = await response.json();
3179
+ } catch {
3180
+ return { requeue: items, stop: true };
3181
+ }
3182
+ if (!body.results) {
3183
+ if (body.error) {
3184
+ const error = reconstructError(body.error);
3185
+ for (const item of items) {
3186
+ this.settleReplayTerminal(item, error);
3187
+ }
3188
+ return { requeue: [], stop: false };
3189
+ }
3190
+ return { requeue: items, stop: true };
3191
+ }
3192
+ return { requeue: this.settleReplayBatchSlots(items, body.results), stop: false };
3193
+ }
3194
+ /**
3195
+ * Demux a `/_lunora/rpc-batch` reply back onto the queued writes it replayed,
3196
+ * in input order. Each slot's envelope classifies its write the same way
3197
+ * {@link replaySequential} does: a success confirms the optimistic layer
3198
+ * against the echoed `commitCursor`; a coded application verdict is terminal;
3199
+ * a transient shard failure ({@link TRANSIENT_BATCH_ERROR_CODES}) or a slot the
3200
+ * server never returned is returned for the caller to re-queue.
3201
+ * @returns the writes that must be re-queued (transient slots), in input order
3202
+ */
3203
+ settleReplayBatchSlots(items, results) {
3204
+ const bySlot = /* @__PURE__ */ new Map();
3205
+ for (const entry of results) {
3206
+ if (typeof entry.id === "number" && entry.body !== void 0) {
3207
+ bySlot.set(entry.id, entry.body);
3208
+ }
3209
+ }
3210
+ const requeue = [];
3211
+ for (const [index, item] of items.entries()) {
3212
+ const inner = bySlot.get(index);
3213
+ if (inner === void 0) {
3214
+ requeue.push(item);
3215
+ } else if ("error" in inner) {
3216
+ if (TRANSIENT_BATCH_ERROR_CODES.has(inner.error.code)) {
3217
+ requeue.push(item);
3218
+ } else {
3219
+ this.settleReplayTerminal(item, reconstructError(inner.error));
3220
+ }
3221
+ } else {
3222
+ this.settleReplaySuccess(item, decodeWire(inner.result), inner.commitCursor);
3223
+ }
3224
+ }
3225
+ return requeue;
3226
+ }
2719
3227
  }
2720
3228
 
2721
3229
  export { LunoraClient };