@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.
- package/dist/auth/index.d.mts +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/index.d.mts +30 -3
- package/dist/index.d.ts +30 -3
- package/dist/index.mjs +4 -4
- package/dist/packem_shared/{LunoraClient-CgZ6FhKP.mjs → LunoraClient-wh6w9Ivn.mjs} +548 -40
- package/dist/packem_shared/SubscriptionRegistry-Cxr70og-.mjs +1 -0
- package/dist/packem_shared/{createInMemoryPersistence-CW82inU5.mjs → createInMemoryPersistence-DlFjWtOm.mjs} +13 -1
- package/dist/packem_shared/{createInMemoryQueryCache-B1PQ9Twl.mjs → createInMemoryQueryCache-4AkTV38-.mjs} +13 -1
- package/dist/packem_shared/{createServerClient-BxkNcRlR.mjs → createServerClient-C5GMCGSQ.mjs} +1 -1
- package/dist/packem_shared/{lunora-client.d-B5vWSgvD.d.mts → lunora-client.d-DTwbfUWF.d.mts} +121 -6
- package/dist/packem_shared/{lunora-client.d-B5vWSgvD.d.ts → lunora-client.d-DTwbfUWF.d.ts} +121 -6
- package/dist/packem_shared/{preload.d-3XJD-2hM.d.mts → preload.d-BEp0JJeu.d.mts} +1 -1
- package/dist/packem_shared/{preload.d-CKZR675M.d.ts → preload.d-CK2IYf5B.d.ts} +1 -1
- package/dist/packem_shared/{subscription-C1Jy7HiF.mjs → subscription-DoyO04-2.mjs} +10 -0
- package/dist/query/index.d.mts +2 -2
- package/dist/query/index.d.ts +2 -2
- package/dist/ssr/index.d.mts +3 -3
- package/dist/ssr/index.d.ts +3 -3
- package/dist/ssr/index.mjs +1 -1
- package/package.json +1 -1
- package/dist/packem_shared/SubscriptionRegistry-Dn-7k7eo.mjs +0 -1
|
@@ -1,12 +1,231 @@
|
|
|
1
|
-
import { S as SubscriptionRegistry, s as stableStringify } from './subscription-
|
|
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 {
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
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
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
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.
|
|
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.
|
|
2710
|
-
item.reject(error);
|
|
2711
|
-
this.emitItemSettled(item, "rejected", error);
|
|
3120
|
+
this.settleReplayTerminal(item, error);
|
|
2712
3121
|
continue;
|
|
2713
3122
|
}
|
|
2714
|
-
this.offlineQueue.requeue(
|
|
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 };
|