@lunora/client 1.0.0-alpha.27 → 1.0.0-alpha.29

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 (77) hide show
  1. package/dist/auth/index.d.mts +1 -1
  2. package/dist/auth/index.d.ts +1 -1
  3. package/dist/auth/index.mjs +1 -60
  4. package/dist/index.d.mts +3 -3
  5. package/dist/index.d.ts +3 -3
  6. package/dist/index.mjs +1 -21
  7. package/dist/packem_shared/CONFLICT_ERROR_CODE-B2SU0CaJ.mjs +1 -0
  8. package/dist/packem_shared/ClientServiceWorker-CRQ5yl7-.mjs +1 -0
  9. package/dist/packem_shared/DEFAULT_MAX_BUFFER-TBn2s7Rz.mjs +1 -0
  10. package/dist/packem_shared/LunoraClient-DDEoZdgP.mjs +1 -0
  11. package/dist/packem_shared/OfflineQueue-B603_q8m.mjs +1 -0
  12. package/dist/packem_shared/SKIP-CArzT3Vl.mjs +1 -0
  13. package/dist/packem_shared/SubscriptionRegistry-CdrOD7sZ.mjs +1 -0
  14. package/dist/packem_shared/TabCoordinator-ijLic02x.mjs +1 -0
  15. package/dist/packem_shared/applyDelta-DXqQORbM.mjs +1 -0
  16. package/dist/packem_shared/createAsyncStoragePersistence-BRLqeXsC.mjs +1 -0
  17. package/dist/packem_shared/createClientQuery-CrdRoicO.mjs +1 -0
  18. package/dist/packem_shared/createInMemoryBookmarkStorage-DCd5Ej-t.mjs +1 -0
  19. package/dist/packem_shared/createInMemoryPersistence-CQYXy8UP.mjs +1 -0
  20. package/dist/packem_shared/createInMemoryQueryCache-DjCKrLjk.mjs +1 -0
  21. package/dist/packem_shared/createLocalStore-B3Tw_Tw1.mjs +1 -0
  22. package/dist/packem_shared/createMutationRunner-lQXH8Amk.mjs +1 -0
  23. package/dist/packem_shared/createMutatorRunner-BVzo0FBZ.mjs +1 -0
  24. package/dist/packem_shared/createReconnect-CFT7YRSR.mjs +1 -0
  25. package/dist/packem_shared/createReply-DF0jRsv-.mjs +1 -0
  26. package/dist/packem_shared/createServerClient-B7KqO8tv.mjs +1 -0
  27. package/dist/packem_shared/createSnapshotPrecondition-DOKRAJ4b.mjs +1 -0
  28. package/dist/packem_shared/deserializePreloaded-CHfOFpka.mjs +1 -0
  29. package/dist/packem_shared/getServerSession-CGjgRBAE.mjs +1 -0
  30. package/dist/packem_shared/httpStream-Cz4_QyjV.mjs +11 -0
  31. package/dist/packem_shared/idb-utility-C-DR8nG1.mjs +1 -0
  32. package/dist/packem_shared/local-store-C53xDvEr.mjs +1 -0
  33. package/dist/packem_shared/{lunora-client.d-B1iZb2Ph.d.mts → lunora-client.d-Bt65hm_E.d.mts} +135 -1
  34. package/dist/packem_shared/{lunora-client.d-B1iZb2Ph.d.ts → lunora-client.d-Bt65hm_E.d.ts} +135 -1
  35. package/dist/packem_shared/offline-queue-D73Dq95V.mjs +1 -0
  36. package/dist/packem_shared/{preload.d-CLwoXl3R.d.mts → preload.d-BmztUzzG.d.mts} +1 -1
  37. package/dist/packem_shared/{preload.d-Cqb4Kh2I.d.ts → preload.d-DGGRMWB5.d.ts} +1 -1
  38. package/dist/packem_shared/preloadQuery-DjwSjFwt.mjs +1 -0
  39. package/dist/packem_shared/wire-key-D_zOxXK4.mjs +1 -0
  40. package/dist/pagination/index.mjs +1 -61
  41. package/dist/query/index.d.mts +2 -2
  42. package/dist/query/index.d.ts +2 -2
  43. package/dist/query/index.mjs +1 -1
  44. package/dist/ssr/index.d.mts +3 -3
  45. package/dist/ssr/index.d.ts +3 -3
  46. package/dist/ssr/index.mjs +1 -4
  47. package/dist/upload.mjs +1 -24
  48. package/package.json +2 -2
  49. package/dist/packem_shared/CONFLICT_ERROR_CODE-B8gQ8tyU.mjs +0 -33
  50. package/dist/packem_shared/ClientServiceWorker-C3PAFwy0.mjs +0 -100
  51. package/dist/packem_shared/DEFAULT_MAX_BUFFER-7hFnzNk9.mjs +0 -110
  52. package/dist/packem_shared/LunoraClient-DKATLGHY.mjs +0 -3631
  53. package/dist/packem_shared/OfflineQueue-BgarnAub.mjs +0 -1
  54. package/dist/packem_shared/SKIP-vItZChkw.mjs +0 -50
  55. package/dist/packem_shared/SubscriptionRegistry-CxS_Inha.mjs +0 -31
  56. package/dist/packem_shared/TabCoordinator-D_5oNTTt.mjs +0 -258
  57. package/dist/packem_shared/applyDelta-CRKZ1PBt.mjs +0 -81
  58. package/dist/packem_shared/createAsyncStoragePersistence-1Z5BZ8RC.mjs +0 -45
  59. package/dist/packem_shared/createClientQuery-dJZg1ohm.mjs +0 -80
  60. package/dist/packem_shared/createInMemoryBookmarkStorage-BoN7a7TH.mjs +0 -11
  61. package/dist/packem_shared/createInMemoryPersistence-DZ2VHWgm.mjs +0 -79
  62. package/dist/packem_shared/createInMemoryQueryCache-DiaGZkA2.mjs +0 -112
  63. package/dist/packem_shared/createLocalStore-BtqUmOQA.mjs +0 -2
  64. package/dist/packem_shared/createMutationRunner-BqsavzvG.mjs +0 -21
  65. package/dist/packem_shared/createMutatorRunner-BETvCd0p.mjs +0 -31
  66. package/dist/packem_shared/createReconnect-Di_-oHH7.mjs +0 -22
  67. package/dist/packem_shared/createReply-lI4tVS2w.mjs +0 -36
  68. package/dist/packem_shared/createServerClient-Bp2r9tcB.mjs +0 -11
  69. package/dist/packem_shared/createSnapshotPrecondition-CxQ1T4ZP.mjs +0 -18
  70. package/dist/packem_shared/deserializePreloaded-C0eJTY_W.mjs +0 -4
  71. package/dist/packem_shared/getServerSession-8jXewqxd.mjs +0 -13
  72. package/dist/packem_shared/httpStream-DIdL8NEw.mjs +0 -168
  73. package/dist/packem_shared/idb-utility-DrSVX43Q.mjs +0 -48
  74. package/dist/packem_shared/local-store-DIq-UWfD.mjs +0 -106
  75. package/dist/packem_shared/offline-queue-N-1JvYb4.mjs +0 -211
  76. package/dist/packem_shared/preloadQuery-lobFkD2Z.mjs +0 -13
  77. package/dist/packem_shared/wire-key-Djie6aaR.mjs +0 -266
@@ -1,3631 +0,0 @@
1
- import { LunoraError } from '@lunora/errors';
2
- import { s as stableWireKey, d as decodeWire, e as encodeWire } from './wire-key-Djie6aaR.mjs';
3
- import createInMemoryBookmarkStorage from './createInMemoryBookmarkStorage-BoN7a7TH.mjs';
4
- import { ClientQueryStore } from './createClientQuery-dJZg1ohm.mjs';
5
- import { TabCoordinator } from './TabCoordinator-D_5oNTTt.mjs';
6
- import { isMutationDelta, applyDelta } from './applyDelta-CRKZ1PBt.mjs';
7
- import { httpStream } from './httpStream-DIdL8NEw.mjs';
8
- import { f as foldOptimistic, a as applyOptimisticLayer, d as dropConfirmedLayers, n as notifySubscription, c as createLocalStore } from './local-store-DIq-UWfD.mjs';
9
- import { O as OfflineQueue, n as nextId, i as isStaleVersion, r as reportPersistenceError } from './offline-queue-N-1JvYb4.mjs';
10
- import { resolvePersistenceAdapter } from './createInMemoryPersistence-DZ2VHWgm.mjs';
11
- import { resolveQueryCacheAdapter, queryCacheKey } from './createInMemoryQueryCache-DiaGZkA2.mjs';
12
- import { createReconnect } from './createReconnect-Di_-oHH7.mjs';
13
- import { createStream } from './DEFAULT_MAX_BUFFER-7hFnzNk9.mjs';
14
- import { SubscriptionRegistry } from './SubscriptionRegistry-CxS_Inha.mjs';
15
-
16
- const MAX_BATCH_ENTRIES = 500;
17
-
18
- const evictOldestEntry = (map, capacity) => {
19
- if (map.size < capacity) {
20
- return;
21
- }
22
- const oldest = map.keys().next().value;
23
- if (oldest !== void 0) {
24
- map.delete(oldest);
25
- }
26
- };
27
-
28
- class Listeners {
29
- listeners = /* @__PURE__ */ new Set();
30
- add(listener) {
31
- this.listeners.add(listener);
32
- return () => {
33
- this.listeners.delete(listener);
34
- };
35
- }
36
- // The conditional rest tuple makes `emit()` argument-free for a
37
- // `Listeners<void>` and one-argument for every other payload.
38
- // 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
39
- emit(...args) {
40
- const [value] = args;
41
- for (const listener of this.listeners) {
42
- try {
43
- listener(value);
44
- } catch {
45
- }
46
- }
47
- }
48
- clear() {
49
- this.listeners.clear();
50
- }
51
- }
52
-
53
- const RPC_PATH = "/_lunora/rpc";
54
- const RPC_BATCH_PATH = "/_lunora/rpc-batch";
55
- const WS_PATH = "/_lunora/ws";
56
- const bucketQuery = (bucket) => bucket === void 0 || bucket === "" ? "" : `&bucket=${encodeURIComponent(bucket)}`;
57
- const rollbackOptimistic = (optimisticRollbacks) => {
58
- for (let index = optimisticRollbacks.length - 1; index >= 0; index -= 1) {
59
- optimisticRollbacks[index]?.();
60
- }
61
- };
62
- const applyRowOpsToView = (rows, ops) => {
63
- for (const op of ops) {
64
- if (op.op === "delete") {
65
- rows.delete(op.key);
66
- } else if (op.value !== void 0) {
67
- rows.set(op.key, op.value);
68
- }
69
- }
70
- };
71
- const WS_KEEPALIVE_PING = "lunora-ping";
72
- const DEFAULT_HEARTBEAT_INTERVAL_MS = 3e4;
73
- const DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
74
- const QUERY_CACHE_DEBOUNCE_MS = 250;
75
- const MAX_PENDING_STREAMS = 64;
76
- const SHARD_TRAFFIC_PATH = "/_lunora/admin/shard-traffic";
77
- const SCHEDULED_PATH = "/_lunora/admin/scheduled";
78
- const SCHEDULED_STATUS_PATH = "/_lunora/admin/scheduled/status";
79
- const SCHEDULED_WS_PATH = "/_lunora/admin/scheduled/ws";
80
- const SCHEDULED_CANCEL_PATH = "/_lunora/admin/scheduled/cancel";
81
- const SCHEDULED_DEAD_PATH = "/_lunora/admin/scheduled/dead";
82
- const SCHEDULED_DEAD_RETRY_PATH = "/_lunora/admin/scheduled/dead/retry";
83
- const SCHEDULED_DEAD_CANCEL_PATH = "/_lunora/admin/scheduled/dead/cancel";
84
- const WORKFLOWS_INSTANCES_PATH = "/_lunora/admin/workflows/instances";
85
- const WORKFLOWS_INSTANCE_PATH = "/_lunora/admin/workflows/instance";
86
- const WORKFLOWS_STATUS_PATH = "/_lunora/admin/workflows/status";
87
- const STORAGE_PATH = "/_lunora/admin/storage";
88
- const STORAGE_URL_PATH = "/_lunora/admin/storage/url";
89
- const STORAGE_BUCKETS_PATH = "/_lunora/admin/storage/buckets";
90
- const FUNCTIONS_PATH = "/_lunora/admin/functions";
91
- const CRON_JOBS_PATH = "/_lunora/admin/cron-jobs";
92
- const CRON_JOBS_RUN_PATH = "/_lunora/admin/cron-jobs/run";
93
- const OPENAPI_PATH = "/_lunora/admin/openapi";
94
- const OPENRPC_PATH = "/_lunora/admin/openrpc";
95
- const GLOBAL_TABLES_PATH = "/_lunora/admin/global/tables";
96
- const GLOBAL_TABLE_PATH = "/_lunora/admin/global/table";
97
- const GLOBAL_FACET_PATH = "/_lunora/admin/global/facet";
98
- const VECTOR_INDEXES_PATH = "/_lunora/admin/vector/indexes";
99
- const VECTOR_QUERY_PATH = "/_lunora/admin/vector/query";
100
- const LOG_ARCHIVE_PATH = "/_lunora/admin/logs/archive";
101
- const KV_NAMESPACES_PATH = "/_lunora/admin/kv/namespaces";
102
- const KV_KEYS_PATH = "/_lunora/admin/kv/keys";
103
- const KV_VALUE_PATH = "/_lunora/admin/kv/value";
104
- const AUTH_USERS_PATH = "/_lunora/admin/auth/users";
105
- const AUTH_SESSIONS_PATH = "/_lunora/admin/auth/sessions";
106
- const AUTH_CREATE_USER_PATH = "/_lunora/admin/auth/users/create";
107
- const AUTH_SET_ROLE_PATH = "/_lunora/admin/auth/users/role";
108
- const AUTH_BAN_PATH = "/_lunora/admin/auth/users/ban";
109
- const AUTH_UNBAN_PATH = "/_lunora/admin/auth/users/unban";
110
- const AUTH_SET_PASSWORD_PATH = "/_lunora/admin/auth/users/password";
111
- const AUTH_REMOVE_USER_PATH = "/_lunora/admin/auth/users/remove";
112
- const AUTH_IMPERSONATE_PATH = "/_lunora/admin/auth/users/impersonate";
113
- const AUTH_REVOKE_SESSION_PATH = "/_lunora/admin/auth/sessions/revoke";
114
- const AUTH_REVOKE_SESSIONS_PATH = "/_lunora/admin/auth/sessions/revoke-all";
115
- const AUTH_CAPABILITIES_PATH = "/_lunora/admin/auth/capabilities";
116
- const AUTH_UPDATE_USER_PATH = "/_lunora/admin/auth/users/update";
117
- const AUTH_ACCOUNTS_PATH = "/_lunora/admin/auth/accounts";
118
- const AUTH_UNLINK_ACCOUNT_PATH = "/_lunora/admin/auth/accounts/unlink";
119
- const AUTH_PASSKEYS_PATH = "/_lunora/admin/auth/passkeys";
120
- const AUTH_DELETE_PASSKEY_PATH = "/_lunora/admin/auth/passkeys/delete";
121
- const AUTH_DISABLE_2FA_PATH = "/_lunora/admin/auth/two-factor/disable";
122
- const AUTH_ORGS_PATH = "/_lunora/admin/auth/organizations";
123
- const AUTH_ORG_MEMBERS_PATH = "/_lunora/admin/auth/organizations/members";
124
- const AUTH_ORG_INVITATIONS_PATH = "/_lunora/admin/auth/organizations/invitations";
125
- const AUTH_REMOVE_MEMBER_PATH = "/_lunora/admin/auth/organizations/members/remove";
126
- const AUTH_CANCEL_INVITATION_PATH = "/_lunora/admin/auth/organizations/invitations/cancel";
127
- const AUTH_CONFIG_PATH = "/_lunora/admin/auth/config";
128
- const AUTH_CREATE_ORG_PATH = "/_lunora/admin/auth/organizations/create";
129
- const AUTH_UPDATE_ORG_PATH = "/_lunora/admin/auth/organizations/update";
130
- const AUTH_REMOVE_ORG_PATH = "/_lunora/admin/auth/organizations/remove";
131
- const AUTH_ADD_MEMBER_PATH = "/_lunora/admin/auth/organizations/members/add";
132
- const AUTH_INVITE_MEMBER_PATH = "/_lunora/admin/auth/organizations/members/invite";
133
- const AUTH_MEMBER_ROLE_PATH = "/_lunora/admin/auth/organizations/members/role";
134
- const AUTH_ORG_TEAMS_PATH = "/_lunora/admin/auth/organizations/teams";
135
- const AUTH_CREATE_TEAM_PATH = "/_lunora/admin/auth/organizations/teams/create";
136
- const AUTH_UPDATE_TEAM_PATH = "/_lunora/admin/auth/organizations/teams/update";
137
- const AUTH_REMOVE_TEAM_PATH = "/_lunora/admin/auth/organizations/teams/remove";
138
- const AUTH_ORG_TEAM_MEMBERS_PATH = "/_lunora/admin/auth/organizations/teams/members";
139
- const AUTH_ADD_TEAM_MEMBER_PATH = "/_lunora/admin/auth/organizations/teams/members/add";
140
- const AUTH_REMOVE_TEAM_MEMBER_PATH = "/_lunora/admin/auth/organizations/teams/members/remove";
141
- const AUTH_ORG_ROLES_PATH = "/_lunora/admin/auth/organizations/roles";
142
- const AUTH_CREATE_ROLE_PATH = "/_lunora/admin/auth/organizations/roles/create";
143
- const AUTH_UPDATE_ROLE_PATH = "/_lunora/admin/auth/organizations/roles/update";
144
- const AUTH_REMOVE_ROLE_PATH = "/_lunora/admin/auth/organizations/roles/remove";
145
- const DEFAULT_AUTH_BASE_PATH = "/api/auth";
146
- const GET_SESSION_PATH = "/get-session";
147
- const deriveWsUrl = (url) => {
148
- if (url.startsWith("https://")) {
149
- return `wss://${url.slice("https://".length)}`;
150
- }
151
- if (url.startsWith("http://")) {
152
- return `ws://${url.slice("http://".length)}`;
153
- }
154
- return url;
155
- };
156
- const joinUrl = (base, path) => {
157
- const trimmed = base.endsWith("/") ? base.slice(0, -1) : base;
158
- return `${trimmed}${path}`;
159
- };
160
- const withQuery = (path, params) => {
161
- const search = new URLSearchParams();
162
- for (const [key, value] of Object.entries(params)) {
163
- if (value !== void 0 && value !== "") {
164
- search.set(key, String(value));
165
- }
166
- }
167
- const query = search.toString();
168
- return query === "" ? path : `${path}?${query}`;
169
- };
170
- const connectionKey = (shardKey) => shardKey ?? "";
171
- const buildStreamError = (message) => {
172
- const errorEnvelope = message.error;
173
- const code = typeof errorEnvelope?.code === "string" ? errorEnvelope.code : void 0;
174
- const nestedMessage = typeof errorEnvelope?.message === "string" ? errorEnvelope.message : void 0;
175
- const messageText = (typeof message.message === "string" ? message.message : void 0) ?? nestedMessage ?? "stream error";
176
- return code === void 0 ? new Error(messageText) : new LunoraError(code, messageText);
177
- };
178
- const buildSubscriptionError = (message) => {
179
- const errorEnvelope = message.error;
180
- const code = typeof errorEnvelope?.code === "string" ? errorEnvelope.code : void 0;
181
- const nestedMessage = typeof errorEnvelope?.message === "string" ? errorEnvelope.message : void 0;
182
- const messageText = (typeof message.message === "string" ? message.message : void 0) ?? nestedMessage ?? "subscription error";
183
- return { message: messageText, ...code === void 0 ? {} : { code } };
184
- };
185
- const fanSubscriptionError = (callbacks, error) => {
186
- for (const errorCallback of callbacks) {
187
- try {
188
- errorCallback(error);
189
- } catch {
190
- }
191
- }
192
- };
193
- const sharedDecoder = new TextDecoder();
194
- const decodeServerFrame = (raw) => {
195
- if (typeof raw === "string") {
196
- return raw;
197
- }
198
- if (raw instanceof ArrayBuffer) {
199
- return sharedDecoder.decode(raw);
200
- }
201
- return void 0;
202
- };
203
- const sendOn = (conn, message) => {
204
- if (!conn.socket || conn.wsState !== "open") {
205
- return false;
206
- }
207
- try {
208
- conn.socket.send(JSON.stringify(message));
209
- return true;
210
- } catch {
211
- return false;
212
- }
213
- };
214
- const reconstructError = (errorBody) => {
215
- const error = new Error(errorBody.message ?? "request failed");
216
- error.code = errorBody.code;
217
- if (errorBody.data !== void 0) {
218
- error.data = decodeWire(errorBody.data);
219
- }
220
- if (errorBody.hint !== void 0) {
221
- error.hint = errorBody.hint;
222
- }
223
- if (errorBody.docsUrl !== void 0) {
224
- error.docsUrl = errorBody.docsUrl;
225
- }
226
- return error;
227
- };
228
- const encodeCallArgs = (payload, label) => {
229
- try {
230
- return encodeWire(payload);
231
- } catch (error) {
232
- const reason = error instanceof Error ? error.message : String(error);
233
- throw new TypeError(`LunoraClient: cannot encode ${label} — ${reason}`, error instanceof Error ? { cause: error } : void 0);
234
- }
235
- };
236
- const demuxBatchResults = (rawResults, count) => {
237
- const slots = Array.from({ length: count });
238
- for (const entry of rawResults) {
239
- if (typeof entry.id !== "number" || entry.id < 0 || entry.id >= count) {
240
- continue;
241
- }
242
- const inner = entry.body;
243
- slots[entry.id] = inner && "error" in inner && inner.error ? { error: reconstructError(inner.error), ok: false } : { ok: true, value: decodeWire(inner?.result) };
244
- }
245
- return slots.map((slot) => slot ?? { error: new Error("batch call returned no result"), ok: false });
246
- };
247
- const TRANSIENT_BATCH_ERROR_CODES = /* @__PURE__ */ new Set(["SHARD_ERROR", "SHARD_UNAVAILABLE"]);
248
- const RESOLVED_PROMISE = Promise.resolve();
249
- class LunoraClient {
250
- /** 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. */
251
- static MAX_POKE_BUFFERS = 256;
252
- /**
253
- * Create a typed {@link ClientQueryRef}. Convenience wrapper around
254
- * {@link createClientQuery} so you don't need a separate import.
255
- * @example
256
- * ```ts
257
- * const sidebarOpen = LunoraClient.createClientQuery("sidebarOpen", true);
258
- * ```
259
- */
260
- static createClientQuery(key, defaultValue) {
261
- return { defaultValue, key };
262
- }
263
- url;
264
- wsUrl;
265
- /** Local reactive store for {@link ClientQueryRef} values — no server round-trip. Private; reach it via `getClientQuery` / `setClientQuery` / `subscribeClientQuery`. */
266
- clientQueryStore;
267
- wsToken;
268
- /** Better-auth base path (trailing slash stripped) for the `get-session` lookup. */
269
- authBasePath;
270
- fetchImpl;
271
- WebSocketImpl;
272
- bookmark;
273
- reconnectOptions;
274
- /** WS connect timeout (ms); `0` disables it. See {@link LunoraClientOptions.connectTimeoutMs}. */
275
- connectTimeoutMs;
276
- /** Keepalive cadence (ms); `0` disables the heartbeat. See {@link LunoraClientOptions.heartbeatIntervalMs}. */
277
- heartbeatIntervalMs;
278
- offlineQueue;
279
- /**
280
- * Durable outbox seam (the `@lunora/db` `createExecutorOutboxSink`). When
281
- * set, offline writes are delegated here and the built-in {@link OfflineQueue}
282
- * is bypassed, so a db app has exactly one durable write path.
283
- */
284
- outbox;
285
- /** Stable per-client id stamped onto every `OutboxMutation` (custom-mutator watermark). */
286
- clientId;
287
- /**
288
- * `true` when the constructor's hydration microtask has finished loading the
289
- * durable read cache (Pillar 2) into `hydratedQueryCache`. Signals that
290
- * the cache is ready for synchronous `peekHydratedQuery` reads.
291
- */
292
- readyResolved = false;
293
- /** Resolvers for `whenReady()` — called once hydration completes. */
294
- readyResolve;
295
- /**
296
- * Promise that resolves once the durable read cache has been loaded. When
297
- * `hydrateOnStart` is not set or no query cache is configured, resolves
298
- * immediately (the constructor creates an already-resolved promise).
299
- */
300
- readyPromise;
301
- /**
302
- * Highest custom-mutator watermark the server has echoed for this client,
303
- * keyed by shard bucket (`shardKey ?? ""`) since the DO tracks one
304
- * `__client_watermark` per shard. `callMutator` bumps it from every
305
- * ack; the `@lunora/db` mutator runtime seeds its `clientSeq` generator from
306
- * it so a reload (which resets the in-memory counter) never reissues a stale
307
- * sequence the server would silently swallow as a replay.
308
- */
309
- clientWatermarks = /* @__PURE__ */ new Map();
310
- /** Monotonic per-client mutation counter backing the server `__client_watermark`. */
311
- outboxMutationCounter = 0;
312
- onPersistenceError;
313
- persistence;
314
- /** App/schema version stamped on persisted writes + cached reads; mismatches are purged. */
315
- persistenceVersion;
316
- /** Releases the multi-tab outbox-leader Web Lock on close (see `hydrateAsOutboxLeader`). */
317
- outboxLeaderRelease;
318
- /** Durable read cache (Pillar 2); `undefined` when `queryCache` is omitted or `false`. */
319
- queryCache;
320
- /**
321
- * Values restored from the `queryCache` at construction, keyed by the
322
- * read-cache key, awaiting the `subscribe()` that will consume them. A
323
- * key is consumed (deleted) the first time its subscription is created, so
324
- * the cache only ever seeds the initial value — live frames take over after.
325
- */
326
- hydratedQueryCache = /* @__PURE__ */ new Map();
327
- /**
328
- * Coalesced read-cache writes: the latest value per key, flushed to
329
- * the `queryCache` on a short debounce so a burst of deltas persists once.
330
- */
331
- pendingCacheWrites = /* @__PURE__ */ new Map();
332
- cacheFlushTimer;
333
- subscriptions = new SubscriptionRegistry();
334
- /**
335
- * Cross-tab coordinator; created only when `crossTabSync: true`. When the
336
- * client is not the elected leader, all WebSocket operations are skipped.
337
- * Not `readonly` — `close()` clears it (mirrors `outboxLeaderRelease`).
338
- */
339
- tabCoordinator;
340
- /** One {@link ShardConnection} per shard key (keyed by `shardKey ?? ""`). */
341
- connections = /* @__PURE__ */ new Map();
342
- /** Default `connect`-envelope context applied to a shard with no explicit override. */
343
- defaultConnectionContext;
344
- /**
345
- * Per-shard `connect`-envelope context registered via `setConnectionContext`
346
- * (keyed by `shardKey ?? ""`), overriding `defaultConnectionContext`. Sent
347
- * on every socket open so it replays across reconnects, and forwarded to the
348
- * server's `onConnect`/`onDisconnect` lifecycle hooks. This holds only the
349
- * imperative (last-writer-wins) override; refcounted holders registered via
350
- * `acquireConnectionContext` live in `connectionContextHolders` and take
351
- * precedence — see `effectiveConnectionContext`.
352
- */
353
- connectionContexts = /* @__PURE__ */ new Map();
354
- /**
355
- * Per-shard stack of refcounted connection-context holders (keyed by
356
- * `shardKey ?? ""`), registered via `acquireConnectionContext`. Each holder
357
- * is an opaque token carrying its `context`; the most-recently acquired
358
- * holder wins (last-writer-wins among live holders), and the context is only
359
- * cleared for a shard once its last holder releases — so two concurrently
360
- * mounted presence hooks on the same shard can't stomp each other's context
361
- * on cleanup. A holder is identified by reference identity so a release
362
- * removes exactly the right one regardless of stack position.
363
- */
364
- connectionContextHolders = /* @__PURE__ */ new Map();
365
- // `null` is the public sentinel for "signed out" across getAuthToken /
366
- // setAuthToken / onAuthTokenChange — part of the exported API contract.
367
- // eslint-disable-next-line unicorn/no-null -- public auth-token contract sentinel
368
- authToken = null;
369
- /**
370
- * Optional STABLE identity subject (a user id), the basis of the offline-queue
371
- * identity stamp when supplied. Keeps a same-user token *refresh* from looking
372
- * like an identity change (which would discard queued writes). `undefined` =
373
- * not supplied, so identity falls back to a hash of the raw token. See
374
- * `setAuthToken` / `identityFingerprint`.
375
- */
376
- authSubject = void 0;
377
- /**
378
- * Identity stamp recorded against each queued offline mutation, keyed by
379
- * the queue-assigned mutation id. Captured at enqueue from the auth token
380
- * in effect at the time, and re-checked at flush so a queued write can
381
- * never replay under a different identity than the one that issued it.
382
- * See `identityFingerprint` for the fingerprint shape.
383
- */
384
- queuedIdentities = /* @__PURE__ */ new Map();
385
- closed = false;
386
- /** Subscribers to auth-token changes (see `onAuthTokenChange`). */
387
- authTokenListeners = new Listeners();
388
- /** Subscribers to aggregate connection-status changes (see `onConnectionStatus`). */
389
- statusListeners = new Listeners();
390
- /** Subscribers notified when the server drops a socket for an expired token (see `onTokenExpired`). */
391
- tokenExpiredListeners = new Listeners();
392
- /** Subscribers to offline-queued mutation verdicts (see `onMutationSettled`). */
393
- mutationSettledListeners = new Listeners();
394
- /** Subscribers to the offline-queue pending-count (see `onPendingChange`). */
395
- pendingChangeListeners = new Listeners();
396
- /**
397
- * Whisper-topic handlers, keyed by `connectionKey(shardKey)` → topic → set
398
- * of callbacks. Membership doubles as the resubscribe set replayed on every
399
- * (re)connect so a topic survives a socket bounce.
400
- */
401
- whisperHandlers = /* @__PURE__ */ new Map();
402
- /** Last status broadcast, so we only notify listeners on an actual change. */
403
- lastStatus = "idle";
404
- nextSubId = 0;
405
- nextStreamId = 0;
406
- /**
407
- * In-flight client-side stream readers, keyed by the stream id sent on the
408
- * wire. The handle drives the underlying iterator queue and `shardKey`
409
- * tells us which socket to push the cancel frame onto when the consumer
410
- * calls `.cancel()` or the iterator is garbage-collected.
411
- */
412
- streams = /* @__PURE__ */ new Map();
413
- /** Live shape subscriptions (partial replication), keyed by their wire id. */
414
- shapeSubscriptions = /* @__PURE__ */ new Map();
415
- /** In-flight pokes being assembled between `pokeStart` and `pokeEnd`, keyed by `pokeId`. */
416
- pokeBuffers = /* @__PURE__ */ new Map();
417
- nextShapeId = 0;
418
- constructor(options) {
419
- this.url = options.url;
420
- this.wsUrl = options.wsUrl ?? joinUrl(deriveWsUrl(options.url), WS_PATH);
421
- this.wsToken = options.wsToken;
422
- const authBase = options.authBasePath ?? DEFAULT_AUTH_BASE_PATH;
423
- this.authBasePath = authBase.endsWith("/") ? authBase.slice(0, -1) : authBase;
424
- this.fetchImpl = options.fetch ?? (typeof fetch === "function" ? fetch.bind(globalThis) : void 0);
425
- this.WebSocketImpl = options.WebSocket ?? (typeof WebSocket === "function" ? WebSocket : void 0);
426
- this.bookmark = options.bookmarkStorage ?? createInMemoryBookmarkStorage();
427
- this.reconnectOptions = options.reconnect;
428
- this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
429
- this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
430
- this.defaultConnectionContext = options.connectionContext;
431
- this.persistence = resolvePersistenceAdapter(options.persistence, options.outbox === void 0);
432
- this.persistenceVersion = options.persistenceVersion;
433
- this.queryCache = resolveQueryCacheAdapter(options.queryCache);
434
- this.onPersistenceError = options.offlineQueue?.onPersistenceError;
435
- this.clientQueryStore = new ClientQueryStore();
436
- if (options.crossTabSync) {
437
- this.tabCoordinator = new TabCoordinator({
438
- onBecomeLeader: () => {
439
- for (const state of this.subscriptions.all()) {
440
- this.ensureSocket(state.shardKey);
441
- this.sendSubscribeIfOpen(state);
442
- }
443
- },
444
- onStopBeingLeader: () => {
445
- for (const [key, conn] of this.connections) {
446
- conn.socket?.close();
447
- this.connections.delete(key);
448
- }
449
- },
450
- onSubscriptionData: (key, data) => {
451
- const state = this.subscriptions.get(key);
452
- if (state) {
453
- state.serverBase = data;
454
- const folded = state.optimisticLayers.length === 0 ? data : foldOptimistic(data, state.optimisticLayers);
455
- state.lastValue = folded;
456
- for (const callback of state.callbacks) {
457
- try {
458
- callback(folded);
459
- } catch {
460
- }
461
- }
462
- }
463
- },
464
- onSubscriptionError: (key, error) => {
465
- const state = this.subscriptions.get(key);
466
- if (state) {
467
- for (const callback of state.errorCallbacks) {
468
- try {
469
- callback(error);
470
- } catch {
471
- }
472
- }
473
- }
474
- }
475
- });
476
- this.tabCoordinator.start();
477
- } else {
478
- this.tabCoordinator = void 0;
479
- }
480
- if (options.hydrateOnStart && this.queryCache) {
481
- this.readyPromise = new Promise((resolve) => {
482
- this.readyResolve = resolve;
483
- });
484
- } else {
485
- this.readyResolved = true;
486
- this.readyPromise = RESOLVED_PROMISE;
487
- }
488
- this.offlineQueue = new OfflineQueue(options.offlineQueue, {
489
- onEvict: (entry, error) => {
490
- this.emitItemSettled(entry, "rejected", error);
491
- },
492
- onSizeChange: (size) => {
493
- this.pendingChangeListeners.emit(size);
494
- },
495
- persistence: this.persistence,
496
- version: options.persistenceVersion
497
- });
498
- this.outbox = options.outbox;
499
- this.clientId = options.clientId ?? `client-${nextId()}`;
500
- if (this.persistence) {
501
- queueMicrotask(() => {
502
- this.hydrateAsOutboxLeader();
503
- });
504
- }
505
- if (this.queryCache) {
506
- queueMicrotask(() => {
507
- const hydration = async () => {
508
- try {
509
- await this.hydrateQueryCache();
510
- } catch {
511
- } finally {
512
- this.readyResolved = true;
513
- this.readyResolve?.();
514
- }
515
- };
516
- hydration().catch(() => {
517
- });
518
- });
519
- }
520
- }
521
- // --- Auth helpers -------------------------------------------------------
522
- /**
523
- * Set (or clear) the bearer token sent on every HTTP RPC. Notifies any
524
- * {@link onAuthTokenChange} listeners so React hooks like `useAuth` stay in
525
- * sync across all mounted instances.
526
- *
527
- * Pass a STABLE `subject` (the user id) to key the offline-queue identity on
528
- * it instead of the token bytes, so a token *refresh* (same user, new JWT)
529
- * doesn't read as an identity change and discard queued writes. The subject is
530
- * **sticky**: a later call that omits it (or passes `undefined`) keeps the
531
- * established subject — so `setAuthToken(refreshedToken)` after a prior
532
- * `setAuthToken(token, user.id)` retains the identity. Pass `null` to clear it
533
- * (an explicit sign-out). Establishing the subject for the first time on an
534
- * UNCHANGED token (e.g. the user id resolves a tick after the token was set)
535
- * re-stamps any in-flight queued writes rather than dropping them — same
536
- * credential, just a more stable label. A real user switch (the token AND
537
- * subject both change) still drops the previous user's writes.
538
- *
539
- * Does NOT update the WebSocket auth — the WS token is fixed at upgrade
540
- * time and lives in the URL. To refresh live WS auth, call
541
- * {@link setWsToken} explicitly, which closes existing shard sockets to
542
- * force a reconnect with the new credential.
543
- */
544
- setAuthToken(token, subject) {
545
- const tokenChanged = this.authToken !== token;
546
- const previousIdentity = this.identityFingerprint();
547
- this.authToken = token;
548
- if (subject !== void 0) {
549
- this.authSubject = subject;
550
- }
551
- const newIdentity = this.identityFingerprint();
552
- if (newIdentity !== previousIdentity) {
553
- if (tokenChanged) {
554
- this.rejectQueuedForIdentityChange();
555
- } else {
556
- this.restampQueuedIdentity(previousIdentity, newIdentity);
557
- }
558
- }
559
- if (tokenChanged) {
560
- this.authTokenListeners.emit(token);
561
- }
562
- }
563
- getAuthToken() {
564
- return this.authToken;
565
- }
566
- /**
567
- * The current identity fingerprint (the same stamp queued offline writes
568
- * carry). Exposed so a durable {@link OutboxSink}'s replay handler — which
569
- * owns its own at-least-once replay outside the built-in `OfflineQueue` —
570
- * can drop a persisted write whose captured `identity` no longer matches the
571
- * signed-in user, the guard the queue path applies in `flushOfflineQueue`.
572
- */
573
- currentIdentity() {
574
- return this.identityFingerprint();
575
- }
576
- /** This client's stable identifier — the watermark key the server's custom-mutator protocol advances per `clientSeq`. */
577
- clientIdentifier() {
578
- return this.clientId;
579
- }
580
- /**
581
- * The highest custom-mutator watermark the server has echoed for this client
582
- * on the given shard (0 if none yet). The `@lunora/db` mutator runtime seeds
583
- * its `clientSeq` generator from this so a reload never reissues a sequence
584
- * the server has already applied (which it would swallow as a replay, silently
585
- * dropping the write).
586
- */
587
- confirmedMutationWatermark(shardKey) {
588
- return this.clientWatermarks.get(shardKey ?? "") ?? 0;
589
- }
590
- /**
591
- * Push a custom mutator to its authoritative server impl over the watermark
592
- * protocol (Phase 4): the request carries `x-lunora-client-id` + a monotonic
593
- * `x-lunora-client-seq`, so the DO runs it exactly once and advances this
594
- * client's `__client_watermark`.
595
- *
596
- * Returns the server `result` plus `applied`: `true` when the DO ran this push
597
- * as the next-in-order mutation, `false` when it was a replay ack (`clientSeq`
598
- * was at or below the stored watermark — e.g. a stale sequence after a reload).
599
- * A `false` verdict tells the caller to reissue above the now-known watermark
600
- * (echoed into {@link confirmedMutationWatermark}) rather than treat the benign
601
- * ack as a confirmed write. Every ack — applied or not — bumps the watermark.
602
- *
603
- * This is the online transport for `@lunora/db`'s client-mutator runtime; the
604
- * optimistic overlay + durable-outbox concerns live in that runtime, not here.
605
- */
606
- async callMutator(functionPath, args, options) {
607
- const clientSeq = options?.clientSeq;
608
- if (clientSeq !== void 0 && (!Number.isInteger(clientSeq) || clientSeq <= 0)) {
609
- throw new LunoraError("INTERNAL", `callMutator: clientSeq must be a positive integer, got ${String(clientSeq)}`);
610
- }
611
- const bucket = options?.shardKey ?? "";
612
- let ackWatermark;
613
- const result = await this.rpc(functionPath, args, options?.shardKey, {
614
- captureBookmark: true,
615
- clientId: this.clientId,
616
- clientSeq,
617
- onMutationAck: (lastMutationId) => {
618
- ackWatermark = lastMutationId;
619
- }
620
- });
621
- if (ackWatermark !== void 0 && ackWatermark > (this.clientWatermarks.get(bucket) ?? 0)) {
622
- this.clientWatermarks.set(bucket, ackWatermark);
623
- }
624
- const applied = ackWatermark === void 0 || ackWatermark === clientSeq;
625
- return { applied, result };
626
- }
627
- /**
628
- * Subscribe to auth-token changes. Returns an unsubscribe function. The
629
- * listener is NOT invoked on registration — use {@link getAuthToken} for
630
- * the current value.
631
- */
632
- onAuthTokenChange(listener) {
633
- return this.authTokenListeners.add(listener);
634
- }
635
- /**
636
- * Fetch the currently authenticated user from better-auth's `get-session`
637
- * endpoint, returning the `user` record or `null` when signed out. Sends
638
- * the stored bearer token (if any) and `credentials: "include"` so a
639
- * cookie-session is also honoured. A network/parse failure or a non-OK
640
- * response resolves to `null` rather than throwing — callers treat "couldn't
641
- * resolve identity" as "signed out".
642
- *
643
- * Framework-agnostic: pair it with {@link onAuthTokenChange} to refetch when
644
- * the token changes (that's what `@lunora/react`'s `useAuth` does).
645
- */
646
- async getCurrentUser() {
647
- if (this.closed || !this.fetchImpl) {
648
- return null;
649
- }
650
- const headers = {};
651
- if (this.authToken) {
652
- headers["authorization"] = `Bearer ${this.authToken}`;
653
- }
654
- try {
655
- const response = await this.fetchImpl(joinUrl(this.url, `${this.authBasePath}${GET_SESSION_PATH}`), {
656
- credentials: "include",
657
- headers,
658
- method: "GET"
659
- });
660
- if (!response.ok) {
661
- return null;
662
- }
663
- const body = await response.json();
664
- return body?.user ?? null;
665
- } catch {
666
- return null;
667
- }
668
- }
669
- /**
670
- * Replace the token appended to WS upgrade URLs as `?token=…` and close
671
- * every open shard socket so the reconnect picks up the new value. Call
672
- * this whenever the user's WS credential changes (rotating the admin token
673
- * in the studio, switching workspaces, etc.). Accepts a static string or a
674
- * {@link WsTokenProvider} resolved fresh at every (re)connect — the channel
675
- * for short-lived credentials like the minted ephemeral admin sub-token.
676
- * Bearer tokens for HTTP RPC are independent — see {@link setAuthToken}.
677
- */
678
- setWsToken(token) {
679
- if (this.wsToken === token) {
680
- return;
681
- }
682
- this.wsToken = token;
683
- for (const conn of this.connections.values()) {
684
- if (conn.socket) {
685
- try {
686
- conn.socket.close();
687
- } catch {
688
- }
689
- }
690
- }
691
- }
692
- /**
693
- * Register (or clear, with `undefined`) the app context sent in the `connect`
694
- * envelope for a shard's socket, overriding the client-wide
695
- * {@link LunoraClientOptions.connectionContext}. The server forwards it to the
696
- * `onConnect`/`onDisconnect` lifecycle hooks as `event.context` — e.g.
697
- * `@lunora/react`'s `usePresence` registers `{ roomId, sessionId }` so the
698
- * presence row is removed the instant the socket drops, with no TTL lag.
699
- *
700
- * Stored per shard and replayed on every (re)connect. When a socket for the
701
- * shard is already open, a fresh `connect` envelope is sent immediately so the
702
- * server sees the new context without waiting for a reconnect.
703
- */
704
- setConnectionContext(context, options = {}) {
705
- const key = connectionKey(options.shardKey);
706
- if (context === void 0) {
707
- this.connectionContexts.delete(key);
708
- } else {
709
- this.connectionContexts.set(key, context);
710
- }
711
- this.refreshConnectionContext(key);
712
- }
713
- /**
714
- * Refcounted variant of {@link setConnectionContext}: register a connection
715
- * `context` for a shard and get back a release function. Unlike the imperative
716
- * setter, the context is only cleared once the *last* acquired holder releases
717
- * it — so two components (e.g. two mounted `usePresence` hooks) on the same
718
- * shard no longer clobber each other's context when one of them unmounts. The
719
- * most-recently acquired live holder wins (last-writer-wins), and releasing
720
- * the top holder falls back to the previous one rather than clearing.
721
- *
722
- * With a single holder the behaviour is identical to a
723
- * `setConnectionContext(context)` / `setConnectionContext(undefined)` pair.
724
- * Releasing more than once is a no-op (the holder is matched by reference, so
725
- * a double release can't drop a different holder).
726
- */
727
- acquireConnectionContext(context, options = {}) {
728
- const key = connectionKey(options.shardKey);
729
- const holder = { context };
730
- const holders = this.connectionContextHolders.get(key);
731
- if (holders) {
732
- holders.push(holder);
733
- } else {
734
- this.connectionContextHolders.set(key, [holder]);
735
- }
736
- this.refreshConnectionContext(key);
737
- let released = false;
738
- return () => {
739
- if (released) {
740
- return;
741
- }
742
- released = true;
743
- const live = this.connectionContextHolders.get(key);
744
- if (!live) {
745
- return;
746
- }
747
- const index = live.indexOf(holder);
748
- if (index !== -1) {
749
- live.splice(index, 1);
750
- }
751
- if (live.length === 0) {
752
- this.connectionContextHolders.delete(key);
753
- }
754
- this.refreshConnectionContext(key);
755
- };
756
- }
757
- // --- Whispering ---------------------------------------------------------
758
- /**
759
- * Join a whisper `topic` and receive every ephemeral message other members
760
- * broadcast to it on the same shard (typing indicators, live cursors,
761
- * presence pings). Whispers never touch the server's durable state — there's
762
- * no query, no row, no CDC entry. Returns an unsubscribe function; the topic
763
- * is left on the server once its last local handler unsubscribes.
764
- *
765
- * `handler` receives the raw `data` and the sender's verified `from` user id
766
- * (omitted for an anonymous sender). The topic is scoped to `options.shardKey`
767
- * (the default shard when omitted) — use the same shard you target with the
768
- * matching queries/mutations so members land on the same Durable Object.
769
- *
770
- * Security: whisper topics are NOT access-controlled beyond the shard
771
- * boundary — any client that can open a socket to the shard can join, read,
772
- * and inject on any topic name. `from` is server-stamped and unforgeable, but
773
- * do not put data on a whisper topic that some shard members shouldn't see,
774
- * and don't trust a whisper's `data` as authorization. Use a query/mutation
775
- * (with RLS) for anything privileged; whispers are for transient awareness.
776
- */
777
- whisperSubscribe(topic, handler, options = {}) {
778
- const key = connectionKey(options.shardKey);
779
- let byTopic = this.whisperHandlers.get(key);
780
- if (!byTopic) {
781
- byTopic = /* @__PURE__ */ new Map();
782
- this.whisperHandlers.set(key, byTopic);
783
- }
784
- let handlers = byTopic.get(topic);
785
- const first = handlers === void 0;
786
- if (!handlers) {
787
- handlers = /* @__PURE__ */ new Set();
788
- byTopic.set(topic, handlers);
789
- }
790
- handlers.add(handler);
791
- this.ensureSocket(options.shardKey);
792
- if (first) {
793
- const conn = this.getConnection(options.shardKey);
794
- if (conn) {
795
- sendOn(conn, { topic, type: "whisper_subscribe" });
796
- }
797
- }
798
- return () => {
799
- const stillByTopic = this.whisperHandlers.get(key);
800
- const stillHandlers = stillByTopic?.get(topic);
801
- if (!stillHandlers?.delete(handler) || stillHandlers.size > 0) {
802
- return;
803
- }
804
- stillByTopic?.delete(topic);
805
- if (stillByTopic?.size === 0) {
806
- this.whisperHandlers.delete(key);
807
- }
808
- const conn = this.getConnection(options.shardKey);
809
- if (conn) {
810
- sendOn(conn, { topic, type: "whisper_unsubscribe" });
811
- }
812
- };
813
- }
814
- /**
815
- * Broadcast an ephemeral `data` payload to the other members of a whisper
816
- * `topic` on `options.shardKey`'s shard. Fire-and-forget: the frame is
817
- * dropped when the shard socket isn't open (whispers are transient, never
818
- * queued), and the server silently drops it if the sender exceeds its
819
- * whisper rate budget. The sender never receives its own whisper. Omitting
820
- * `data` delivers JSON `null` to receivers (not `undefined`).
821
- */
822
- whisper(topic, data, options = {}) {
823
- this.ensureSocket(options.shardKey);
824
- const conn = this.getConnection(options.shardKey);
825
- if (conn) {
826
- sendOn(conn, { data: encodeCallArgs(data ?? null, `whisper data for topic '${topic}'`), topic, type: "whisper" });
827
- }
828
- }
829
- /**
830
- * Subscribe to token-expiry events: invoked whenever the server drops a
831
- * shard socket because the connection's credential lapsed (close code
832
- * `4001`). The client already reconnects automatically (re-resolving
833
- * identity from the cookie/token in effect); use this to refresh a
834
- * short-lived token first — e.g. call {@link setWsToken} / {@link setAuthToken}
835
- * with a freshly minted one. Returns an unsubscribe function.
836
- */
837
- onTokenExpired(listener) {
838
- return this.tokenExpiredListeners.add(listener);
839
- }
840
- // --- Connection status --------------------------------------------------
841
- /**
842
- * Current aggregate live-socket status across all shard connections. See
843
- * {@link ConnectionStatus}.
844
- */
845
- connectionStatus() {
846
- return this.computeStatus();
847
- }
848
- /**
849
- * Subscribe to aggregate connection-status changes. Invokes `listener`
850
- * immediately with the current status, then on every transition. Returns an
851
- * unsubscribe function.
852
- */
853
- onConnectionStatus(listener) {
854
- const unsubscribe = this.statusListeners.add(listener);
855
- listener(this.computeStatus());
856
- return unsubscribe;
857
- }
858
- /**
859
- * Number of offline writes waiting in the built-in queue to be sent — the
860
- * depth for a "N changes waiting to sync" indicator. Counts writes that are
861
- * queued (offline / mid-reconnect), not ones already in flight on the wire.
862
- * A `@lunora/db` app whose writes ride the unified outbox should read
863
- * `LunoraDb.pendingCount()` instead (this counts only the built-in queue).
864
- */
865
- pendingCount() {
866
- return this.offlineQueue.size;
867
- }
868
- /**
869
- * Subscribe to changes in {@link pendingCount}. Invokes `listener` immediately
870
- * with the current count, then whenever the queue depth changes (a write is
871
- * enqueued, flushed, or discarded). Returns an unsubscribe function.
872
- */
873
- onPendingChange(listener) {
874
- const unsubscribe = this.pendingChangeListeners.add(listener);
875
- listener(this.offlineQueue.size);
876
- return unsubscribe;
877
- }
878
- /**
879
- * Subscribe to terminal verdicts for offline-queued mutations. The listener
880
- * fires once per queued write that commits or is rejected — including a write
881
- * restored from durable storage after a reload, whose original `mutation()`
882
- * Promise no longer exists (`hadAwaiter: false`), and a write the queue
883
- * evicts on overflow or discards on an identity change. This is the durable
884
- * channel for surfacing a rolled-back optimistic write to the UI; an online
885
- * mutation that never queued still surfaces through the Promise `mutation()`
886
- * returns. The listener is NOT invoked on registration. Returns an
887
- * unsubscribe function. See {@link MutationSettledEvent}.
888
- */
889
- onMutationSettled(listener) {
890
- return this.mutationSettledListeners.add(listener);
891
- }
892
- /**
893
- * The `WebSocket` implementation this client was constructed with (an
894
- * explicit `options.WebSocket`, or the ambient global on platforms that have
895
- * one) — `undefined` if neither is available. This is the seam a feature
896
- * that opens its OWN socket outside the client's multiplexed connection
897
- * (e.g. a voice-agent hook) should default to, instead of reaching for
898
- * `globalThis.WebSocket` directly: on React Native the client wraps this
899
- * constructor to inject the auth-headers factory's credential onto the
900
- * upgrade request (`createLunoraClient`'s `withAuthWebSocket`), which a raw
901
- * `new globalThis.WebSocket(url)` would silently bypass.
902
- */
903
- getWebSocketImpl() {
904
- return this.WebSocketImpl;
905
- }
906
- // --- Client Query (local state) ----------------------------------------
907
- /**
908
- * Read the current value for a {@link ClientQueryRef}. Returns
909
- * `ref.defaultValue` when no value has been explicitly set.
910
- */
911
- getClientQuery(ref) {
912
- return this.clientQueryStore.get(ref);
913
- }
914
- /**
915
- * Set a new value for `ref` and notify every subscriber. Pass `undefined`
916
- * to reset the slot to `ref.defaultValue`.
917
- */
918
- setClientQuery(ref, value) {
919
- this.clientQueryStore.set(ref, value);
920
- }
921
- /**
922
- * Subscribe to changes for `ref`. The callback is NOT invoked on
923
- * registration — call {@link getClientQuery} for the current value.
924
- * Returns an unsubscribe function.
925
- */
926
- subscribeClientQuery(ref, callback) {
927
- return this.clientQueryStore.subscribe(ref, callback);
928
- }
929
- /**
930
- * Reset a {@link ClientQueryRef} to its default value, notifying every
931
- * subscriber. Equivalent to `setClientQuery(ref, ref.defaultValue)` but
932
- * removes the stored entry so a future {@link getClientQuery} returns
933
- * the default rather than an explicitly-set value.
934
- */
935
- resetClientQuery(ref) {
936
- this.clientQueryStore.reset(ref);
937
- }
938
- /**
939
- * Capture a snapshot of the current live query value at call time and
940
- * produce a `() => boolean` precondition that compares it against the
941
- * value at replay time (on queue drain / reconnect).
942
- *
943
- * When the precondition is checked it re-reads the query's current value
944
- * via `peekActiveQueryValue`. If the value differs from what was
945
- * captured at call time the precondition returns `false` and the offline
946
- * mutation is dropped as stale.
947
- *
948
- * This is a method wrapper around `createSnapshotPrecondition` that
949
- * binds the client instance for you — no need to pass `client` explicitly.
950
- * @example
951
- * ```ts
952
- * client.mutation(api.todos.update, { id, text }, {
953
- * precondition: client.snapshotPrecondition(api.todos.list, { userId }),
954
- * });
955
- * ```
956
- */
957
- snapshotPrecondition(functionRef, args, shardKey) {
958
- const snapshot = this.peekActiveQueryValue(functionRef.__lunoraRef, args, shardKey);
959
- const snapshotKey = snapshot === void 0 ? void 0 : stableWireKey(snapshot);
960
- return () => {
961
- const current = this.peekActiveQueryValue(functionRef.__lunoraRef, args, shardKey);
962
- if (snapshotKey === void 0 && current === void 0) {
963
- return true;
964
- }
965
- if (snapshotKey === void 0 || current === void 0) {
966
- return false;
967
- }
968
- return stableWireKey(current) === snapshotKey;
969
- };
970
- }
971
- // --- Hydration helpers --------------------------------------------------
972
- /**
973
- * Resolves once the durable read cache has been loaded into memory. When
974
- * `hydrateOnStart` is not configured or no query cache adapter is active,
975
- * returns an already-resolved promise so callers can always await it
976
- * unconditionally.
977
- *
978
- * Framework adapters (React, Vue, etc.) use this to gate the first
979
- * (enabled) render of a live query behind hydration, so the user sees
980
- * cached data instead of an undefined flash before the socket round-trip.
981
- */
982
- whenReady() {
983
- return this.readyPromise;
984
- }
985
- /**
986
- * Synchronously reports whether {@link whenReady} has already resolved (the
987
- * durable read cache is loaded, or none is configured). Framework adapters
988
- * read this to seed the hydration-gate state on the first render without
989
- * awaiting, then subscribe via {@link whenReady} for the pending case.
990
- */
991
- get isReady() {
992
- return this.readyResolved;
993
- }
994
- /**
995
- * Synchronously peek at a value the durable read cache loaded for the given
996
- * function path + args + shard key. Returns `undefined` when:
997
- *
998
- * - No query cache adapter is configured.
999
- * - Hydration hasn't completed yet (race — await {@link whenReady} first).
1000
- * - The cached value's identity fingerprint doesn't match the current auth.
1001
- *
1002
- * Unlike the internal {@link takeHydratedCache}, this is a READ-ONLY peek:
1003
- * the cached entry stays in `hydratedQueryCache` so the subscription created
1004
- * later by {@link subscribe} consumes it normally.
1005
- */
1006
- peekHydratedQuery(functionPath, args, shardKey) {
1007
- if (!this.readyResolved) {
1008
- return void 0;
1009
- }
1010
- const argsKey = stableWireKey(args);
1011
- const key = queryCacheKey(functionPath, argsKey, shardKey);
1012
- const entry = this.hydratedQueryCache.get(key);
1013
- if (entry === void 0) {
1014
- return void 0;
1015
- }
1016
- return entry.identity === this.identityFingerprint() ? entry.value : void 0;
1017
- }
1018
- /**
1019
- * Peek at the **current live value** of an active subscription, if one
1020
- * exists. Returns the subscription's `lastValue` (which includes any
1021
- * optimistic overlay) or `undefined` if no subscription is active for the
1022
- * given `(functionPath, args, shardKey)`.
1023
- *
1024
- * Unlike {@link peekHydratedQuery} (which reads from the durable read cache
1025
- * and is independent of active subscriptions), this method reflects the
1026
- * current in-memory state of an already-opened subscription — useful for
1027
- * offline mutation preconditions that need to snapshot the value at call time
1028
- * and compare it at replay time.
1029
- */
1030
- peekActiveQueryValue(functionPath, args, shardKey) {
1031
- const key = SubscriptionRegistry.key(functionPath, args, shardKey);
1032
- const state = this.subscriptions.get(key);
1033
- return state?.lastValue;
1034
- }
1035
- // --- RPC ---------------------------------------------------------------
1036
- async query(function_, args, options = {}) {
1037
- if (this.closed) {
1038
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1039
- }
1040
- return await this.rpc(function_.__lunoraRef, args, options.shardKey, { attachBookmark: true });
1041
- }
1042
- /**
1043
- * Batch several independent calls into ONE round trip (plan 088). Each call is
1044
- * dispatched server-side exactly as an individual RPC — per-shard
1045
- * authorization, `(identity, mutationId)` idempotency, and custom-mutator
1046
- * watermark ordering are all preserved — and the worker splits the batch by
1047
- * shard so calls to different shards fan out to their own DOs. Results are
1048
- * demuxed back in input order; a failing call does NOT fail the batch (its
1049
- * slot carries `{ ok: false, error }`, with `.code`/`.data` reconstructed like
1050
- * a single call). Args/results ride the value codec (bytes/bigint survive).
1051
- *
1052
- * No promise pipelining and no capability passing — a call's args cannot
1053
- * reference another call's result (see plan 088 §fence; capabilities are
1054
- * incompatible with DO hibernation).
1055
- */
1056
- async batch(calls) {
1057
- if (this.closed) {
1058
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1059
- }
1060
- if (!this.fetchImpl) {
1061
- throw new LunoraError("INTERNAL", "LunoraClient: no `fetch` implementation available");
1062
- }
1063
- if (calls.length === 0) {
1064
- return [];
1065
- }
1066
- const response = await this.fetchImpl(joinUrl(this.url, RPC_BATCH_PATH), {
1067
- body: JSON.stringify({
1068
- calls: calls.map((call, index) => {
1069
- return {
1070
- args: encodeCallArgs(call.args ?? {}, `args for batch call '${call.fn.__lunoraRef}'`),
1071
- functionPath: call.fn.__lunoraRef,
1072
- id: index,
1073
- shardKey: call.shardKey
1074
- };
1075
- })
1076
- }),
1077
- headers: this.rpcRequestHeaders({ attachBookmark: true }),
1078
- method: "POST"
1079
- });
1080
- const bookmark = response.headers.get("x-d1-bookmark");
1081
- if (bookmark) {
1082
- this.bookmark.set(bookmark);
1083
- }
1084
- let body;
1085
- try {
1086
- body = await response.json();
1087
- } catch {
1088
- throw new LunoraError("INTERNAL", `LunoraClient: batch response was not JSON (status ${response.status.toString()})`);
1089
- }
1090
- if (!response.ok || body.error && !body.results) {
1091
- if (body.error) {
1092
- throw reconstructError(body.error);
1093
- }
1094
- throw new LunoraError("INTERNAL", `LunoraClient: batch request failed (status ${response.status.toString()})`);
1095
- }
1096
- return demuxBatchResults(body.results ?? [], calls.length);
1097
- }
1098
- /**
1099
- * Invoke a mutation. Errors propagate as rejections.
1100
- *
1101
- * Offline-queue semantics: a mutation is queued (and replayed on reconnect)
1102
- * only when the targeted shard's socket was open at least once already
1103
- * (`wasEverConnected`), so the registry / resubscribe handshake has run.
1104
- * Mutations issued before the very first WS connect to a shard fail fast.
1105
- * Opt into queueing-before-first-connect via
1106
- * `OfflineQueueOptions.queueBeforeFirstConnect`.
1107
- */
1108
- async mutation(function_, args, options = {}) {
1109
- if (this.closed) {
1110
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1111
- }
1112
- const argsRecord = args;
1113
- const mutationId = options.mutationId ?? nextId();
1114
- const { confirms: optimisticConfirms, rollbacks: optimisticRollbacks } = this.applyOptimisticUpdates(
1115
- function_.__lunoraRef,
1116
- argsRecord,
1117
- options.shardKey,
1118
- options.optimistic
1119
- );
1120
- if (options.optimisticUpdate) {
1121
- this.applyOptimisticUpdate(options.optimisticUpdate, args, options.shardKey, optimisticRollbacks, optimisticConfirms);
1122
- }
1123
- const conn = this.getConnection(options.shardKey);
1124
- const wsState = conn?.wsState ?? "idle";
1125
- const hasSocket = conn?.socket !== void 0;
1126
- const wasEverConnected = conn?.wasEverConnected ?? false;
1127
- const { queueBeforeFirstConnect } = this.offlineQueue;
1128
- const connectedGate = wasEverConnected || queueBeforeFirstConnect;
1129
- const shouldQueueOffline = this.WebSocketImpl !== void 0 && connectedGate;
1130
- const midReconnect = wsState === "connecting" && connectedGate;
1131
- if (wsState !== "open" && !hasSocket && shouldQueueOffline || midReconnect) {
1132
- return this.enqueueOfflineMutation(
1133
- function_,
1134
- argsRecord,
1135
- options.shardKey,
1136
- mutationId,
1137
- optimisticRollbacks,
1138
- optimisticConfirms,
1139
- options.precondition
1140
- );
1141
- }
1142
- try {
1143
- let commitCursor;
1144
- const result = await this.rpc(function_.__lunoraRef, argsRecord, options.shardKey, {
1145
- captureBookmark: true,
1146
- mutationId,
1147
- onCommitCursor: (cursor) => {
1148
- commitCursor = cursor;
1149
- }
1150
- });
1151
- for (const confirm of optimisticConfirms) {
1152
- confirm(commitCursor);
1153
- }
1154
- return result;
1155
- } catch (error) {
1156
- rollbackOptimistic(optimisticRollbacks);
1157
- throw error;
1158
- }
1159
- }
1160
- async action(function_, args, options = {}) {
1161
- if (this.closed) {
1162
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1163
- }
1164
- return await this.rpc(function_.__lunoraRef, args, options.shardKey);
1165
- }
1166
- // --- Advisor admin ------------------------------------------------------
1167
- /**
1168
- * Read the cross-shard request distribution for a `.shardBy(...)` table —
1169
- * the feed the studio's `hot_shard` advisor lint consumes. Hits the
1170
- * admin-gated `POST /_lunora/admin/shard-traffic` endpoint, which fans the
1171
- * cheap per-shard `getMetrics` read out across every live shard and returns
1172
- * each shard's `{ shardKey, requests }` total (a failed shard surfaces with
1173
- * `requests: 0`). Requires the worker to be built with a `queryCoordinator`
1174
- * and `adminToken`, and this client's auth token to match; defaults any
1175
- * absent field so an older worker yields an empty-but-valid shape.
1176
- */
1177
- async shardTraffic(table) {
1178
- if (this.closed) {
1179
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1180
- }
1181
- const body = await this.adminFetch(SHARD_TRAFFIC_PATH, "POST", { table });
1182
- return { failed: body.failed ?? 0, ok: body.ok ?? 0, shards: body.shards ?? [] };
1183
- }
1184
- // --- Scheduler admin ----------------------------------------------------
1185
- /**
1186
- * List the functions queued via `runAfter` / `runAt`, soonest-due last
1187
- * (the worker returns them in storage order). Hits the admin-gated
1188
- * `/_lunora/admin/scheduled` endpoint, so the worker must be built with a
1189
- * `schedulerDO` namespace and `adminToken`, and this client's auth token
1190
- * must match. Powers `@lunora/studio`'s scheduled-jobs panel.
1191
- */
1192
- async listScheduledJobs() {
1193
- if (this.closed) {
1194
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1195
- }
1196
- const body = await this.adminFetch(SCHEDULED_PATH, "GET");
1197
- return body.records ?? [];
1198
- }
1199
- /**
1200
- * Read the app-level workpool backlog that powers `@lunora/studio`'s SLO
1201
- * view: per-pool `{ name, queued, inFlight, maxConcurrency }` plus the
1202
- * app-wide `backlog` (total queued) and `inFlight` (total held slots) sums.
1203
- * Hits the admin-gated `GET /_lunora/admin/scheduled/status` endpoint, so the
1204
- * same preconditions as {@link listScheduledJobs} apply (a `schedulerDO`
1205
- * namespace + `adminToken` on the worker and a matching auth token here).
1206
- * Defaults any absent field so an older worker still yields a valid shape.
1207
- */
1208
- async schedulerStatus() {
1209
- if (this.closed) {
1210
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1211
- }
1212
- const body = await this.adminFetch(SCHEDULED_STATUS_PATH, "GET");
1213
- return {
1214
- backlog: body.backlog ?? 0,
1215
- inFlight: body.inFlight ?? 0,
1216
- pools: body.pools ?? []
1217
- };
1218
- }
1219
- /** Cancel a pending scheduled job by id. Returns whether a job was removed. */
1220
- async cancelScheduledJob(id) {
1221
- if (this.closed) {
1222
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1223
- }
1224
- const body = await this.adminFetch(SCHEDULED_CANCEL_PATH, "POST", { id });
1225
- return { cancelled: body.cancelled === true };
1226
- }
1227
- /**
1228
- * List the dead-letter jobs: schedules that exhausted their retry budget
1229
- * and were parked instead of dropped. These never appear in
1230
- * {@link listScheduledJobs} (their live header is gone), so this is the only
1231
- * way the studio surfaces a permanently-failed job. Hits the admin-gated
1232
- * `GET /_lunora/admin/scheduled/dead`; same preconditions as
1233
- * {@link listScheduledJobs}. Powers `@lunora/studio`'s dead-letter panel.
1234
- */
1235
- async listDeadJobs() {
1236
- if (this.closed) {
1237
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1238
- }
1239
- const body = await this.adminFetch(SCHEDULED_DEAD_PATH, "GET");
1240
- return body.records ?? [];
1241
- }
1242
- /**
1243
- * Resurrect a dead-letter job by id: it re-enters the schedule with a fresh
1244
- * retry budget and fires on the next drain. Returns whether a parked record
1245
- * matched. Hits the admin-gated `POST /_lunora/admin/scheduled/dead/retry`.
1246
- */
1247
- async retryDeadJob(id) {
1248
- if (this.closed) {
1249
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1250
- }
1251
- const body = await this.adminFetch(SCHEDULED_DEAD_RETRY_PATH, "POST", { id });
1252
- return { retried: body.retried === true };
1253
- }
1254
- /**
1255
- * Permanently drop a dead-letter job by id (the operator has decided not to
1256
- * recover it). Returns whether a parked record was removed. Hits the
1257
- * admin-gated `POST /_lunora/admin/scheduled/dead/cancel`.
1258
- */
1259
- async removeDeadJob(id) {
1260
- if (this.closed) {
1261
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1262
- }
1263
- const body = await this.adminFetch(SCHEDULED_DEAD_CANCEL_PATH, "POST", { id });
1264
- return { removed: body.removed === true };
1265
- }
1266
- /**
1267
- * List a workflow's instances via the admin Workflows proxy
1268
- * (`/_lunora/admin/workflows/instances`) — the Cloudflare control-plane data
1269
- * the `Workflow` binding can't expose. Requires the worker to be built with a
1270
- * `workflowsClient` (Cloudflare account id + API token). When one isn't
1271
- * configured this does NOT reject: the proxy returns a `200 { configured:
1272
- * false }` sentinel, so the result resolves with `configured === false` and an
1273
- * empty `instances` list — callers should branch on that flag rather than
1274
- * try/catch. (The instance-detail / status endpoints still reject with 501.)
1275
- * `name` is the deployed workflow name.
1276
- */
1277
- async listWorkflowInstances(options) {
1278
- if (this.closed) {
1279
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1280
- }
1281
- const query = new URLSearchParams({ name: options.name });
1282
- if (options.status !== void 0) {
1283
- query.set("status", options.status);
1284
- }
1285
- if (options.page !== void 0) {
1286
- query.set("page", String(options.page));
1287
- }
1288
- if (options.perPage !== void 0) {
1289
- query.set("perPage", String(options.perPage));
1290
- }
1291
- const body = await this.adminFetch(`${WORKFLOWS_INSTANCES_PATH}?${query.toString()}`, "GET");
1292
- return {
1293
- configured: body.configured,
1294
- instances: body.instances ?? [],
1295
- page: body.page ?? 1,
1296
- perPage: body.perPage ?? options.perPage ?? 0,
1297
- totalCount: body.totalCount
1298
- };
1299
- }
1300
- /** Read one workflow instance with its step timeline (`/_lunora/admin/workflows/instance`). */
1301
- async getWorkflowInstance(options) {
1302
- if (this.closed) {
1303
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1304
- }
1305
- const query = new URLSearchParams({ id: options.id, name: options.name });
1306
- const body = await this.adminFetch(`${WORKFLOWS_INSTANCE_PATH}?${query.toString()}`, "GET");
1307
- return {
1308
- createdOn: body.createdOn,
1309
- endedOn: body.endedOn,
1310
- error: body.error,
1311
- id: body.id ?? options.id,
1312
- output: body.output,
1313
- params: body.params,
1314
- startedOn: body.startedOn,
1315
- status: body.status ?? "unknown",
1316
- steps: body.steps ?? []
1317
- };
1318
- }
1319
- /** Pause / resume / terminate a workflow instance (`/_lunora/admin/workflows/status`). Needs an Edit-scoped Cloudflare token. */
1320
- async setWorkflowInstanceStatus(options) {
1321
- if (this.closed) {
1322
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1323
- }
1324
- const body = await this.adminFetch(WORKFLOWS_STATUS_PATH, "POST", { action: options.action, id: options.id, name: options.name });
1325
- return { status: body.status ?? "unknown" };
1326
- }
1327
- /**
1328
- * Subscribe to the live scheduled-jobs list over the SchedulerDO's admin
1329
- * WebSocket. `onJobs` fires with the full list on connect and on every
1330
- * change (schedule / cancel / alarm-fire). Reconnects with the client's
1331
- * configured backoff. Requires `wsToken` to be set to an admin credential
1332
- * (the browser can't send an `Authorization` header on a WS) — the master
1333
- * token, or preferably a {@link WsTokenProvider} minting the ephemeral
1334
- * sub-token so the master credential stays out of the URL. Returns an
1335
- * unsubscribe function that closes the socket and stops reconnecting.
1336
- */
1337
- subscribeScheduledJobs(onJobs) {
1338
- if (this.closed) {
1339
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1340
- }
1341
- if (this.WebSocketImpl === void 0) {
1342
- return () => void 0;
1343
- }
1344
- const base = joinUrl(deriveWsUrl(this.url), SCHEDULED_WS_PATH);
1345
- const reconnect = createReconnect(this.reconnectOptions);
1346
- let socket;
1347
- let timer;
1348
- let closed = false;
1349
- const openWith = (token) => {
1350
- if (closed || this.WebSocketImpl === void 0) {
1351
- return;
1352
- }
1353
- const url = token === void 0 ? base : `${base}?token=${encodeURIComponent(token)}`;
1354
- socket = new this.WebSocketImpl(url);
1355
- socket.addEventListener("open", () => {
1356
- reconnect.reset();
1357
- });
1358
- socket.addEventListener("message", (event) => {
1359
- try {
1360
- const message = JSON.parse(typeof event.data === "string" ? event.data : "");
1361
- if (message.type === "jobs" && Array.isArray(message.records)) {
1362
- onJobs(message.records);
1363
- }
1364
- } catch {
1365
- }
1366
- });
1367
- socket.addEventListener("close", () => {
1368
- socket = void 0;
1369
- if (!closed) {
1370
- timer = setTimeout(connect, reconnect.next());
1371
- }
1372
- });
1373
- socket.addEventListener("error", () => {
1374
- });
1375
- };
1376
- const connectWithProvider = async (provider) => {
1377
- let token;
1378
- try {
1379
- token = await provider();
1380
- } catch {
1381
- if (!closed) {
1382
- timer = setTimeout(connect, reconnect.next());
1383
- }
1384
- return;
1385
- }
1386
- openWith(token);
1387
- };
1388
- const connect = () => {
1389
- if (closed || this.WebSocketImpl === void 0) {
1390
- return;
1391
- }
1392
- const { wsToken } = this;
1393
- if (typeof wsToken === "function") {
1394
- connectWithProvider(wsToken).catch(() => void 0);
1395
- return;
1396
- }
1397
- openWith(wsToken);
1398
- };
1399
- connect();
1400
- return () => {
1401
- closed = true;
1402
- if (timer !== void 0) {
1403
- clearTimeout(timer);
1404
- }
1405
- socket?.close();
1406
- };
1407
- }
1408
- // --- Functions admin ----------------------------------------------------
1409
- /**
1410
- * List the registered public functions (queries / mutations / actions) with
1411
- * their kinds. Hits the admin-gated `GET /_lunora/admin/functions` endpoint —
1412
- * the worker must be built with a `functions` registry and `adminToken`, and
1413
- * this client's auth token must match. Powers `@lunora/studio`'s function
1414
- * runner auto-discovery.
1415
- */
1416
- async listFunctions() {
1417
- if (this.closed) {
1418
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1419
- }
1420
- const body = await this.adminFetch(FUNCTIONS_PATH, "GET");
1421
- return body.functions ?? [];
1422
- }
1423
- /**
1424
- * List the code-defined cron triggers (the `cronJobs()` map injected on the
1425
- * worker), each flattened to its firing `cron` expression. Hits the
1426
- * admin-gated `GET /_lunora/admin/cron-jobs` endpoint — the worker must be
1427
- * built with a `cronJobs` map and `adminToken`, and this client's auth token
1428
- * must match. These are static (Cloudflare exposes no runtime cron
1429
- * introspection), so the studio renders them read-only alongside the dynamic
1430
- * scheduler jobs.
1431
- */
1432
- async getCronJobs() {
1433
- if (this.closed) {
1434
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1435
- }
1436
- const body = await this.adminFetch(CRON_JOBS_PATH, "GET");
1437
- return body.jobs ?? [];
1438
- }
1439
- /**
1440
- * Manually fire one code-defined cron job by name — the same dispatch the
1441
- * scheduled trigger runs (dispatch the function, or start the durable
1442
- * workflow), on demand. Hits the admin-gated `POST /_lunora/admin/cron-jobs/run`
1443
- * endpoint; the worker must be built with a `cronJobs` map and `adminToken`,
1444
- * and this client's auth token must match. Resolves when the job has run (a
1445
- * function job's shard response is 2xx, or the workflow instance was created)
1446
- * and rejects with the dispatch error otherwise.
1447
- */
1448
- async runCronJob(name) {
1449
- if (this.closed) {
1450
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1451
- }
1452
- const body = await this.adminFetch(CRON_JOBS_RUN_PATH, "POST", { name });
1453
- return { name: body.name ?? name, ran: body.ran === true };
1454
- }
1455
- /**
1456
- * Fetch the generated OpenAPI 3.1 document. Hits the admin-gated
1457
- * `GET /_lunora/admin/openapi` endpoint — the worker must be built with an
1458
- * `openApiSpec` and `adminToken`, and this client's auth token must match.
1459
- * Powers `@lunora/studio`'s API-reference (Scalar) view. When the worker has
1460
- * no spec wired, the endpoint still resolves with an empty-but-valid OpenAPI
1461
- * document (no `paths`), so callers can render a "not configured" state.
1462
- */
1463
- async fetchOpenApi() {
1464
- if (this.closed) {
1465
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1466
- }
1467
- return await this.adminFetch(OPENAPI_PATH, "GET");
1468
- }
1469
- /**
1470
- * Fetch the generated OpenRPC 1.x document. Hits the admin-gated
1471
- * `GET /_lunora/admin/openrpc` endpoint — the worker must be built with an
1472
- * `openRpcSpec` and `adminToken`, and this client's auth token must match.
1473
- * OpenRPC is the RPC-native spec (a `methods` array over the JSON-RPC-shaped
1474
- * `POST /_lunora/rpc` transport); it documents the RPC functions only.
1475
- * Powers `@lunora/studio`'s OpenRPC API-reference view. When the worker has
1476
- * no spec wired, the endpoint still resolves with an empty-but-valid OpenRPC
1477
- * document (no `methods`), so callers can render a "not configured" state.
1478
- */
1479
- async fetchOpenRpc() {
1480
- if (this.closed) {
1481
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1482
- }
1483
- return await this.adminFetch(OPENRPC_PATH, "GET");
1484
- }
1485
- // --- Storage admin ------------------------------------------------------
1486
- /**
1487
- * List objects in the storage bucket, optionally under a `prefix` and from a
1488
- * pagination `cursor`. Hits the admin-gated `GET /_lunora/admin/storage`
1489
- * endpoint — the worker must be built with a `storageList` function and
1490
- * `adminToken`, and this client's auth token must match. Powers
1491
- * `@lunora/studio`'s file browser.
1492
- */
1493
- async listStorageObjects(options = {}) {
1494
- if (this.closed) {
1495
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1496
- }
1497
- const params = new URLSearchParams();
1498
- if (options.prefix !== void 0 && options.prefix !== "") {
1499
- params.set("prefix", options.prefix);
1500
- }
1501
- if (options.cursor !== void 0 && options.cursor !== "") {
1502
- params.set("cursor", options.cursor);
1503
- }
1504
- if (options.limit !== void 0) {
1505
- params.set("limit", String(options.limit));
1506
- }
1507
- if (options.bucket !== void 0 && options.bucket !== "") {
1508
- params.set("bucket", options.bucket);
1509
- }
1510
- const query = params.toString();
1511
- const path = query === "" ? STORAGE_PATH : `${STORAGE_PATH}?${query}`;
1512
- const body = await this.adminFetch(path, "GET");
1513
- return { cursor: body.cursor, objects: body.objects ?? [] };
1514
- }
1515
- /**
1516
- * Delete one object from the storage bucket by key. Hits the admin-gated
1517
- * `DELETE /_lunora/admin/storage?key=…` endpoint — the worker must be built
1518
- * with a `storageDelete` function and `adminToken`. Powers the studio file
1519
- * browser's per-row delete; resolves `{ deleted, key }`.
1520
- */
1521
- async deleteStorageObject(key, options) {
1522
- if (this.closed) {
1523
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1524
- }
1525
- const path = `${STORAGE_PATH}?key=${encodeURIComponent(key)}${bucketQuery(options?.bucket)}`;
1526
- const body = await this.adminFetch(path, "DELETE");
1527
- return { deleted: body.deleted ?? true, key: body.key ?? key };
1528
- }
1529
- /**
1530
- * List the storage bucket names the worker exposes, for the studio file
1531
- * browser's bucket picker. Hits the admin-gated
1532
- * `GET /_lunora/admin/storage/buckets` endpoint — always resolves (an empty
1533
- * array when the worker configures no `storageBuckets`, i.e. single-bucket).
1534
- */
1535
- async listStorageBuckets() {
1536
- if (this.closed) {
1537
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1538
- }
1539
- const body = await this.adminFetch(STORAGE_BUCKETS_PATH, "GET");
1540
- return body.buckets ?? [];
1541
- }
1542
- /**
1543
- * Upload one object to the storage bucket. Hits the admin-gated
1544
- * `PUT /_lunora/admin/storage?key=…` endpoint with the raw body and an
1545
- * optional `contentType` header — the worker must be built with a
1546
- * `storageUpload` function and `adminToken`. Powers the studio file
1547
- * browser's upload control; resolves `{ etag?, key }`.
1548
- */
1549
- async uploadStorageObject(options) {
1550
- if (this.closed) {
1551
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1552
- }
1553
- const path = `${STORAGE_PATH}?key=${encodeURIComponent(options.key)}${bucketQuery(options.bucket)}`;
1554
- const body = await this.adminFetch(path, "PUT", options.body, options.contentType);
1555
- return { etag: body.etag, key: body.key ?? options.key };
1556
- }
1557
- /**
1558
- * Build a (signed or public) URL for one object. Hits the admin-gated
1559
- * `GET /_lunora/admin/storage/url?key=…` endpoint — the worker must be built
1560
- * with a `storageSignedUrl` function and `adminToken`. Powers the studio
1561
- * file browser's copy-URL action; resolves the URL string.
1562
- *
1563
- * `options.expiresInSeconds` requests a share-link lifetime, which is
1564
- * validated/clamped server-side. The options object mirrors the worker's
1565
- * `StorageSignedUrlFunction` options (a `password` / download-limit are noted
1566
- * as future fields there).
1567
- */
1568
- async signedStorageUrl(key, options) {
1569
- if (this.closed) {
1570
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1571
- }
1572
- const expiresInSeconds = options?.expiresInSeconds;
1573
- const expiryQuery = expiresInSeconds === void 0 ? "" : `&expiresIn=${encodeURIComponent(expiresInSeconds.toString())}`;
1574
- const path = `${STORAGE_URL_PATH}?key=${encodeURIComponent(key)}${expiryQuery}${bucketQuery(options?.bucket)}`;
1575
- const body = await this.adminFetch(path, "GET");
1576
- if (typeof body.url !== "string") {
1577
- throw new TypeError("LunoraClient: storage URL endpoint returned no `url`");
1578
- }
1579
- return body.url;
1580
- }
1581
- // --- Global (D1) tables admin -------------------------------------------
1582
- /**
1583
- * List the `.global()` (D1-backed) tables with their row counts. Hits the
1584
- * admin-gated `GET /_lunora/admin/global/tables` endpoint — the worker must
1585
- * be built with a `globalIntrospector` and `adminToken`. Powers the data
1586
- * browser's global mode.
1587
- */
1588
- async listGlobalTables() {
1589
- if (this.closed) {
1590
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1591
- }
1592
- return await this.adminFetch(GLOBAL_TABLES_PATH, "GET");
1593
- }
1594
- /**
1595
- * Read a page of rows from one `.global()` table. `filters` AND-narrows the
1596
- * page to rows matching each `column = value` eq constraint — the drill-down a
1597
- * facet-value click applies; the array is JSON-encoded into the `filters`
1598
- * query param and the values are bound server-side.
1599
- */
1600
- async readGlobalTablePage(options) {
1601
- if (this.closed) {
1602
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1603
- }
1604
- const params = new URLSearchParams({ table: options.table });
1605
- if (options.limit !== void 0) {
1606
- params.set("limit", String(options.limit));
1607
- }
1608
- if (options.offset !== void 0) {
1609
- params.set("offset", String(options.offset));
1610
- }
1611
- if (options.filters !== void 0 && options.filters.length > 0) {
1612
- params.set("filters", JSON.stringify(options.filters));
1613
- }
1614
- return await this.adminFetch(`${GLOBAL_TABLE_PATH}?${params.toString()}`, "GET");
1615
- }
1616
- /**
1617
- * Summarise the distinct values of one column in a `.global()` table over the
1618
- * active view (the same eq `filters` the browser is previewing) — the global
1619
- * twin of the shard browser's facet. Hits the admin-gated
1620
- * `GET /_lunora/admin/global/facet` endpoint; `column` is validated + bound
1621
- * server-side. Powers the global data browser's facet sidebar.
1622
- */
1623
- async facetGlobalColumn(options) {
1624
- if (this.closed) {
1625
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1626
- }
1627
- const params = new URLSearchParams({ column: options.column, table: options.table });
1628
- if (options.limit !== void 0) {
1629
- params.set("limit", String(options.limit));
1630
- }
1631
- if (options.filters !== void 0 && options.filters.length > 0) {
1632
- params.set("filters", JSON.stringify(options.filters));
1633
- }
1634
- return await this.adminFetch(`${GLOBAL_FACET_PATH}?${params.toString()}`, "GET");
1635
- }
1636
- // --- Vector indexes admin -----------------------------------------------
1637
- /**
1638
- * List the schema's Vectorize indexes with their declared shape (table,
1639
- * field, dimensions, metric, metadata) and live stats (vector count,
1640
- * processing watermark) when the binding is reachable. Hits the admin-gated
1641
- * `GET /_lunora/admin/vector/indexes` endpoint — the worker must be built
1642
- * with a `vectorIntrospector` and `adminToken`. Powers the studio's vector
1643
- * browser. Vectorize can't enumerate indexes at runtime, so this list comes
1644
- * from the generated `LUNORA_VECTOR_INDEXES` registry.
1645
- */
1646
- async listVectorIndexes() {
1647
- if (this.closed) {
1648
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1649
- }
1650
- const body = await this.adminFetch(VECTOR_INDEXES_PATH, "GET");
1651
- return body.indexes ?? [];
1652
- }
1653
- /**
1654
- * Run a nearest-neighbour similarity query against one vector index: the
1655
- * worker embeds `text` via the index's embedder and returns the top matches.
1656
- * Hits the admin-gated `POST /_lunora/admin/vector/query` endpoint. Throws
1657
- * `VECTOR_QUERY_UNSUPPORTED` when the worker's introspector has no embedder
1658
- * wired (the index lists read-only).
1659
- */
1660
- async queryVectorIndex(options) {
1661
- if (this.closed) {
1662
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1663
- }
1664
- const body = await this.adminFetch(VECTOR_QUERY_PATH, "POST", options);
1665
- return body.matches ?? [];
1666
- }
1667
- /**
1668
- * Read one keyset-paginated page of the durable `ctx.log` archive that
1669
- * `pipelineLogSink` writes to R2. Server-side only — the worker holds the R2
1670
- * SQL credentials and runs the reader; the browser only sees the decoded
1671
- * `{ rows, nextCursor }`. Pass the previous page's `nextCursor` as
1672
- * `query.cursor` to page. Admin-gated. When the operator hasn't wired the
1673
- * archive, `adminFetch` throws a `LunoraClientError` with `.code ===
1674
- * "LOG_ARCHIVE_NOT_CONFIGURED"`, so a caller can render a "not configured"
1675
- * state rather than an error.
1676
- */
1677
- async queryLogArchive(query = {}) {
1678
- if (this.closed) {
1679
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1680
- }
1681
- const body = await this.adminFetch(LOG_ARCHIVE_PATH, "POST", query);
1682
- return { nextCursor: body.nextCursor, rows: body.rows ?? [] };
1683
- }
1684
- // --- KV namespace admin -------------------------------------------------
1685
- /**
1686
- * List the worker's registered Workers KV namespaces (binding names). Hits
1687
- * the admin-gated `GET /_lunora/admin/kv/namespaces` endpoint — the worker
1688
- * must be built with a `kvIntrospector` and `adminToken`. Powers the
1689
- * studio's KV browser.
1690
- */
1691
- async listKvNamespaces() {
1692
- if (this.closed) {
1693
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1694
- }
1695
- const body = await this.adminFetch(KV_NAMESPACES_PATH, "GET");
1696
- return body.namespaces ?? [];
1697
- }
1698
- /**
1699
- * List keys in a KV namespace, optionally filtered by `prefix` and
1700
- * paginated via `cursor`. Hits the admin-gated
1701
- * `GET /_lunora/admin/kv/keys` endpoint.
1702
- */
1703
- async listKvKeys(options) {
1704
- if (this.closed) {
1705
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1706
- }
1707
- const path = withQuery(KV_KEYS_PATH, {
1708
- cursor: options.cursor,
1709
- limit: options.limit,
1710
- namespace: options.namespace,
1711
- prefix: options.prefix
1712
- });
1713
- return await this.adminFetch(path, "GET");
1714
- }
1715
- /**
1716
- * Read a KV value (as text) and its metadata. Hits the admin-gated
1717
- * `GET /_lunora/admin/kv/value` endpoint. Returns `{ value: null, metadata: null }`
1718
- * when the key is absent.
1719
- */
1720
- async getKvValue(options) {
1721
- if (this.closed) {
1722
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1723
- }
1724
- const path = withQuery(KV_VALUE_PATH, { key: options.key, namespace: options.namespace });
1725
- return await this.adminFetch(path, "GET");
1726
- }
1727
- /**
1728
- * Write a string value to a KV namespace. Accepts an absolute `expiration`
1729
- * (Unix seconds) or a relative `expirationTtl`, plus optional `metadata` —
1730
- * re-send the loaded values on edit so a save preserves rather than clears
1731
- * them. Hits the admin-gated `PUT /_lunora/admin/kv/value` endpoint.
1732
- */
1733
- async putKvValue(options) {
1734
- if (this.closed) {
1735
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1736
- }
1737
- await this.adminFetch(KV_VALUE_PATH, "PUT", options);
1738
- }
1739
- /**
1740
- * Delete a key from a KV namespace. No-op when the key is absent. Hits the
1741
- * admin-gated `DELETE /_lunora/admin/kv/value` endpoint.
1742
- */
1743
- async deleteKvKey(options) {
1744
- if (this.closed) {
1745
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1746
- }
1747
- const path = withQuery(KV_VALUE_PATH, { key: options.key, namespace: options.namespace });
1748
- await this.adminFetch(path, "DELETE");
1749
- }
1750
- // --- Auth admin ---------------------------------------------------------
1751
- /**
1752
- * List authenticated users, paged and optionally searched / filtered / sorted.
1753
- * Hits the admin-gated `GET /_lunora/admin/auth/users` endpoint — the worker
1754
- * must be built with an `authAdmin` and `adminToken`. Powers the studio's
1755
- * users dashboard.
1756
- */
1757
- async listAuthUsers(options = {}) {
1758
- if (this.closed) {
1759
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1760
- }
1761
- const path = withQuery(AUTH_USERS_PATH, {
1762
- filterField: options.filterField,
1763
- filterValue: options.filterValue,
1764
- limit: options.limit,
1765
- offset: options.offset,
1766
- search: options.search,
1767
- searchField: options.searchField,
1768
- sortBy: options.sortBy,
1769
- sortDirection: options.sortDirection
1770
- });
1771
- return await this.adminFetch(path, "GET");
1772
- }
1773
- /**
1774
- * Create a user. Hits the admin-gated `POST /_lunora/admin/auth/users/create`
1775
- * endpoint (requires the worker's `authAdmin` to implement `createUser`).
1776
- * `data` carries any app-defined `user.additionalFields`.
1777
- */
1778
- async createAuthUser(input) {
1779
- return await this.adminFetch(AUTH_CREATE_USER_PATH, "POST", input);
1780
- }
1781
- /** Set a user's role (string, or array joined comma-wise server-side). */
1782
- async setAuthUserRole(input) {
1783
- return await this.adminFetch(AUTH_SET_ROLE_PATH, "POST", input);
1784
- }
1785
- /** Ban a user. `expiresInSeconds` sets a temporary ban; omit it for a permanent one. Revokes the user's live sessions. */
1786
- async banAuthUser(input) {
1787
- return await this.adminFetch(AUTH_BAN_PATH, "POST", input);
1788
- }
1789
- /** Lift a user's ban. */
1790
- async unbanAuthUser(input) {
1791
- return await this.adminFetch(AUTH_UNBAN_PATH, "POST", input);
1792
- }
1793
- /** Set a user's password (admin override — no current-password challenge). */
1794
- async setAuthUserPassword(input) {
1795
- await this.adminFetch(AUTH_SET_PASSWORD_PATH, "POST", input);
1796
- }
1797
- /** Permanently delete a user and revoke their sessions. */
1798
- async removeAuthUser(input) {
1799
- await this.adminFetch(AUTH_REMOVE_USER_PATH, "POST", input);
1800
- }
1801
- /**
1802
- * Mint an impersonation session for a user, returning its bearer `token`.
1803
- * The caller is responsible for using the token (e.g. setting the session
1804
- * cookie); the server performs no cookie round-trip.
1805
- */
1806
- async impersonateAuthUser(input) {
1807
- return await this.adminFetch(AUTH_IMPERSONATE_PATH, "POST", input);
1808
- }
1809
- /** Revoke a single session by its id (force sign-out of one device). */
1810
- async revokeAuthSession(input) {
1811
- await this.adminFetch(AUTH_REVOKE_SESSION_PATH, "POST", input);
1812
- }
1813
- /** Revoke every session for a user (force sign-out everywhere). */
1814
- async revokeAuthUserSessions(input) {
1815
- await this.adminFetch(AUTH_REVOKE_SESSIONS_PATH, "POST", input);
1816
- }
1817
- /**
1818
- * Report which auth dashboard surfaces are available — derived server-side
1819
- * from the enabled better-auth plugins. The studio renders only the panels
1820
- * whose capability is `true`.
1821
- */
1822
- async getAuthCapabilities() {
1823
- return await this.adminFetch(AUTH_CAPABILITIES_PATH, "GET");
1824
- }
1825
- /** Update a user's fields (name/email/app-defined `additionalFields`). */
1826
- async updateAuthUser(input) {
1827
- return await this.adminFetch(AUTH_UPDATE_USER_PATH, "POST", input);
1828
- }
1829
- /** List a user's linked accounts (credential / OAuth providers). Token material is stripped server-side. */
1830
- async listAuthAccounts(input) {
1831
- return await this.adminFetch(withQuery(AUTH_ACCOUNTS_PATH, { userId: input.userId }), "GET");
1832
- }
1833
- /** Unlink a linked account from a user. */
1834
- async unlinkAuthAccount(input) {
1835
- await this.adminFetch(AUTH_UNLINK_ACCOUNT_PATH, "POST", input);
1836
- }
1837
- /** List a user's registered passkeys (requires the passkey plugin). */
1838
- async listAuthPasskeys(input) {
1839
- return await this.adminFetch(withQuery(AUTH_PASSKEYS_PATH, { userId: input.userId }), "GET");
1840
- }
1841
- /** Delete a passkey by id (requires the passkey plugin). */
1842
- async deleteAuthPasskey(input) {
1843
- await this.adminFetch(AUTH_DELETE_PASSKEY_PATH, "POST", input);
1844
- }
1845
- /** Disable two-factor auth for a user (requires the two-factor plugin). */
1846
- async disableAuthTwoFactor(input) {
1847
- await this.adminFetch(AUTH_DISABLE_2FA_PATH, "POST", input);
1848
- }
1849
- /** List organizations, paged (requires the organization plugin). */
1850
- async listAuthOrganizations(options = {}) {
1851
- return await this.adminFetch(withQuery(AUTH_ORGS_PATH, { limit: options.limit, offset: options.offset }), "GET");
1852
- }
1853
- /** List the members of an organization (requires the organization plugin). */
1854
- async listAuthOrgMembers(input) {
1855
- const path = withQuery(AUTH_ORG_MEMBERS_PATH, { limit: input.limit, offset: input.offset, organizationId: input.organizationId });
1856
- return await this.adminFetch(path, "GET");
1857
- }
1858
- /** List an organization's pending invitations (requires the organization plugin). */
1859
- async listAuthOrgInvitations(input) {
1860
- const path = withQuery(AUTH_ORG_INVITATIONS_PATH, { limit: input.limit, offset: input.offset, organizationId: input.organizationId });
1861
- return await this.adminFetch(path, "GET");
1862
- }
1863
- /** Remove a member from an organization. */
1864
- async removeAuthOrgMember(input) {
1865
- await this.adminFetch(AUTH_REMOVE_MEMBER_PATH, "POST", input);
1866
- }
1867
- /** Cancel a pending organization invitation. */
1868
- async cancelAuthOrgInvitation(input) {
1869
- await this.adminFetch(AUTH_CANCEL_INVITATION_PATH, "POST", input);
1870
- }
1871
- /**
1872
- * Report the deployment's auth configuration — enabled plugins, sign-in
1873
- * methods, user-settable create-user fields, organization sub-features
1874
- * (teams / roles), and session / rate-limit policy. Drives the config panel
1875
- * and the dynamic create-user form. Never carries a secret.
1876
- */
1877
- async getAuthConfig() {
1878
- return await this.adminFetch(AUTH_CONFIG_PATH, "GET");
1879
- }
1880
- /** Create an organization; optionally seed an `owner` member for `ownerId`. */
1881
- async createAuthOrganization(input) {
1882
- return await this.adminFetch(AUTH_CREATE_ORG_PATH, "POST", input);
1883
- }
1884
- /** Update an organization's name/slug/logo/metadata. */
1885
- async updateAuthOrganization(input) {
1886
- return await this.adminFetch(AUTH_UPDATE_ORG_PATH, "POST", input);
1887
- }
1888
- /** Delete an organization and cascade its members, invitations, teams, and custom roles. */
1889
- async deleteAuthOrganization(input) {
1890
- await this.adminFetch(AUTH_REMOVE_ORG_PATH, "POST", input);
1891
- }
1892
- /** Directly add an existing user to an organization (no invitation/acceptance). */
1893
- async addAuthOrgMember(input) {
1894
- return await this.adminFetch(AUTH_ADD_MEMBER_PATH, "POST", input);
1895
- }
1896
- /** Create a pending email invitation to an organization. */
1897
- async inviteAuthOrgMember(input) {
1898
- return await this.adminFetch(AUTH_INVITE_MEMBER_PATH, "POST", input);
1899
- }
1900
- /** Change a member's role. */
1901
- async setAuthOrgMemberRole(input) {
1902
- return await this.adminFetch(AUTH_MEMBER_ROLE_PATH, "POST", input);
1903
- }
1904
- /** List an organization's teams (requires the organization plugin with teams enabled). */
1905
- async listAuthOrgTeams(input) {
1906
- const path = withQuery(AUTH_ORG_TEAMS_PATH, { limit: input.limit, offset: input.offset, organizationId: input.organizationId });
1907
- return await this.adminFetch(path, "GET");
1908
- }
1909
- /** Create a team under an organization. */
1910
- async createAuthOrgTeam(input) {
1911
- return await this.adminFetch(AUTH_CREATE_TEAM_PATH, "POST", input);
1912
- }
1913
- /** Rename a team. */
1914
- async updateAuthOrgTeam(input) {
1915
- return await this.adminFetch(AUTH_UPDATE_TEAM_PATH, "POST", input);
1916
- }
1917
- /** Delete a team and its memberships. */
1918
- async removeAuthOrgTeam(input) {
1919
- await this.adminFetch(AUTH_REMOVE_TEAM_PATH, "POST", input);
1920
- }
1921
- /** List a team's members. */
1922
- async listAuthOrgTeamMembers(input) {
1923
- const path = withQuery(AUTH_ORG_TEAM_MEMBERS_PATH, { limit: input.limit, offset: input.offset, teamId: input.teamId });
1924
- return await this.adminFetch(path, "GET");
1925
- }
1926
- /** Add a user to a team. */
1927
- async addAuthOrgTeamMember(input) {
1928
- return await this.adminFetch(AUTH_ADD_TEAM_MEMBER_PATH, "POST", input);
1929
- }
1930
- /** Remove a member from a team. */
1931
- async removeAuthOrgTeamMember(input) {
1932
- await this.adminFetch(AUTH_REMOVE_TEAM_MEMBER_PATH, "POST", input);
1933
- }
1934
- /** List an organization's custom roles (requires the organization plugin with dynamic access control). */
1935
- async listAuthOrgRoles(input) {
1936
- const path = withQuery(AUTH_ORG_ROLES_PATH, { limit: input.limit, offset: input.offset, organizationId: input.organizationId });
1937
- return await this.adminFetch(path, "GET");
1938
- }
1939
- /** Create a custom org role with a permission grant (a `resource -> actions[]` map). */
1940
- async createAuthOrgRole(input) {
1941
- return await this.adminFetch(AUTH_CREATE_ROLE_PATH, "POST", input);
1942
- }
1943
- /** Replace a custom org role's permission grant. */
1944
- async updateAuthOrgRole(input) {
1945
- return await this.adminFetch(AUTH_UPDATE_ROLE_PATH, "POST", input);
1946
- }
1947
- /** Delete a custom org role. */
1948
- async deleteAuthOrgRole(input) {
1949
- await this.adminFetch(AUTH_REMOVE_ROLE_PATH, "POST", input);
1950
- }
1951
- /** List auth sessions, paged and optionally filtered to one user. */
1952
- async listAuthSessions(options = {}) {
1953
- if (this.closed) {
1954
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1955
- }
1956
- const params = new URLSearchParams();
1957
- if (options.userId !== void 0 && options.userId !== "") {
1958
- params.set("userId", options.userId);
1959
- }
1960
- if (options.limit !== void 0) {
1961
- params.set("limit", String(options.limit));
1962
- }
1963
- if (options.offset !== void 0) {
1964
- params.set("offset", String(options.offset));
1965
- }
1966
- const query = params.toString();
1967
- return await this.adminFetch(query === "" ? AUTH_SESSIONS_PATH : `${AUTH_SESSIONS_PATH}?${query}`, "GET");
1968
- }
1969
- // --- Subscriptions ------------------------------------------------------
1970
- subscribe(function_, args, callback, options = {}) {
1971
- if (this.closed) {
1972
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
1973
- }
1974
- const argsRecord = args ?? {};
1975
- const key = SubscriptionRegistry.key(function_.__lunoraRef, argsRecord, options.shardKey);
1976
- let state = this.subscriptions.get(key);
1977
- const subscriptionCallback = callback;
1978
- const errorCallback = options.onError;
1979
- if (!state) {
1980
- this.nextSubId += 1;
1981
- const id = `sub_${this.nextSubId.toString()}`;
1982
- const argsKey = stableWireKey(argsRecord);
1983
- const cached = this.takeHydratedCache(function_.__lunoraRef, argsKey, options.shardKey);
1984
- state = {
1985
- acked: false,
1986
- args: argsRecord,
1987
- argsKey,
1988
- callbacks: /* @__PURE__ */ new Set(),
1989
- checkpointCallbacks: /* @__PURE__ */ new Set(),
1990
- errorCallbacks: /* @__PURE__ */ new Set(),
1991
- fn: function_,
1992
- id,
1993
- lastValue: cached?.value,
1994
- optimisticLayers: [],
1995
- serverBase: cached?.value,
1996
- serverCursor: cached?.serverCursor,
1997
- shardKey: options.shardKey,
1998
- ...cached?.serverEpoch === void 0 ? {} : { serverEpoch: cached.serverEpoch }
1999
- };
2000
- this.subscriptions.add(state);
2001
- }
2002
- state.callbacks.add(subscriptionCallback);
2003
- if (errorCallback) {
2004
- state.errorCallbacks.add(errorCallback);
2005
- }
2006
- if (options.onCheckpoint) {
2007
- state.checkpointCallbacks.add(options.onCheckpoint);
2008
- }
2009
- if (state.lastValue !== void 0) {
2010
- try {
2011
- subscriptionCallback(state.lastValue);
2012
- } catch {
2013
- }
2014
- }
2015
- this.ensureSocket(options.shardKey);
2016
- this.sendSubscribeIfOpen(state);
2017
- const subscriptionState = state;
2018
- return () => {
2019
- subscriptionState.callbacks.delete(subscriptionCallback);
2020
- if (errorCallback) {
2021
- subscriptionState.errorCallbacks.delete(errorCallback);
2022
- }
2023
- if (options.onCheckpoint) {
2024
- subscriptionState.checkpointCallbacks.delete(options.onCheckpoint);
2025
- }
2026
- if (subscriptionState.callbacks.size === 0) {
2027
- const conn = this.getConnection(subscriptionState.shardKey);
2028
- const ok = conn ? sendOn(conn, { id: subscriptionState.id, type: "unsubscribe" }) : false;
2029
- if (!ok && conn) {
2030
- conn.pendingUnsubscribes.push({ id: subscriptionState.id, type: "unsubscribe" });
2031
- }
2032
- this.subscriptions.remove(subscriptionState);
2033
- }
2034
- };
2035
- }
2036
- /**
2037
- * Subscribe to a declarative **shape** — server-side partial replication
2038
- * scoped by `shardBy` + the shape's predicate + RLS. The parallel to
2039
- * {@link subscribe} for the poke protocol: the client sends the shape *name* +
2040
- * validated `args` (never a `where` the client could forge), the server seeds
2041
- * the current membership as an insert-poke and streams live membership diffs.
2042
- * Each applied poke materializes the shape's rowset and invokes `callback`.
2043
- *
2044
- * Unlike {@link subscribe}, shape subscriptions are NOT deduped by
2045
- * (name, args): the server resolves them under the socket's verified identity,
2046
- * so every call gets its own id + view. The returned function unsubscribes.
2047
- */
2048
- subscribeShape(shape, callback, options = {}) {
2049
- if (this.closed) {
2050
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
2051
- }
2052
- this.nextShapeId += 1;
2053
- const id = `shape_${this.nextShapeId.toString()}`;
2054
- const state = {
2055
- args: shape.args,
2056
- callbacks: /* @__PURE__ */ new Set([callback]),
2057
- errorCallbacks: options.onError ? /* @__PURE__ */ new Set([options.onError]) : /* @__PURE__ */ new Set(),
2058
- id,
2059
- name: shape.name,
2060
- onCheckpoint: options.onCheckpoint,
2061
- rows: /* @__PURE__ */ new Map(),
2062
- shardKey: options.shardKey,
2063
- // Encode ONCE, here, so an unsupported arg value throws at this call
2064
- // site instead of inside a reconnect's open handler.
2065
- wireArgs: shape.args === void 0 ? void 0 : encodeCallArgs(shape.args, `shape args for '${shape.name}'`)
2066
- };
2067
- this.shapeSubscriptions.set(id, state);
2068
- this.ensureSocket(options.shardKey);
2069
- this.sendShapeSubscribeIfOpen(state);
2070
- return () => {
2071
- this.shapeSubscriptions.delete(id);
2072
- const conn = this.getConnection(state.shardKey);
2073
- const ok = conn ? sendOn(conn, { id, type: "shape_unsubscribe" }) : false;
2074
- if (!ok && conn) {
2075
- conn.pendingUnsubscribes.push({ id, type: "shape_unsubscribe" });
2076
- }
2077
- };
2078
- }
2079
- /**
2080
- * Open a streaming query. The function reference must be a
2081
- * `kind:"stream"` registration (built with `c.query.input(...).stream(...)`);
2082
- * the type constraint catches accidental use of a query/mutation/action
2083
- * reference at compile time. The returned iterable yields one element per
2084
- * chunk frame the server pushes, terminating when the server sends
2085
- * `complete` or the consumer calls `.cancel()`. Errors arrive as a
2086
- * rejection on the next `next()`.
2087
- *
2088
- * Streams ride the same WS as subscriptions and share the unsubscribe
2089
- * channel: cancelling sends `{type:"unsubscribe", id}` with the stream id,
2090
- * which the DO recognises as an abort signal for the in-flight iterator.
2091
- *
2092
- * Stream-start frames buffered while the socket is (re)connecting are
2093
- * capped at {@link MAX_PENDING_STREAMS} per connection — overflowing the
2094
- * cap drops the oldest queued frame (and fails its consumer) so a stuck
2095
- * reconnect can't OOM the page.
2096
- */
2097
- stream(function_, args, options = {}) {
2098
- if (this.closed) {
2099
- throw new LunoraError("INTERNAL", "LunoraClient is closed");
2100
- }
2101
- if (this.WebSocketImpl === void 0) {
2102
- throw new LunoraError("INTERNAL", "LunoraClient: streams require a WebSocket implementation");
2103
- }
2104
- this.nextStreamId += 1;
2105
- const id = `stream_${this.nextStreamId.toString()}`;
2106
- const { shardKey } = options;
2107
- const argsRecord = args ?? {};
2108
- const { handle, iterable } = createStream({
2109
- maxBuffer: options.maxBuffer,
2110
- onCancel: () => {
2111
- const conn2 = this.getConnection(shardKey);
2112
- if (conn2) {
2113
- sendOn(conn2, { id, type: "unsubscribe" });
2114
- }
2115
- this.streams.delete(id);
2116
- }
2117
- });
2118
- this.streams.set(id, { handle, shardKey });
2119
- this.ensureSocket(shardKey);
2120
- const conn = this.getConnection(shardKey);
2121
- const message = {
2122
- id,
2123
- // Wire-encode the stream args so `bigint`/bytes survive the send (raw
2124
- // `JSON.stringify` throws on a bigint); the shard `decodeWire`s them
2125
- // before invoking the stream handler.
2126
- query: {
2127
- args: encodeCallArgs(argsRecord, `stream args for '${function_.__lunoraRef}'`),
2128
- functionPath: function_.__lunoraRef,
2129
- shardKey
2130
- },
2131
- type: "stream"
2132
- };
2133
- const sentImmediately = conn?.wsState === "open" && sendOn(conn, message);
2134
- if (!sentImmediately && conn) {
2135
- conn.pendingStreams = conn.pendingStreams ?? [];
2136
- while (conn.pendingStreams.length >= MAX_PENDING_STREAMS) {
2137
- const dropped = conn.pendingStreams.shift();
2138
- const droppedId = dropped?.id;
2139
- const droppedStream = droppedId ? this.streams.get(droppedId) : void 0;
2140
- if (droppedStream) {
2141
- droppedStream.handle.fail(new LunoraError("STREAM_QUEUE_OVERFLOW", "stream-start frame evicted while socket was unreachable"));
2142
- this.streams.delete(droppedId);
2143
- }
2144
- }
2145
- conn.pendingStreams.push(message);
2146
- }
2147
- return iterable;
2148
- }
2149
- /**
2150
- * Open a typed **HTTP-SSE route stream** (`httpRoute.&lt;verb>(path).stream()`).
2151
- * Distinct from {@link LunoraClient.stream}, which consumes the WS procedure
2152
- * stream (`kind: "stream"`): this one opens the route's own URL with `fetch`
2153
- * and parses the Server-Sent Events framing the route pump writes (`data:`
2154
- * chunks, a final `event: complete`, an `event: error` on throw).
2155
- *
2156
- * The reference comes from the generated `httpStreams.*` registry, so the
2157
- * yielded chunk type is the route handler's yielded type. Cancelling the
2158
- * returned iterable (or aborting `options.signal`) aborts the fetch, which
2159
- * the server handler observes via its `signal`. The client's bearer token
2160
- * (when set) rides as an `authorization` header.
2161
- * @experimental Reconnect/POST-body/wire-fidelity design questions are still open, so the shape may change.
2162
- */
2163
- httpStream(route, args, options = {}) {
2164
- if (this.closed) {
2165
- throw new LunoraError("CLIENT_CLOSED", "LunoraClient is closed");
2166
- }
2167
- if (!this.fetchImpl) {
2168
- throw new LunoraError("INTERNAL", "LunoraClient: no `fetch` implementation available");
2169
- }
2170
- const headers = {
2171
- ...this.authToken ? { authorization: `Bearer ${this.authToken}` } : {},
2172
- ...options.headers
2173
- };
2174
- return httpStream(route, args, {
2175
- baseUrl: this.url,
2176
- fetch: this.fetchImpl,
2177
- headers,
2178
- maxBuffer: options.maxBuffer,
2179
- signal: options.signal
2180
- });
2181
- }
2182
- close() {
2183
- this.closed = true;
2184
- this.outboxLeaderRelease?.();
2185
- this.outboxLeaderRelease = void 0;
2186
- for (const stream of this.streams.values()) {
2187
- stream.handle.fail(new LunoraError("CLIENT_CLOSED", "LunoraClient closed"));
2188
- }
2189
- this.streams.clear();
2190
- for (const conn of this.connections.values()) {
2191
- if (conn.reconnectTimer !== void 0) {
2192
- clearTimeout(conn.reconnectTimer);
2193
- conn.reconnectTimer = void 0;
2194
- }
2195
- if (conn.connectTimer !== void 0) {
2196
- clearTimeout(conn.connectTimer);
2197
- conn.connectTimer = void 0;
2198
- }
2199
- this.stopHeartbeat(conn);
2200
- if (conn.socket) {
2201
- try {
2202
- conn.socket.close();
2203
- } catch {
2204
- }
2205
- conn.socket = void 0;
2206
- }
2207
- conn.wsState = "closed";
2208
- }
2209
- this.offlineQueue.clear();
2210
- this.queuedIdentities.clear();
2211
- if (this.cacheFlushTimer !== void 0) {
2212
- clearTimeout(this.cacheFlushTimer);
2213
- this.cacheFlushTimer = void 0;
2214
- }
2215
- if (this.pendingCacheWrites.size > 0) {
2216
- this.flushQueryCacheWrites().catch(() => void 0);
2217
- }
2218
- this.authTokenListeners.clear();
2219
- this.statusListeners.clear();
2220
- this.tokenExpiredListeners.clear();
2221
- this.mutationSettledListeners.clear();
2222
- this.pendingChangeListeners.clear();
2223
- this.whisperHandlers.clear();
2224
- this.shapeSubscriptions.clear();
2225
- this.pokeBuffers.clear();
2226
- this.tabCoordinator?.stop();
2227
- this.tabCoordinator = void 0;
2228
- }
2229
- // --- Internals ----------------------------------------------------------
2230
- /**
2231
- * Persist a mutation that can't go out on the wire right now (offline, or
2232
- * mid-reconnect after a prior connect). The optimistic update has already
2233
- * been applied by `mutation`; this only chooses the durable write path and
2234
- * rolls the optimistic write back if persistence is rejected.
2235
- *
2236
- * Two paths: when an `outbox` sink is wired (the `@lunora/db` executor) it
2237
- * owns persistence + at-least-once replay, so we delegate and return
2238
- * optimistically (confirmation rides the synced view). Otherwise the
2239
- * built-in `OfflineQueue` resolves/rejects the returned promise on replay.
2240
- */
2241
- async enqueueOfflineMutation(function_, argsRecord, shardKey, mutationId, optimisticRollbacks, optimisticConfirms, precondition) {
2242
- const issuingIdentity = this.identityFingerprint();
2243
- if (this.outbox) {
2244
- this.outboxMutationCounter += 1;
2245
- const outboxMutationId = this.outboxMutationCounter;
2246
- try {
2247
- await this.outbox.enqueue({
2248
- args: argsRecord,
2249
- clientId: this.clientId,
2250
- functionPath: function_.__lunoraRef,
2251
- idempotencyKey: `${this.clientId}:${String(outboxMutationId)}`,
2252
- identity: issuingIdentity,
2253
- mutationId: outboxMutationId,
2254
- shardKey
2255
- });
2256
- } catch (error) {
2257
- rollbackOptimistic(optimisticRollbacks);
2258
- throw error instanceof Error ? error : new Error(String(error));
2259
- }
2260
- for (const confirm of optimisticConfirms) {
2261
- confirm(void 0);
2262
- }
2263
- return void 0;
2264
- }
2265
- return new Promise((resolve, reject) => {
2266
- const entry = {
2267
- args: argsRecord,
2268
- functionPath: function_.__lunoraRef,
2269
- // A live caller is awaiting this Promise, so a terminal verdict
2270
- // reaches them directly; the observer event carries
2271
- // `hadAwaiter: true`. Hydrated replays leave this unset.
2272
- liveAwaiter: true,
2273
- // Reuse the call's idempotency key as the queue id so the replay
2274
- // carries the same `x-lunora-mutation-id` the server dedups on.
2275
- id: mutationId,
2276
- // Persist the stamp alongside the record so a hydrated write can
2277
- // only replay under the identity that queued it.
2278
- identity: issuingIdentity,
2279
- // Optional precondition checked before replay — see drainConflict.
2280
- precondition,
2281
- // Confirm the per-call optimistic layer(s) against the commit cursor
2282
- // the flush replay echoes (see flushOfflineQueue).
2283
- onCommit: (commitCursor) => {
2284
- for (const confirm of optimisticConfirms) {
2285
- confirm(commitCursor);
2286
- }
2287
- },
2288
- reject: (error) => {
2289
- this.queuedIdentities.delete(mutationId);
2290
- rollbackOptimistic(optimisticRollbacks);
2291
- reject(error instanceof Error ? error : new Error(String(error)));
2292
- },
2293
- resolve,
2294
- shardKey
2295
- };
2296
- this.offlineQueue.enqueue(entry);
2297
- if (entry.id !== void 0) {
2298
- this.queuedIdentities.set(entry.id, issuingIdentity);
2299
- }
2300
- });
2301
- }
2302
- /**
2303
- * Restore offline mutations persisted in a prior session and open a socket
2304
- * for each shard they target so they flush once the WS reconnects. Failures
2305
- * are swallowed — a broken durable store must not stop the client booting.
2306
- */
2307
- async hydratePersistedQueue() {
2308
- try {
2309
- const shardKeys = await this.offlineQueue.hydrate();
2310
- for (const shardKey of shardKeys) {
2311
- this.ensureSocket(shardKey);
2312
- }
2313
- } catch {
2314
- }
2315
- }
2316
- /**
2317
- * Re-queue the durable offline writes — but only as the multi-tab LEADER. The
2318
- * persisted queue is shared across a profile's tabs; without coordination
2319
- * every tab would re-queue and replay the same writes (correct only because
2320
- * the server dedups by idempotency key, but wasteful + racy). A Web Lock makes
2321
- * exactly one tab hydrate; it holds the lock for its lifetime, so when it
2322
- * closes another tab acquires the lock and takes over. Falls back to
2323
- * unconditional hydration where Web Locks are unavailable (React Native, older
2324
- * browsers, SSR) — single-context there, so no coordination is needed.
2325
- */
2326
- hydrateAsOutboxLeader() {
2327
- const hydrate = () => {
2328
- this.hydratePersistedQueue().catch(() => void 0);
2329
- };
2330
- const locks = globalThis.navigator?.locks;
2331
- if (!locks) {
2332
- hydrate();
2333
- return;
2334
- }
2335
- locks.request(`lunora:outbox-leader:${this.url}`, () => {
2336
- if (!this.closed) {
2337
- hydrate();
2338
- }
2339
- return new Promise((resolve) => {
2340
- if (this.closed) {
2341
- resolve();
2342
- return;
2343
- }
2344
- this.outboxLeaderRelease = resolve;
2345
- });
2346
- }).catch(hydrate);
2347
- }
2348
- /**
2349
- * Load every cached query into {@link hydratedQueryCache} so the next
2350
- * `subscribe()` for each key seeds its initial value off disk. A
2351
- * subscription created before this resolves simply misses the cache (it
2352
- * gets a live snapshot as before); the gate at seed time also drops any
2353
- * entry whose stamped identity no longer matches the current one.
2354
- */
2355
- async hydrateQueryCache() {
2356
- if (!this.queryCache) {
2357
- return;
2358
- }
2359
- try {
2360
- const entries = await this.queryCache.load();
2361
- for (const { key, ...entry } of entries) {
2362
- if (isStaleVersion(this.persistenceVersion, entry.version)) {
2363
- this.queryCache.remove(key).catch(() => void 0);
2364
- continue;
2365
- }
2366
- this.hydratedQueryCache.set(key, entry);
2367
- }
2368
- } catch {
2369
- }
2370
- }
2371
- /**
2372
- * Consume the hydrated read-cache entry for a key (if any), gated on
2373
- * identity. The entry is removed whether or not it matches — the cache only
2374
- * ever seeds a subscription's first value. A mismatch (the cache was written
2375
- * under a different identity) yields `undefined` so a signed-out cache never
2376
- * leaks into a new session.
2377
- */
2378
- takeHydratedCache(functionPath, argsKey, shardKey) {
2379
- const key = queryCacheKey(functionPath, argsKey, shardKey);
2380
- const entry = this.hydratedQueryCache.get(key);
2381
- if (entry === void 0) {
2382
- return void 0;
2383
- }
2384
- this.hydratedQueryCache.delete(key);
2385
- return entry.identity === this.identityFingerprint() ? entry : void 0;
2386
- }
2387
- /**
2388
- * Queue a coalesced read-cache write for a subscription's current value.
2389
- * Latest-wins per key; flushed on a short debounce so a delta burst writes
2390
- * once. No-op when the read cache is disabled or the value is undefined
2391
- * (nothing to render offline).
2392
- */
2393
- persistQueryValue(state) {
2394
- const authoritative = state.serverBase;
2395
- if (!this.queryCache || authoritative === void 0) {
2396
- return;
2397
- }
2398
- const key = queryCacheKey(state.fn.__lunoraRef, state.argsKey, state.shardKey);
2399
- this.pendingCacheWrites.set(key, {
2400
- identity: this.identityFingerprint(),
2401
- serverCursor: state.serverCursor,
2402
- ts: Date.now(),
2403
- value: authoritative,
2404
- ...state.serverEpoch === void 0 ? {} : { serverEpoch: state.serverEpoch },
2405
- ...this.persistenceVersion === void 0 ? {} : { version: this.persistenceVersion }
2406
- });
2407
- this.cacheFlushTimer ??= setTimeout(() => {
2408
- this.flushQueryCacheWrites().catch(() => void 0);
2409
- }, QUERY_CACHE_DEBOUNCE_MS);
2410
- }
2411
- /** Drain {@link pendingCacheWrites} to the durable store. */
2412
- async flushQueryCacheWrites() {
2413
- this.cacheFlushTimer = void 0;
2414
- const { queryCache } = this;
2415
- if (!queryCache) {
2416
- this.pendingCacheWrites.clear();
2417
- return;
2418
- }
2419
- const batch = [...this.pendingCacheWrites.entries()];
2420
- this.pendingCacheWrites.clear();
2421
- await Promise.allSettled(batch.map(([key, entry]) => queryCache.put(key, entry)));
2422
- }
2423
- /** Derive the aggregate status from the per-shard socket states. */
2424
- computeStatus() {
2425
- const conns = [...this.connections.values()];
2426
- if (conns.length === 0) {
2427
- return "idle";
2428
- }
2429
- if (conns.some((conn) => conn.wsState === "open")) {
2430
- return "connected";
2431
- }
2432
- if (conns.some((conn) => conn.wsState === "connecting")) {
2433
- return "connecting";
2434
- }
2435
- return "offline";
2436
- }
2437
- /** Recompute the aggregate status and notify listeners if it changed. */
2438
- emitConnectionStatus() {
2439
- const next = this.computeStatus();
2440
- if (next === this.lastStatus) {
2441
- return;
2442
- }
2443
- this.lastStatus = next;
2444
- this.statusListeners.emit(next);
2445
- }
2446
- /**
2447
- * Build a {@link MutationSettledEvent} from a queued entry and emit it on the
2448
- * {@link onMutationSettled} channel. `item.id` is always assigned by the time
2449
- * a write settles (`enqueue`/`hydrate` guarantee it), so the `?? ""` fallback
2450
- * is unreachable — present only to satisfy the optional queue-id type.
2451
- */
2452
- emitItemSettled(item, status, error) {
2453
- this.mutationSettledListeners.emit({
2454
- args: item.args,
2455
- code: error === void 0 ? void 0 : error.code,
2456
- error,
2457
- functionPath: item.functionPath,
2458
- hadAwaiter: item.liveAwaiter ?? false,
2459
- id: item.id ?? "",
2460
- shardKey: item.shardKey,
2461
- status
2462
- });
2463
- }
2464
- /**
2465
- * Apply an optimistic update to the subscription that matches the mutation's
2466
- * `(functionRef, args, shardKey)` triple, returning the rollback callbacks to
2467
- * invoke if the mutation later fails.
2468
- *
2469
- * The registry is already indexed by exactly this triple via
2470
- * `SubscriptionRegistry.key`, so at most one subscription can match. A direct
2471
- * O(1) keyed lookup replaces the former O(N) linear scan over all subscriptions.
2472
- *
2473
- * `shardKey` normalization: both `undefined` and `""` map to the empty string
2474
- * inside `SubscriptionRegistry.key` (via `?? ""`), so a mutation fired without
2475
- * a shardKey correctly matches a subscription registered without one regardless
2476
- * of whether the caller passed `undefined` or omitted the field.
2477
- */
2478
- applyOptimisticUpdates(functionRef, argsRecord, mutationShardKey, optimistic) {
2479
- const confirms = [];
2480
- const rollbacks = [];
2481
- if (!optimistic) {
2482
- return { confirms, rollbacks };
2483
- }
2484
- const matchKey = SubscriptionRegistry.key(functionRef, argsRecord, mutationShardKey);
2485
- const state = this.subscriptions.get(matchKey);
2486
- if (state) {
2487
- const handle = applyOptimisticLayer(state, optimistic);
2488
- if (handle) {
2489
- confirms.push(handle.confirm);
2490
- rollbacks.push(handle.rollback);
2491
- }
2492
- }
2493
- return { confirms, rollbacks };
2494
- }
2495
- /**
2496
- * Run a Convex-parity `optimisticUpdate` callback against a localStore bound
2497
- * to the live subscription registry. Each `setQuery` registers a constant
2498
- * optimistic LAYER on its target subscription (via the same engine the
2499
- * per-call `optimistic` path uses), so the multi-query patch rebases onto
2500
- * incoming deltas and drops gaplessly on its commit cursor — its `confirm` /
2501
- * `rollback` closures are appended to the mutation's settle lists. A throwing
2502
- * callback unwinds its own partial writes — LIFO over just the rollbacks it
2503
- * produced — and is swallowed, so a buggy optimistic update can never fail the
2504
- * mutation or leave a partial patch live.
2505
- */
2506
- applyOptimisticUpdate(optimisticUpdate, args, shardKey, optimisticRollbacks, optimisticConfirms) {
2507
- const { confirms, rollbacks, store } = createLocalStore(this.subscriptions, shardKey);
2508
- try {
2509
- optimisticUpdate(store, args);
2510
- } catch {
2511
- for (let index = rollbacks.length - 1; index >= 0; index -= 1) {
2512
- rollbacks[index]?.();
2513
- }
2514
- return;
2515
- }
2516
- optimisticRollbacks.push(...rollbacks);
2517
- optimisticConfirms.push(...confirms);
2518
- }
2519
- getConnection(shardKey) {
2520
- return this.connections.get(connectionKey(shardKey));
2521
- }
2522
- getOrCreateConnection(shardKey) {
2523
- const key = connectionKey(shardKey);
2524
- let conn = this.connections.get(key);
2525
- if (!conn) {
2526
- conn = {
2527
- connectTimer: void 0,
2528
- heartbeatTimer: void 0,
2529
- pendingUnsubscribes: [],
2530
- reconnect: createReconnect(this.reconnectOptions),
2531
- reconnectTimer: void 0,
2532
- shardKey,
2533
- socket: void 0,
2534
- wasEverConnected: false,
2535
- wsState: "idle"
2536
- };
2537
- this.connections.set(key, conn);
2538
- }
2539
- return conn;
2540
- }
2541
- wsUrlFor(shardKey, token) {
2542
- const params = [];
2543
- if (shardKey !== void 0) {
2544
- params.push(`shard=${encodeURIComponent(shardKey)}`);
2545
- }
2546
- if (token !== void 0) {
2547
- params.push(`token=${encodeURIComponent(token)}`);
2548
- }
2549
- if (params.length === 0) {
2550
- return this.wsUrl;
2551
- }
2552
- const separator = this.wsUrl.includes("?") ? "&" : "?";
2553
- return `${this.wsUrl}${separator}${params.join("&")}`;
2554
- }
2555
- /**
2556
- * Build the outbound RPC headers: JSON content type, optional bearer auth,
2557
- * the optional mutation-replay idempotency key, and the D1 read-your-writes
2558
- * bookmark when the caller opted into `attachBookmark`. The mutation id
2559
- * rides both the direct send and any offline-queue replay of the same write,
2560
- * so a mutation the server already committed returns its cached result
2561
- * instead of running twice.
2562
- */
2563
- rpcRequestHeaders(flags) {
2564
- const headers = { "content-type": "application/json" };
2565
- if (this.authToken) {
2566
- headers["authorization"] = `Bearer ${this.authToken}`;
2567
- }
2568
- if (flags.mutationId) {
2569
- headers["x-lunora-mutation-id"] = flags.mutationId;
2570
- }
2571
- if (flags.clientId !== void 0) {
2572
- headers["x-lunora-client-id"] = flags.clientId;
2573
- }
2574
- if (flags.clientSeq !== void 0) {
2575
- headers["x-lunora-client-seq"] = flags.clientSeq.toString();
2576
- }
2577
- if (flags.attachBookmark) {
2578
- const bookmark = this.bookmark.get();
2579
- if (bookmark) {
2580
- headers["x-d1-bookmark"] = bookmark;
2581
- }
2582
- }
2583
- return headers;
2584
- }
2585
- async rpc(functionPath, args, shardKey, flags = {}) {
2586
- if (!this.fetchImpl) {
2587
- throw new LunoraError("INTERNAL", "LunoraClient: no `fetch` implementation available");
2588
- }
2589
- const headers = this.rpcRequestHeaders(flags);
2590
- const response = await this.fetchImpl(joinUrl(this.url, RPC_PATH), {
2591
- // `encodeWire` tags leaves plain JSON can't carry (`bigint`,
2592
- // `ArrayBuffer`/typed arrays, `NaN`/±Infinity); a pure-JSON `args`
2593
- // encodes byte-identically, so a pre-codec server still interops.
2594
- body: JSON.stringify({ args: encodeCallArgs(args, `args for '${functionPath}'`), functionPath, shardKey }),
2595
- headers,
2596
- method: "POST"
2597
- });
2598
- if (flags.captureBookmark) {
2599
- const value = response.headers.get("x-d1-bookmark");
2600
- if (value) {
2601
- this.bookmark.set(value);
2602
- }
2603
- }
2604
- let body;
2605
- try {
2606
- body = await response.json();
2607
- } catch {
2608
- const statusText = response.statusText ? ` ${response.statusText}` : "";
2609
- throw new LunoraError("INTERNAL", `LunoraClient: response was not JSON (status ${response.status.toString()}${statusText})`);
2610
- }
2611
- if ("error" in body) {
2612
- throw reconstructError(body.error);
2613
- }
2614
- if (!response.ok) {
2615
- const statusText = response.statusText ? ` ${response.statusText}` : "";
2616
- throw new LunoraError("INTERNAL", `LunoraClient: request failed (status ${response.status.toString()}${statusText})`);
2617
- }
2618
- flags.onMutationAck?.(body.lastMutationId);
2619
- flags.onCommitCursor?.(body.commitCursor);
2620
- return decodeWire(body.result);
2621
- }
2622
- /**
2623
- * Authenticated request to a non-RPC admin endpoint (the scheduler list /
2624
- * cancel routes). Attaches the bearer token, parses JSON, and surfaces the
2625
- * worker's `{ error: { code, message } }` envelope as a coded `Error` —
2626
- * mirroring {@link rpc} so callers see the same failure shape.
2627
- */
2628
- async adminFetch(path, method, payload, contentType) {
2629
- if (!this.fetchImpl) {
2630
- throw new LunoraError("INTERNAL", "LunoraClient: no `fetch` implementation available");
2631
- }
2632
- const headers = {};
2633
- if (this.authToken) {
2634
- headers["authorization"] = `Bearer ${this.authToken}`;
2635
- }
2636
- const isBinary = payload instanceof ArrayBuffer || payload instanceof Blob;
2637
- let requestBody;
2638
- if (payload === void 0) {
2639
- requestBody = void 0;
2640
- } else if (isBinary) {
2641
- requestBody = payload;
2642
- if (contentType !== void 0) {
2643
- headers["content-type"] = contentType;
2644
- }
2645
- } else {
2646
- requestBody = JSON.stringify(payload);
2647
- headers["content-type"] = "application/json";
2648
- }
2649
- const response = await this.fetchImpl(joinUrl(this.url, path), {
2650
- body: requestBody,
2651
- headers,
2652
- method
2653
- });
2654
- let body;
2655
- try {
2656
- body = await response.json();
2657
- } catch {
2658
- const statusText = response.statusText ? ` ${response.statusText}` : "";
2659
- throw new LunoraError("INTERNAL", `LunoraClient: response was not JSON (status ${response.status.toString()}${statusText})`);
2660
- }
2661
- if (typeof body === "object" && body !== null && "error" in body) {
2662
- const envelope = body.error;
2663
- const error = new Error(envelope.message ?? "admin request failed");
2664
- error.code = envelope.code;
2665
- throw error;
2666
- }
2667
- if (!response.ok) {
2668
- const statusText = response.statusText ? ` ${response.statusText}` : "";
2669
- throw new LunoraError("INTERNAL", `LunoraClient: admin request failed (status ${response.status.toString()}${statusText})`);
2670
- }
2671
- return body;
2672
- }
2673
- /**
2674
- * Resolve the effective connection context for a shard: the most-recently
2675
- * acquired refcounted holder ({@link acquireConnectionContext}) wins, falling
2676
- * back to the imperative {@link setConnectionContext} override, then the
2677
- * client-wide default. Returns `undefined` when none apply.
2678
- */
2679
- effectiveConnectionContext(key) {
2680
- const holders = this.connectionContextHolders.get(key);
2681
- if (holders && holders.length > 0) {
2682
- return holders[holders.length - 1]?.context;
2683
- }
2684
- return this.connectionContexts.get(key) ?? this.defaultConnectionContext;
2685
- }
2686
- /** Re-send the `connect` envelope for a shard whose effective context just changed (if its socket is open). */
2687
- refreshConnectionContext(key) {
2688
- const conn = this.connections.get(key);
2689
- if (conn?.wsState === "open") {
2690
- this.sendConnectEnvelope(conn);
2691
- }
2692
- }
2693
- /**
2694
- * Send the one-shot `connect` envelope on an open shard socket. Always sent
2695
- * once per socket open, so the server's `onConnect` hooks fire symmetrically
2696
- * with `onDisconnect` (which the DO dispatches unconditionally at close for
2697
- * every lifecycle-aware socket). The DO no-ops cheaply when no `onConnect`
2698
- * hooks are registered, so the single frame costs nothing in the common case.
2699
- *
2700
- * The shard's registered context (or the client-wide default) rides along
2701
- * when one is set — the DO records it on the attachment for replay to
2702
- * `onDisconnect`. A socket with no registered context still announces itself;
2703
- * the envelope simply omits `context`, which is optional on the wire.
2704
- * Register a context — e.g. `setConnectionContext({})` — to attach app state
2705
- * to the lifecycle dispatch.
2706
- */
2707
- sendConnectEnvelope(conn) {
2708
- const context = this.effectiveConnectionContext(connectionKey(conn.shardKey));
2709
- sendOn(conn, {
2710
- // Lets the server scope this connection's `__client_watermark` so
2711
- // custom-mutator pokes can echo this client's `lastMutationId`.
2712
- clientId: this.clientId,
2713
- id: "connect",
2714
- type: "connect",
2715
- ...context === void 0 ? {} : { context }
2716
- });
2717
- }
2718
- /**
2719
- * Re-send every shape subscription bound to `shardKey` over its (now open)
2720
- * socket. Each frame carries the shape's last applied checkpoint, so the
2721
- * server resumes from it — or re-seeds when the cursor fell below CDC
2722
- * retention or the epoch forked.
2723
- */
2724
- resendShapeSubscriptions(shardKey) {
2725
- for (const state of this.shapeSubscriptions.values()) {
2726
- if (connectionKey(state.shardKey) === connectionKey(shardKey)) {
2727
- this.sendShapeSubscribeIfOpen(state);
2728
- }
2729
- }
2730
- }
2731
- ensureSocket(shardKey) {
2732
- if (this.closed || this.WebSocketImpl === void 0) {
2733
- return;
2734
- }
2735
- if (this.tabCoordinator && !this.tabCoordinator.isLeader()) {
2736
- return;
2737
- }
2738
- const conn = this.getOrCreateConnection(shardKey);
2739
- if (conn.wsState === "open" || conn.wsState === "connecting") {
2740
- return;
2741
- }
2742
- conn.wsState = "connecting";
2743
- this.emitConnectionStatus();
2744
- if (typeof this.wsToken === "function") {
2745
- this.openSocketWithProvidedToken(conn, shardKey, this.wsToken).catch(() => void 0);
2746
- return;
2747
- }
2748
- this.openSocket(conn, shardKey, this.wsToken);
2749
- }
2750
- /**
2751
- * Resolve the {@link WsTokenProvider} and open the shard socket with the
2752
- * minted token. The connection is already in the `connecting` state, so the
2753
- * async gap is race-guarded: a client `close()`, a `setWsToken` bounce, or a
2754
- * competing connect that landed first all abandon this attempt. A provider
2755
- * failure fails the attempt through {@link handleDisconnect}, which arms the
2756
- * normal reconnect backoff — a broken mint endpoint degrades to retries, not
2757
- * a silent tokenless socket the admin gate would reject.
2758
- */
2759
- async openSocketWithProvidedToken(conn, shardKey, provider) {
2760
- let token;
2761
- try {
2762
- token = await provider();
2763
- } catch {
2764
- this.handleDisconnect(conn);
2765
- return;
2766
- }
2767
- if (this.closed || this.WebSocketImpl === void 0 || conn.wsState !== "connecting" || conn.socket !== void 0) {
2768
- return;
2769
- }
2770
- this.openSocket(conn, shardKey, token);
2771
- }
2772
- /** Construct the shard socket and wire its lifecycle handlers. The connection must already be in the `connecting` state. */
2773
- openSocket(conn, shardKey, token) {
2774
- if (this.WebSocketImpl === void 0) {
2775
- return;
2776
- }
2777
- const socket = new this.WebSocketImpl(this.wsUrlFor(shardKey, token));
2778
- conn.socket = socket;
2779
- if (this.connectTimeoutMs > 0) {
2780
- conn.connectTimer = setTimeout(() => {
2781
- conn.connectTimer = void 0;
2782
- if (conn.socket !== socket || conn.wsState !== "connecting") {
2783
- return;
2784
- }
2785
- try {
2786
- socket.close();
2787
- } catch {
2788
- }
2789
- this.handleDisconnect(conn);
2790
- }, this.connectTimeoutMs);
2791
- }
2792
- socket.addEventListener("open", () => {
2793
- if (conn.socket !== socket) {
2794
- return;
2795
- }
2796
- if (conn.connectTimer !== void 0) {
2797
- clearTimeout(conn.connectTimer);
2798
- conn.connectTimer = void 0;
2799
- }
2800
- conn.wsState = "open";
2801
- conn.wasEverConnected = true;
2802
- conn.reconnect.reset();
2803
- this.emitConnectionStatus();
2804
- this.sendConnectEnvelope(conn);
2805
- this.markShardPendingAck(shardKey);
2806
- for (const state of this.subscriptions.all()) {
2807
- if (connectionKey(state.shardKey) === connectionKey(shardKey)) {
2808
- this.sendSubscribeIfOpen(state);
2809
- }
2810
- }
2811
- this.resendShapeSubscriptions(shardKey);
2812
- if (conn.pendingUnsubscribes.length > 0) {
2813
- const pending = conn.pendingUnsubscribes;
2814
- conn.pendingUnsubscribes = [];
2815
- for (const { id, type } of pending) {
2816
- sendOn(conn, { id, type });
2817
- }
2818
- }
2819
- if (conn.pendingStreams && conn.pendingStreams.length > 0) {
2820
- const pending = conn.pendingStreams;
2821
- conn.pendingStreams = [];
2822
- for (const message of pending) {
2823
- sendOn(conn, message);
2824
- }
2825
- }
2826
- const byTopic = this.whisperHandlers.get(connectionKey(shardKey));
2827
- if (byTopic) {
2828
- for (const topic of byTopic.keys()) {
2829
- sendOn(conn, { topic, type: "whisper_subscribe" });
2830
- }
2831
- }
2832
- this.flushOfflineQueue(shardKey).catch(() => void 0);
2833
- this.startHeartbeat(conn);
2834
- });
2835
- socket.addEventListener("message", (event) => {
2836
- this.handleServerMessage(event.data, shardKey);
2837
- });
2838
- socket.addEventListener("close", (event) => {
2839
- if (conn.socket !== socket) {
2840
- return;
2841
- }
2842
- if (event?.code === 4001) {
2843
- this.notifyTokenExpired();
2844
- }
2845
- this.handleDisconnect(conn);
2846
- });
2847
- socket.addEventListener("error", () => {
2848
- if (conn.socket !== socket) {
2849
- return;
2850
- }
2851
- if (conn.wsState === "connecting" || conn.wsState === "open") {
2852
- this.handleDisconnect(conn);
2853
- }
2854
- });
2855
- }
2856
- handleDisconnect(conn) {
2857
- if (this.closed) {
2858
- return;
2859
- }
2860
- if (conn.wsState === "idle" || conn.wsState === "closed") {
2861
- return;
2862
- }
2863
- this.stopHeartbeat(conn);
2864
- if (conn.connectTimer !== void 0) {
2865
- clearTimeout(conn.connectTimer);
2866
- conn.connectTimer = void 0;
2867
- }
2868
- conn.socket = void 0;
2869
- conn.wsState = "idle";
2870
- this.emitConnectionStatus();
2871
- this.markShardPendingAck(conn.shardKey);
2872
- const pendingStreamIds = new Set((conn.pendingStreams ?? []).map((message) => message.id));
2873
- for (const [id, stream] of this.streams) {
2874
- if (connectionKey(stream.shardKey) === connectionKey(conn.shardKey) && !pendingStreamIds.has(id)) {
2875
- stream.handle.fail(new LunoraError("STREAM_DISCONNECTED", "stream terminated: WebSocket disconnected"));
2876
- this.streams.delete(id);
2877
- }
2878
- }
2879
- if (this.WebSocketImpl === void 0) {
2880
- return;
2881
- }
2882
- const delay = conn.reconnect.next();
2883
- conn.reconnectTimer = setTimeout(() => {
2884
- conn.reconnectTimer = void 0;
2885
- this.ensureSocket(conn.shardKey);
2886
- }, delay);
2887
- }
2888
- /**
2889
- * Begin the keepalive heartbeat on an open connection. Each tick sends a
2890
- * {@link WS_KEEPALIVE_PING} text frame the server answers from its
2891
- * hibernation auto-response without waking the DO. A no-op when the
2892
- * heartbeat is disabled (an interval of zero or less); idempotent — any
2893
- * existing timer is cleared first so a reconnect can't leak intervals.
2894
- */
2895
- startHeartbeat(conn) {
2896
- this.stopHeartbeat(conn);
2897
- if (this.heartbeatIntervalMs <= 0) {
2898
- return;
2899
- }
2900
- conn.heartbeatTimer = setInterval(() => {
2901
- if (conn.wsState !== "open" || !conn.socket) {
2902
- return;
2903
- }
2904
- try {
2905
- conn.socket.send(WS_KEEPALIVE_PING);
2906
- } catch {
2907
- }
2908
- }, this.heartbeatIntervalMs);
2909
- }
2910
- /** Clear a connection's keepalive timer, if any. Safe to call repeatedly. */
2911
- // eslint-disable-next-line class-methods-use-this -- cohesive connection helper; pairs with startHeartbeat
2912
- stopHeartbeat(conn) {
2913
- if (conn.heartbeatTimer !== void 0) {
2914
- clearInterval(conn.heartbeatTimer);
2915
- conn.heartbeatTimer = void 0;
2916
- }
2917
- }
2918
- /** Mark every subscription bound to `shardKey` as needing a fresh ack. */
2919
- markShardPendingAck(shardKey) {
2920
- const key = connectionKey(shardKey);
2921
- for (const state of this.subscriptions.all()) {
2922
- if (connectionKey(state.shardKey) === key) {
2923
- state.acked = false;
2924
- }
2925
- }
2926
- }
2927
- sendSubscribeIfOpen(state) {
2928
- const conn = this.getConnection(state.shardKey);
2929
- if (conn?.wsState !== "open" || state.acked) {
2930
- return;
2931
- }
2932
- const table = state.fn.__lunoraTable ?? state.fn.__lunoraRef;
2933
- sendOn(conn, {
2934
- id: state.id,
2935
- // `sinceSeq` rides along when we hold a persisted cursor for this
2936
- // sub (a hydrated read or an earlier frame), so the server can
2937
- // resume instead of re-snapshotting. Omitted on a cold sub.
2938
- query: {
2939
- // Wire-encode so a `bigint`/`Date`/bytes arg survives the frame's
2940
- // `JSON.stringify` (the shard `decodeWire`s at its subscribe entry
2941
- // point). Identity for pure-JSON args. Cannot throw here: the
2942
- // registry key (`stableWireKey`) already encoded these args at
2943
- // subscribe() time, so reconnect resends stay safe.
2944
- args: encodeWire(state.args),
2945
- functionPath: state.fn.__lunoraRef,
2946
- table,
2947
- ...state.serverCursor === void 0 ? {} : { sinceSeq: state.serverCursor },
2948
- ...state.serverEpoch === void 0 ? {} : { sinceEpoch: state.serverEpoch }
2949
- },
2950
- type: "subscribe"
2951
- });
2952
- }
2953
- sendShapeSubscribeIfOpen(state) {
2954
- const conn = this.getConnection(state.shardKey);
2955
- if (conn?.wsState !== "open") {
2956
- return;
2957
- }
2958
- sendOn(conn, {
2959
- id: state.id,
2960
- // `wireArgs` is the pre-encoded form of `args` (computed at
2961
- // `subscribeShape` time) so a `bigint`/`Date`/bytes arg survives the
2962
- // frame's `JSON.stringify`; the shard `decodeWire`s it at its
2963
- // `shape_subscribe` entry point. Identity for pure-JSON args.
2964
- shape: { name: state.name, ...state.wireArgs === void 0 ? {} : { args: state.wireArgs } },
2965
- type: "shape_subscribe",
2966
- // Resume from the last applied checkpoint when we hold one; a cold
2967
- // subscribe omits it and the server seeds the full membership.
2968
- ...state.serverCursor === void 0 ? {} : { sinceCheckpoint: state.serverCursor },
2969
- ...state.serverEpoch === void 0 ? {} : { sinceEpoch: state.serverEpoch }
2970
- });
2971
- }
2972
- handleServerMessage(raw, shardKey) {
2973
- const text = decodeServerFrame(raw);
2974
- if (text === void 0) {
2975
- return;
2976
- }
2977
- let message;
2978
- try {
2979
- message = JSON.parse(text);
2980
- } catch {
2981
- return;
2982
- }
2983
- switch (message.type) {
2984
- case "ack": {
2985
- const state = this.subscriptions.getById(message.id);
2986
- if (state) {
2987
- state.acked = true;
2988
- }
2989
- return;
2990
- }
2991
- case "chunk": {
2992
- const { data, id } = message;
2993
- const stream = this.streams.get(id);
2994
- stream?.handle.push(decodeWire(data));
2995
- return;
2996
- }
2997
- case "complete": {
2998
- this.handleCompleteMessage(message.id);
2999
- return;
3000
- }
3001
- case "data":
3002
- case "delta": {
3003
- this.handleDataMessage(message);
3004
- return;
3005
- }
3006
- case "error": {
3007
- this.handleErrorMessage(message);
3008
- break;
3009
- }
3010
- case "pokeEnd": {
3011
- this.handlePokeEnd(message);
3012
- break;
3013
- }
3014
- case "pokePart": {
3015
- this.handlePokePart(message);
3016
- break;
3017
- }
3018
- case "pokeStart": {
3019
- this.handlePokeStart(message);
3020
- break;
3021
- }
3022
- case "resume": {
3023
- this.handleResumeMessage(message);
3024
- break;
3025
- }
3026
- case "settled": {
3027
- this.handleSettledMessage(message);
3028
- break;
3029
- }
3030
- case "whisper": {
3031
- this.dispatchWhisper(message, shardKey);
3032
- break;
3033
- }
3034
- }
3035
- }
3036
- handleErrorMessage(message) {
3037
- const errorCode = message.error?.code;
3038
- if (errorCode === "TOKEN_EXPIRED") {
3039
- this.notifyTokenExpired();
3040
- return;
3041
- }
3042
- const { id } = message;
3043
- const stream = id === void 0 ? void 0 : this.streams.get(id);
3044
- if (stream && id !== void 0) {
3045
- stream.handle.fail(buildStreamError(message));
3046
- this.streams.delete(id);
3047
- return;
3048
- }
3049
- const state = id === void 0 ? void 0 : this.subscriptions.getById(id);
3050
- if (state) {
3051
- fanSubscriptionError(state.errorCallbacks, buildSubscriptionError(message));
3052
- return;
3053
- }
3054
- const shapeState = id === void 0 ? void 0 : this.shapeSubscriptions.get(id);
3055
- if (shapeState) {
3056
- fanSubscriptionError(shapeState.errorCallbacks, buildSubscriptionError(message));
3057
- }
3058
- }
3059
- handlePokeStart(message) {
3060
- evictOldestEntry(this.pokeBuffers, LunoraClient.MAX_POKE_BUFFERS);
3061
- this.pokeBuffers.set(message.pokeId, { baseCheckpoint: message.baseCheckpoint, epoch: message.epoch, lastMutationId: /* @__PURE__ */ new Map(), parts: /* @__PURE__ */ new Map() });
3062
- }
3063
- handlePokePart(message) {
3064
- const buffer = this.pokeBuffers.get(message.pokeId);
3065
- if (!buffer) {
3066
- return;
3067
- }
3068
- const existing = buffer.parts.get(message.shapeId) ?? [];
3069
- for (const op of message.rowsPatch) {
3070
- existing.push(op.value === void 0 ? op : { ...op, value: decodeWire(op.value) });
3071
- }
3072
- buffer.parts.set(message.shapeId, existing);
3073
- if (message.lastMutationId !== void 0) {
3074
- buffer.lastMutationId.set(message.shapeId, message.lastMutationId);
3075
- }
3076
- }
3077
- handlePokeEnd(message) {
3078
- const buffer = this.pokeBuffers.get(message.pokeId);
3079
- if (!buffer) {
3080
- return;
3081
- }
3082
- this.pokeBuffers.delete(message.pokeId);
3083
- for (const [shapeId, ops] of buffer.parts) {
3084
- const state = this.shapeSubscriptions.get(shapeId);
3085
- if (!state) {
3086
- continue;
3087
- }
3088
- const epochForked = buffer.epoch !== void 0 && state.serverEpoch !== void 0 && buffer.epoch !== state.serverEpoch;
3089
- const baseDiverged = buffer.baseCheckpoint !== void 0 && state.serverCursor !== void 0 && state.serverCursor !== buffer.baseCheckpoint;
3090
- if (epochForked || baseDiverged) {
3091
- state.rows.clear();
3092
- state.serverCursor = void 0;
3093
- state.serverEpoch = void 0;
3094
- this.emitShapeRows(state);
3095
- this.sendShapeSubscribeIfOpen(state);
3096
- continue;
3097
- }
3098
- applyRowOpsToView(state.rows, ops);
3099
- if (message.checkpoint !== void 0) {
3100
- state.serverCursor = message.checkpoint;
3101
- }
3102
- if (message.epoch !== void 0) {
3103
- state.serverEpoch = message.epoch;
3104
- }
3105
- const watermark = buffer.lastMutationId.get(shapeId);
3106
- if (watermark !== void 0) {
3107
- state.lastMutationId = watermark;
3108
- }
3109
- this.emitShapeRows(state);
3110
- state.onCheckpoint?.({ checkpoint: state.serverCursor, mutationId: state.lastMutationId });
3111
- }
3112
- }
3113
- /** Materialize a shape's keyed view to an array and invoke its callbacks. */
3114
- // eslint-disable-next-line class-methods-use-this -- a pure state→callback fan-out kept beside the shape-subscription pipeline it serves.
3115
- emitShapeRows(state) {
3116
- const rows = [...state.rows.values()];
3117
- for (const shapeCallback of state.callbacks) {
3118
- try {
3119
- shapeCallback(rows);
3120
- } catch {
3121
- }
3122
- }
3123
- }
3124
- handleDataMessage(message) {
3125
- const { id } = message;
3126
- const state = id ? this.subscriptions.getById(id) : void 0;
3127
- if (!state) {
3128
- return;
3129
- }
3130
- const payload = this.resolveDataPayload(message, state);
3131
- state.serverBase = payload;
3132
- if (message.cursor !== void 0) {
3133
- state.serverCursor = message.cursor;
3134
- }
3135
- if (message.epoch !== void 0) {
3136
- state.serverEpoch = message.epoch;
3137
- }
3138
- this.persistQueryValue(state);
3139
- dropConfirmedLayers(state, state.serverCursor);
3140
- notifySubscription(state, state.optimisticLayers.length === 0 ? payload : foldOptimistic(payload, state.optimisticLayers));
3141
- if (this.tabCoordinator?.isLeader()) {
3142
- const key = SubscriptionRegistry.key(state.fn.__lunoraRef, state.args, state.shardKey);
3143
- this.tabCoordinator.broadcastSubscriptionData(key, payload);
3144
- }
3145
- }
3146
- /**
3147
- * Handle a `resume` frame (Pillar 1b): the server proved nothing the
3148
- * subscription reads changed since our `sinceSeq`, so the cached value is
3149
- * still current. We keep `lastValue` as-is, mark the sub acked, and advance
3150
- * the cursor (re-persisting so the next reconnect resumes from the newer
3151
- * watermark). No callback fires — the value didn't change, and `subscribe()`
3152
- * already replayed the cached value to every consumer synchronously.
3153
- */
3154
- handleResumeMessage(message) {
3155
- const state = this.subscriptions.getById(message.id);
3156
- if (!state) {
3157
- return;
3158
- }
3159
- this.ackAndAdvanceCursor(state, message.cursor, message.epoch);
3160
- }
3161
- /**
3162
- * Handle a `settled` frame: a write touched one of this subscription's read
3163
- * tables but produced a byte-identical result, so the server suppressed the
3164
- * data frame. Like {@link handleResumeMessage} the value didn't change — we
3165
- * advance the resume position and re-persist — but we ALSO surface the echoed
3166
- * custom-mutator watermark via `onCheckpoint` so a `@lunora/db` list
3167
- * collection drops the optimistic overlay for the confirmed write (otherwise
3168
- * its checkpoint gate, fed only by data frames, would hang forever). Sent
3169
- * only to custom-mutator clients; plain `useQuery` subscribers leave
3170
- * `onCheckpoint` unset and this is a near no-op.
3171
- */
3172
- handleSettledMessage(message) {
3173
- const state = this.subscriptions.getById(message.id);
3174
- if (!state) {
3175
- return;
3176
- }
3177
- this.ackAndAdvanceCursor(state, message.cursor, message.epoch);
3178
- if (message.lastMutationId !== void 0) {
3179
- state.lastMutationId = message.lastMutationId;
3180
- }
3181
- for (const onCheckpoint of state.checkpointCallbacks) {
3182
- onCheckpoint({ checkpoint: state.serverCursor, mutationId: state.lastMutationId });
3183
- }
3184
- }
3185
- /**
3186
- * Mark `state` acked and, when the frame carries a newer cursor/epoch than
3187
- * the cached position, advance the resume watermark and re-persist. Shared by
3188
- * the `resume` and `settled` frame handlers — both acknowledge "nothing the
3189
- * client must re-render changed, but the resume position may have moved".
3190
- */
3191
- ackAndAdvanceCursor(state, cursor, epoch) {
3192
- state.acked = true;
3193
- if (cursor !== void 0 && cursor !== state.serverCursor || epoch !== void 0 && epoch !== state.serverEpoch) {
3194
- if (cursor !== void 0) {
3195
- state.serverCursor = cursor;
3196
- }
3197
- if (epoch !== void 0) {
3198
- state.serverEpoch = epoch;
3199
- }
3200
- this.persistQueryValue(state);
3201
- if (dropConfirmedLayers(state, state.serverCursor)) {
3202
- notifySubscription(state, foldOptimistic(state.serverBase, state.optimisticLayers));
3203
- }
3204
- }
3205
- }
3206
- /**
3207
- * Resolve the value to publish for a `data`/`delta` frame.
3208
- *
3209
- * A `data` frame is an authoritative snapshot (the server re-execution path)
3210
- * and always replaces the cached value wholesale. A `delta` frame carrying a
3211
- * structured `MutationDelta` (the `broadcastDelta` row-change path) is
3212
- * merged incrementally into the cached list — preserving order, no dup/loss —
3213
- * so each subscription (including every paginated page) updates by delta
3214
- * rather than a full re-send. We fall back to full replacement when the
3215
- * delta isn't a recognisable row change, when there's no cached value yet,
3216
- * or when it can't be applied cleanly against the current cached shape.
3217
- */
3218
- // eslint-disable-next-line class-methods-use-this -- instance method for symmetry with the other message handlers; reads no shared client state
3219
- resolveDataPayload(message, state) {
3220
- if ("data" in message && message.data !== void 0) {
3221
- return decodeWire(message.data);
3222
- }
3223
- const delta = decodeWire(message.delta);
3224
- if (isMutationDelta(delta) && state.serverBase !== void 0) {
3225
- const merged = applyDelta(state.serverBase, delta);
3226
- if (merged !== void 0) {
3227
- return merged;
3228
- }
3229
- }
3230
- return delta;
3231
- }
3232
- /** Route an inbound whisper to the topic's handlers on the originating shard. */
3233
- dispatchWhisper(message, shardKey) {
3234
- const handlers = this.whisperHandlers.get(connectionKey(shardKey))?.get(message.topic);
3235
- if (!handlers) {
3236
- return;
3237
- }
3238
- const data = decodeWire(message.data);
3239
- for (const handler of handlers) {
3240
- try {
3241
- handler(data, message.from);
3242
- } catch {
3243
- }
3244
- }
3245
- }
3246
- /** Notify every {@link onTokenExpired} listener (best-effort, listener throws swallowed). */
3247
- notifyTokenExpired() {
3248
- this.tokenExpiredListeners.emit();
3249
- }
3250
- handleCompleteMessage(id) {
3251
- const stream = this.streams.get(id);
3252
- if (stream) {
3253
- stream.handle.complete();
3254
- this.streams.delete(id);
3255
- return;
3256
- }
3257
- const state = this.subscriptions.getById(id);
3258
- if (state) {
3259
- this.subscriptions.remove(state);
3260
- }
3261
- }
3262
- unpersist(id) {
3263
- if (id) {
3264
- this.persistence?.remove(id).catch((error) => {
3265
- reportPersistenceError(this.onPersistenceError, "remove", error, id);
3266
- });
3267
- }
3268
- }
3269
- /**
3270
- * Stable, non-reversible fingerprint of the current auth identity used to
3271
- * stamp queued offline writes. `null` (signed out) is its own identity and
3272
- * never matches a bearer-token fingerprint. The raw token is never stored;
3273
- * a length-prefixed FNV-1a hash is enough to detect an identity *change*
3274
- * without keeping the credential around in the queue map.
3275
- */
3276
- // `null` is the distinct "signed out" identity (separate from `undefined`,
3277
- // which means "not stamped / hydrated"); the two must not be conflated.
3278
- identityFingerprint() {
3279
- if (this.authSubject !== void 0) {
3280
- return this.authSubject === null ? null : `subj:${this.authSubject}`;
3281
- }
3282
- const token = this.authToken;
3283
- if (token === null) {
3284
- return null;
3285
- }
3286
- return this.hashToken(token);
3287
- }
3288
- /**
3289
- * Stable token-hash fingerprint of a bearer token (the `&lt;len>:&lt;fnv>:&lt;djb2>`
3290
- * format a token-stamped queued write carries). Extracted so the replay gate
3291
- * can recompute the hash of the current credential and recognise a write
3292
- * stamped under it — even after the fingerprint was relabelled to a subject.
3293
- *
3294
- * Two independent 32-bit passes (FNV-1a + djb2) give a ~64-bit digest, so
3295
- * two distinct equal-length tokens are astronomically unlikely to share a
3296
- * fingerprint. A single 32-bit hash collides ~1-in-4e9 per equal-length
3297
- * pair — enough that, on a shared device, user B could hydrate A's cached
3298
- * reads. Different algorithms (not the same FNV with a different seed, which
3299
- * would be affine-related) keep the two passes genuinely independent.
3300
- * Still synchronous (no crypto) and stable across surrogate pairs.
3301
- */
3302
- // eslint-disable-next-line class-methods-use-this -- pure helper; a method for locality with identityFingerprint, reads no shared state
3303
- hashToken(token) {
3304
- let fnv = 2166136261;
3305
- let djb2 = 5381;
3306
- for (let index = 0; index < token.length; index += 1) {
3307
- const code = token.charCodeAt(index);
3308
- fnv ^= code;
3309
- fnv = Math.imul(fnv, 16777619);
3310
- djb2 = Math.imul(djb2, 33) + code;
3311
- }
3312
- return `${token.length.toString(36)}:${(fnv >>> 0).toString(36)}:${(djb2 >>> 0).toString(36)}`;
3313
- }
3314
- /**
3315
- * True when `stamped` is a token-hash of the SAME credential still held now,
3316
- * even though the live identity has since been relabelled to a subject. Covers
3317
- * `setAuthToken(token, userId)` where the subject resolved a tick after the
3318
- * token was set: a write persisted (or requeued) under the token hash must
3319
- * still replay — the credential never changed, only its label — instead of
3320
- * being dropped as an identity mismatch. This is the durable counterpart to
3321
- * {@link restampQueuedIdentity}, which only relabels the in-memory live stamp
3322
- * (consumed on the first flush) and never touches `item.identity` or the
3323
- * persisted record, so a reload or a transient-failure requeue would otherwise
3324
- * fall back to the stale token-hash and wrongly reject the same user's write.
3325
- */
3326
- isSameCredentialUnderTokenHash(stamped) {
3327
- if (stamped === null || stamped.startsWith("subj:")) {
3328
- return false;
3329
- }
3330
- const token = this.authToken;
3331
- return token === null ? false : this.hashToken(token) === stamped;
3332
- }
3333
- /**
3334
- * Drain every in-memory offline write and reject it because the auth
3335
- * identity changed. Durable entries are also dropped from persistence so a
3336
- * later `hydrate` can't resurrect another user's writes. Stamps are cleared
3337
- * alongside. Persisted entries restored without a live awaiter still get
3338
- * unpersisted here.
3339
- */
3340
- rejectQueuedForIdentityChange() {
3341
- const drained = this.offlineQueue.drain();
3342
- for (const item of drained) {
3343
- this.queuedIdentities.delete(item.id ?? "");
3344
- this.unpersist(item.id);
3345
- const error = new Error("offline mutation discarded: auth identity changed before replay");
3346
- error.code = "OFFLINE_IDENTITY_CHANGED";
3347
- item.reject(error);
3348
- this.emitItemSettled(item, "rejected", error);
3349
- }
3350
- this.clearQueryCacheForIdentityChange();
3351
- }
3352
- /**
3353
- * Migrate every live identity stamp from `from` to `to` — used when the auth
3354
- * identity label changes but the underlying credential (token) does NOT, e.g.
3355
- * the user id resolves a tick after the token was set. The in-memory
3356
- * `queuedIdentities` map is the flush-time source of truth, so re-stamping it
3357
- * keeps the in-flight writes replayable under the new (more stable) identity
3358
- * instead of the flush guard discarding them as a mismatch.
3359
- */
3360
- restampQueuedIdentity(from, to) {
3361
- for (const [id, stamp] of this.queuedIdentities) {
3362
- if (stamp === from) {
3363
- this.queuedIdentities.set(id, to);
3364
- }
3365
- }
3366
- }
3367
- /**
3368
- * Drop the durable read cache on an identity change so a cached value stamped
3369
- * under the previous identity can never hydrate into a new session. Clears
3370
- * the in-flight write batch and the not-yet-consumed hydrated entries too;
3371
- * the durable `clear()` is best-effort.
3372
- */
3373
- clearQueryCacheForIdentityChange() {
3374
- if (this.cacheFlushTimer !== void 0) {
3375
- clearTimeout(this.cacheFlushTimer);
3376
- this.cacheFlushTimer = void 0;
3377
- }
3378
- this.pendingCacheWrites.clear();
3379
- this.hydratedQueryCache.clear();
3380
- this.queryCache?.clear().catch(() => void 0);
3381
- }
3382
- async flushOfflineQueue(shardKey) {
3383
- const conflicted = this.offlineQueue.drainConflict();
3384
- for (const item of conflicted) {
3385
- this.unpersist(item.id);
3386
- this.queuedIdentities.delete(item.id ?? "");
3387
- const error = new Error("offline mutation skipped: precondition failed before replay");
3388
- error.code = "OFFLINE_PRECONDITION_FAILED";
3389
- this.emitItemSettled(item, "rejected", error);
3390
- }
3391
- const key = connectionKey(shardKey);
3392
- const drained = this.offlineQueue.drain((item) => connectionKey(item.shardKey) === key);
3393
- if (drained.length === 0) {
3394
- return;
3395
- }
3396
- const currentIdentity = this.identityFingerprint();
3397
- const sendable = [];
3398
- for (const item of drained) {
3399
- if (this.passesReplayIdentityGate(item, currentIdentity)) {
3400
- sendable.push(item);
3401
- }
3402
- }
3403
- if (sendable.length === 0) {
3404
- return;
3405
- }
3406
- const encodable = this.encodableOrSettleTerminal(sendable);
3407
- if (encodable.length === 0) {
3408
- return;
3409
- }
3410
- if (encodable.length === 1) {
3411
- await this.replaySequential(encodable);
3412
- return;
3413
- }
3414
- const toRequeue = [];
3415
- for (let start = 0; start < encodable.length; start += MAX_BATCH_ENTRIES) {
3416
- const chunk = encodable.slice(start, start + MAX_BATCH_ENTRIES);
3417
- const outcome = await this.replayBatched(chunk);
3418
- toRequeue.push(...outcome.requeue);
3419
- if (outcome.stop) {
3420
- toRequeue.push(...encodable.slice(start + MAX_BATCH_ENTRIES));
3421
- break;
3422
- }
3423
- }
3424
- if (toRequeue.length > 0) {
3425
- this.offlineQueue.requeue(toRequeue);
3426
- }
3427
- }
3428
- /**
3429
- * Partition already-gated writes into the encodable ones (returned) and reject
3430
- * the rest terminally. A write whose args can't be wire-encoded (e.g. a RegExp
3431
- * or class instance in a `v.any()` field) can NEVER replay — the codec failure
3432
- * is deterministic, not transient. Rejecting here is essential: otherwise
3433
- * `encodeWire` throws mid-flush, is classified as transient (a codec error has
3434
- * no `.code`), and re-queues forever — a silent hang where the caller's Promise
3435
- * never settles and the optimistic write never rolls back. Encoding is cheap;
3436
- * the flush is the slow reconnect path.
3437
- */
3438
- encodableOrSettleTerminal(items) {
3439
- const encodable = [];
3440
- for (const item of items) {
3441
- try {
3442
- encodeCallArgs(item.args, `args for '${item.functionPath}'`);
3443
- encodable.push(item);
3444
- } catch (error) {
3445
- this.settleReplayTerminal(item, error instanceof Error ? error : new Error(String(error)));
3446
- }
3447
- }
3448
- return encodable;
3449
- }
3450
- /**
3451
- * Identity guard for one queued write about to replay: a write stamped under
3452
- * one identity must never replay under another. The live `queuedIdentities`
3453
- * map is the source of truth for the current session; a hydrated write whose
3454
- * id isn't in the map falls back to the stamp persisted with the record
3455
- * (`item.identity`), so a reload can't replay another user's queued writes.
3456
- * Only legacy records (persisted before stamps were durable —
3457
- * `item.identity === undefined`) replay under whatever identity is current.
3458
- *
3459
- * `Map.get` returns `undefined` for unstamped/hydrated ids and `item.identity`
3460
- * is `undefined` for legacy records; a persisted `null` (queued while signed
3461
- * out) is a real value that must not collapse into `undefined` — hence the
3462
- * explicit `=== undefined` check rather than `??`. Returns `true` when the
3463
- * write may replay; otherwise settles it `OFFLINE_IDENTITY_CHANGED` and returns
3464
- * `false`. Either way the live stamp is consumed.
3465
- */
3466
- passesReplayIdentityGate(item, currentIdentity) {
3467
- const liveStamp = item.id === void 0 ? void 0 : this.queuedIdentities.get(item.id);
3468
- const stamped = liveStamp === void 0 ? item.identity : liveStamp;
3469
- if (stamped !== void 0 && stamped !== currentIdentity && !this.isSameCredentialUnderTokenHash(stamped)) {
3470
- this.queuedIdentities.delete(item.id ?? "");
3471
- this.unpersist(item.id);
3472
- const error = new Error("offline mutation skipped: auth identity changed before replay");
3473
- error.code = "OFFLINE_IDENTITY_CHANGED";
3474
- item.reject(error);
3475
- this.emitItemSettled(item, "rejected", error);
3476
- return false;
3477
- }
3478
- this.queuedIdentities.delete(item.id ?? "");
3479
- return true;
3480
- }
3481
- /** 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. */
3482
- settleReplaySuccess(item, value, commitCursor) {
3483
- this.unpersist(item.id);
3484
- item.onCommit?.(commitCursor);
3485
- item.resolve(value);
3486
- this.emitItemSettled(item, "committed");
3487
- }
3488
- /** Settle a write the server reached a coded verdict on: replaying would re-trigger the same failure (a poison-message loop), so drop it. */
3489
- settleReplayTerminal(item, error) {
3490
- this.unpersist(item.id);
3491
- item.reject(error);
3492
- this.emitItemSettled(item, "rejected", error);
3493
- }
3494
- /**
3495
- * Replay already-identity-gated writes one at a time on the single-call `/rpc`
3496
- * path, preserving FIFO order (parallel `.then()` chains would race the
3497
- * ordering callers depend on). Each replays under its stable `mutationId` so
3498
- * the server dedups a write it already committed (exactly-once). A coded error
3499
- * is a server verdict (drop it); a codeless (transport/transient) failure stops
3500
- * the flush and re-queues this write and every unreplayed one for the next
3501
- * reconnect — their callers stay pending, and the identity guard re-applies on
3502
- * retry via each record's persisted stamp.
3503
- */
3504
- async replaySequential(items) {
3505
- for (let index = 0; index < items.length; index += 1) {
3506
- const item = items[index];
3507
- if (!item) {
3508
- continue;
3509
- }
3510
- try {
3511
- let commitCursor;
3512
- const value = await this.rpc(item.functionPath, item.args, item.shardKey, {
3513
- captureBookmark: true,
3514
- mutationId: item.id,
3515
- onCommitCursor: (cursor) => {
3516
- commitCursor = cursor;
3517
- }
3518
- });
3519
- this.settleReplaySuccess(item, value, commitCursor);
3520
- } catch (error) {
3521
- if (error.code !== void 0) {
3522
- this.settleReplayTerminal(item, error);
3523
- continue;
3524
- }
3525
- this.offlineQueue.requeue(items.slice(index));
3526
- return;
3527
- }
3528
- }
3529
- }
3530
- /**
3531
- * Coalesce already-identity-gated writes for a single shard into ONE
3532
- * `/_lunora/rpc-batch` round trip (plan 088 follow-on). The worker forwards
3533
- * them to the shard DO, which replays each through its single-call dispatch, so
3534
- * per-entry `mutationId` idempotency and in-order application are inherited from
3535
- * the proven path. Per-slot demux mirrors {@link replaySequential}'s
3536
- * classification: success confirms the optimistic layer against the echoed
3537
- * `commitCursor`; a coded application verdict is terminal; a transient shard
3538
- * failure (`SHARD_UNAVAILABLE`/`SHARD_ERROR`), a missing slot, or a whole-batch
3539
- * transport failure re-queues for the next reconnect (never dropping a durable
3540
- * write). A whole-batch coded rejection (bad request / authorization denial the
3541
- * server reached a verdict on) is terminal for every entry.
3542
- *
3543
- * Returns the writes that must be re-queued and `stop` — `true` when the whole
3544
- * chunk failed at the transport level, so the caller leaves later chunks queued
3545
- * rather than sending on. The caller re-queues once, in order, so requeuing is
3546
- * NOT done here.
3547
- */
3548
- async replayBatched(items) {
3549
- if (!this.fetchImpl) {
3550
- return { requeue: items, stop: true };
3551
- }
3552
- let response;
3553
- try {
3554
- response = await this.fetchImpl(joinUrl(this.url, RPC_BATCH_PATH), {
3555
- body: JSON.stringify({
3556
- calls: items.map((item, index) => {
3557
- return {
3558
- args: encodeCallArgs(item.args, `args for '${item.functionPath}'`),
3559
- functionPath: item.functionPath,
3560
- id: index,
3561
- // Stable per-write key so the DO dedups a write it already
3562
- // committed (exactly-once), exactly as the single-call replay.
3563
- mutationId: item.id,
3564
- shardKey: item.shardKey
3565
- };
3566
- })
3567
- }),
3568
- headers: this.rpcRequestHeaders({ attachBookmark: true }),
3569
- method: "POST"
3570
- });
3571
- } catch {
3572
- return { requeue: items, stop: true };
3573
- }
3574
- const bookmark = response.headers.get("x-d1-bookmark");
3575
- if (bookmark) {
3576
- this.bookmark.set(bookmark);
3577
- }
3578
- let body;
3579
- try {
3580
- body = await response.json();
3581
- } catch {
3582
- return { requeue: items, stop: true };
3583
- }
3584
- if (!body.results) {
3585
- if (body.error) {
3586
- const error = reconstructError(body.error);
3587
- for (const item of items) {
3588
- this.settleReplayTerminal(item, error);
3589
- }
3590
- return { requeue: [], stop: false };
3591
- }
3592
- return { requeue: items, stop: true };
3593
- }
3594
- return { requeue: this.settleReplayBatchSlots(items, body.results), stop: false };
3595
- }
3596
- /**
3597
- * Demux a `/_lunora/rpc-batch` reply back onto the queued writes it replayed,
3598
- * in input order. Each slot's envelope classifies its write the same way
3599
- * {@link replaySequential} does: a success confirms the optimistic layer
3600
- * against the echoed `commitCursor`; a coded application verdict is terminal;
3601
- * a transient shard failure ({@link TRANSIENT_BATCH_ERROR_CODES}) or a slot the
3602
- * server never returned is returned for the caller to re-queue.
3603
- * @returns the writes that must be re-queued (transient slots), in input order
3604
- */
3605
- settleReplayBatchSlots(items, results) {
3606
- const bySlot = /* @__PURE__ */ new Map();
3607
- for (const entry of results) {
3608
- if (typeof entry.id === "number" && entry.body !== void 0) {
3609
- bySlot.set(entry.id, entry.body);
3610
- }
3611
- }
3612
- const requeue = [];
3613
- for (const [index, item] of items.entries()) {
3614
- const inner = bySlot.get(index);
3615
- if (inner === void 0) {
3616
- requeue.push(item);
3617
- } else if ("error" in inner) {
3618
- if (TRANSIENT_BATCH_ERROR_CODES.has(inner.error.code)) {
3619
- requeue.push(item);
3620
- } else {
3621
- this.settleReplayTerminal(item, reconstructError(inner.error));
3622
- }
3623
- } else {
3624
- this.settleReplaySuccess(item, decodeWire(inner.result), inner.commitCursor);
3625
- }
3626
- }
3627
- return requeue;
3628
- }
3629
- }
3630
-
3631
- export { LunoraClient };