@lunora/client 0.0.0 → 1.0.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +105 -0
- package/README.md +111 -9
- package/__assets__/package-og.svg +14 -0
- package/dist/auth/index.d.mts +20 -0
- package/dist/auth/index.d.ts +20 -0
- package/dist/auth/index.mjs +60 -0
- package/dist/index.d.mts +281 -0
- package/dist/index.d.ts +281 -0
- package/dist/index.mjs +14 -0
- package/dist/packem_shared/CONFLICT_ERROR_CODE-aUdVbEDw.mjs +4 -0
- package/dist/packem_shared/DEFAULT_MAX_BUFFER-BDkqO5PW.mjs +107 -0
- package/dist/packem_shared/LunoraClient-UiULzH_1.mjs +2165 -0
- package/dist/packem_shared/OfflineQueue-D5p_QgF_.mjs +127 -0
- package/dist/packem_shared/SKIP-vItZChkw.mjs +50 -0
- package/dist/packem_shared/SubscriptionRegistry-B-Qx_Gux.mjs +26 -0
- package/dist/packem_shared/applyDelta-4jFGTPA3.mjs +61 -0
- package/dist/packem_shared/createAsyncStoragePersistence-1Z5BZ8RC.mjs +45 -0
- package/dist/packem_shared/createInMemoryBookmarkStorage-BoN7a7TH.mjs +11 -0
- package/dist/packem_shared/createInMemoryPersistence-CW82inU5.mjs +105 -0
- package/dist/packem_shared/createInMemoryQueryCache-B1PQ9Twl.mjs +138 -0
- package/dist/packem_shared/createLocalStore-DSUfoLqY.mjs +36 -0
- package/dist/packem_shared/createMutationRunner-BqsavzvG.mjs +21 -0
- package/dist/packem_shared/createReconnect-Di_-oHH7.mjs +22 -0
- package/dist/packem_shared/createServerClient-BjZc3gD8.mjs +11 -0
- package/dist/packem_shared/deserializePreloaded-C0eJTY_W.mjs +4 -0
- package/dist/packem_shared/getServerSession-8jXewqxd.mjs +13 -0
- package/dist/packem_shared/lunora-client.d-DGvyuJ_p.d.mts +1597 -0
- package/dist/packem_shared/lunora-client.d-DGvyuJ_p.d.ts +1597 -0
- package/dist/packem_shared/preload.d-BoDmFqSG.d.ts +20 -0
- package/dist/packem_shared/preload.d-dSaRMuhL.d.mts +20 -0
- package/dist/packem_shared/preloadQuery-lobFkD2Z.mjs +13 -0
- package/dist/pagination/index.d.mts +82 -0
- package/dist/pagination/index.d.ts +82 -0
- package/dist/pagination/index.mjs +61 -0
- package/dist/query/index.d.mts +62 -0
- package/dist/query/index.d.ts +62 -0
- package/dist/query/index.mjs +1 -0
- package/dist/ssr/index.d.mts +115 -0
- package/dist/ssr/index.d.ts +115 -0
- package/dist/ssr/index.mjs +4 -0
- package/package.json +53 -17
|
@@ -0,0 +1,2165 @@
|
|
|
1
|
+
import createInMemoryBookmarkStorage from './createInMemoryBookmarkStorage-BoN7a7TH.mjs';
|
|
2
|
+
import { isMutationDelta, applyDelta } from './applyDelta-4jFGTPA3.mjs';
|
|
3
|
+
import { createLocalStore } from './createLocalStore-DSUfoLqY.mjs';
|
|
4
|
+
import { OfflineQueue, nextId, reportPersistenceError } from './OfflineQueue-D5p_QgF_.mjs';
|
|
5
|
+
import { queryCacheKey } from './createInMemoryQueryCache-B1PQ9Twl.mjs';
|
|
6
|
+
import { createReconnect } from './createReconnect-Di_-oHH7.mjs';
|
|
7
|
+
import { createStream } from './DEFAULT_MAX_BUFFER-BDkqO5PW.mjs';
|
|
8
|
+
import { SubscriptionRegistry } from './SubscriptionRegistry-B-Qx_Gux.mjs';
|
|
9
|
+
|
|
10
|
+
const RPC_PATH = "/_lunora/rpc";
|
|
11
|
+
const WS_PATH = "/_lunora/ws";
|
|
12
|
+
const bucketQuery = (bucket) => bucket === void 0 || bucket === "" ? "" : `&bucket=${encodeURIComponent(bucket)}`;
|
|
13
|
+
const WS_KEEPALIVE_PING = "lunora-ping";
|
|
14
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 3e4;
|
|
15
|
+
const QUERY_CACHE_DEBOUNCE_MS = 250;
|
|
16
|
+
const MAX_PENDING_STREAMS = 64;
|
|
17
|
+
const SHARD_TRAFFIC_PATH = "/_lunora/admin/shard-traffic";
|
|
18
|
+
const SCHEDULED_PATH = "/_lunora/admin/scheduled";
|
|
19
|
+
const SCHEDULED_STATUS_PATH = "/_lunora/admin/scheduled/status";
|
|
20
|
+
const SCHEDULED_WS_PATH = "/_lunora/admin/scheduled/ws";
|
|
21
|
+
const SCHEDULED_CANCEL_PATH = "/_lunora/admin/scheduled/cancel";
|
|
22
|
+
const SCHEDULED_DEAD_PATH = "/_lunora/admin/scheduled/dead";
|
|
23
|
+
const SCHEDULED_DEAD_RETRY_PATH = "/_lunora/admin/scheduled/dead/retry";
|
|
24
|
+
const SCHEDULED_DEAD_CANCEL_PATH = "/_lunora/admin/scheduled/dead/cancel";
|
|
25
|
+
const WORKFLOWS_INSTANCES_PATH = "/_lunora/admin/workflows/instances";
|
|
26
|
+
const WORKFLOWS_INSTANCE_PATH = "/_lunora/admin/workflows/instance";
|
|
27
|
+
const WORKFLOWS_STATUS_PATH = "/_lunora/admin/workflows/status";
|
|
28
|
+
const STORAGE_PATH = "/_lunora/admin/storage";
|
|
29
|
+
const STORAGE_URL_PATH = "/_lunora/admin/storage/url";
|
|
30
|
+
const STORAGE_BUCKETS_PATH = "/_lunora/admin/storage/buckets";
|
|
31
|
+
const FUNCTIONS_PATH = "/_lunora/admin/functions";
|
|
32
|
+
const CRON_JOBS_PATH = "/_lunora/admin/cron-jobs";
|
|
33
|
+
const CRON_JOBS_RUN_PATH = "/_lunora/admin/cron-jobs/run";
|
|
34
|
+
const OPENAPI_PATH = "/_lunora/admin/openapi";
|
|
35
|
+
const OPENRPC_PATH = "/_lunora/admin/openrpc";
|
|
36
|
+
const GLOBAL_TABLES_PATH = "/_lunora/admin/global/tables";
|
|
37
|
+
const GLOBAL_TABLE_PATH = "/_lunora/admin/global/table";
|
|
38
|
+
const GLOBAL_FACET_PATH = "/_lunora/admin/global/facet";
|
|
39
|
+
const VECTOR_INDEXES_PATH = "/_lunora/admin/vector/indexes";
|
|
40
|
+
const VECTOR_QUERY_PATH = "/_lunora/admin/vector/query";
|
|
41
|
+
const AUTH_USERS_PATH = "/_lunora/admin/auth/users";
|
|
42
|
+
const AUTH_SESSIONS_PATH = "/_lunora/admin/auth/sessions";
|
|
43
|
+
const AUTH_CREATE_USER_PATH = "/_lunora/admin/auth/users/create";
|
|
44
|
+
const AUTH_SET_ROLE_PATH = "/_lunora/admin/auth/users/role";
|
|
45
|
+
const AUTH_BAN_PATH = "/_lunora/admin/auth/users/ban";
|
|
46
|
+
const AUTH_UNBAN_PATH = "/_lunora/admin/auth/users/unban";
|
|
47
|
+
const AUTH_SET_PASSWORD_PATH = "/_lunora/admin/auth/users/password";
|
|
48
|
+
const AUTH_REMOVE_USER_PATH = "/_lunora/admin/auth/users/remove";
|
|
49
|
+
const AUTH_IMPERSONATE_PATH = "/_lunora/admin/auth/users/impersonate";
|
|
50
|
+
const AUTH_REVOKE_SESSION_PATH = "/_lunora/admin/auth/sessions/revoke";
|
|
51
|
+
const AUTH_REVOKE_SESSIONS_PATH = "/_lunora/admin/auth/sessions/revoke-all";
|
|
52
|
+
const AUTH_CAPABILITIES_PATH = "/_lunora/admin/auth/capabilities";
|
|
53
|
+
const AUTH_UPDATE_USER_PATH = "/_lunora/admin/auth/users/update";
|
|
54
|
+
const AUTH_ACCOUNTS_PATH = "/_lunora/admin/auth/accounts";
|
|
55
|
+
const AUTH_UNLINK_ACCOUNT_PATH = "/_lunora/admin/auth/accounts/unlink";
|
|
56
|
+
const AUTH_PASSKEYS_PATH = "/_lunora/admin/auth/passkeys";
|
|
57
|
+
const AUTH_DELETE_PASSKEY_PATH = "/_lunora/admin/auth/passkeys/delete";
|
|
58
|
+
const AUTH_DISABLE_2FA_PATH = "/_lunora/admin/auth/two-factor/disable";
|
|
59
|
+
const AUTH_ORGS_PATH = "/_lunora/admin/auth/organizations";
|
|
60
|
+
const AUTH_ORG_MEMBERS_PATH = "/_lunora/admin/auth/organizations/members";
|
|
61
|
+
const AUTH_ORG_INVITATIONS_PATH = "/_lunora/admin/auth/organizations/invitations";
|
|
62
|
+
const AUTH_REMOVE_MEMBER_PATH = "/_lunora/admin/auth/organizations/members/remove";
|
|
63
|
+
const AUTH_CANCEL_INVITATION_PATH = "/_lunora/admin/auth/organizations/invitations/cancel";
|
|
64
|
+
const DEFAULT_AUTH_BASE_PATH = "/api/auth";
|
|
65
|
+
const GET_SESSION_PATH = "/get-session";
|
|
66
|
+
const compareEntryKeys = ([a], [b]) => {
|
|
67
|
+
if (a < b) {
|
|
68
|
+
return -1;
|
|
69
|
+
}
|
|
70
|
+
return a > b ? 1 : 0;
|
|
71
|
+
};
|
|
72
|
+
const stableStringify = (value) => {
|
|
73
|
+
if (value === null || typeof value !== "object") {
|
|
74
|
+
return JSON.stringify(value);
|
|
75
|
+
}
|
|
76
|
+
if (Array.isArray(value)) {
|
|
77
|
+
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
|
|
78
|
+
}
|
|
79
|
+
const entries = Object.entries(value).toSorted(compareEntryKeys);
|
|
80
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
|
|
81
|
+
};
|
|
82
|
+
const deriveWsUrl = (url) => {
|
|
83
|
+
if (url.startsWith("https://")) {
|
|
84
|
+
return `wss://${url.slice("https://".length)}`;
|
|
85
|
+
}
|
|
86
|
+
if (url.startsWith("http://")) {
|
|
87
|
+
return `ws://${url.slice("http://".length)}`;
|
|
88
|
+
}
|
|
89
|
+
return url;
|
|
90
|
+
};
|
|
91
|
+
const joinUrl = (base, path) => {
|
|
92
|
+
const trimmed = base.endsWith("/") ? base.slice(0, -1) : base;
|
|
93
|
+
return `${trimmed}${path}`;
|
|
94
|
+
};
|
|
95
|
+
const withQuery = (path, params) => {
|
|
96
|
+
const search = new URLSearchParams();
|
|
97
|
+
for (const [key, value] of Object.entries(params)) {
|
|
98
|
+
if (value !== void 0 && value !== "") {
|
|
99
|
+
search.set(key, String(value));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const query = search.toString();
|
|
103
|
+
return query === "" ? path : `${path}?${query}`;
|
|
104
|
+
};
|
|
105
|
+
const connectionKey = (shardKey) => shardKey ?? "";
|
|
106
|
+
const writeOptimisticToState = (state, next) => {
|
|
107
|
+
const previous = state.lastValue;
|
|
108
|
+
const versionAtApply = state.serverVersion;
|
|
109
|
+
state.lastValue = next;
|
|
110
|
+
for (const callback of state.callbacks) {
|
|
111
|
+
try {
|
|
112
|
+
callback(next);
|
|
113
|
+
} catch {
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return () => {
|
|
117
|
+
if (state.serverVersion > versionAtApply) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (state.lastValue !== next) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
state.lastValue = previous;
|
|
124
|
+
for (const callback of state.callbacks) {
|
|
125
|
+
try {
|
|
126
|
+
callback(previous);
|
|
127
|
+
} catch {
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
const applyOptimisticToState = (state, optimistic) => {
|
|
133
|
+
let next;
|
|
134
|
+
try {
|
|
135
|
+
next = optimistic(state.lastValue);
|
|
136
|
+
} catch {
|
|
137
|
+
return void 0;
|
|
138
|
+
}
|
|
139
|
+
return writeOptimisticToState(state, next);
|
|
140
|
+
};
|
|
141
|
+
const buildStreamError = (message) => {
|
|
142
|
+
const errorEnvelope = message.error;
|
|
143
|
+
const code = typeof errorEnvelope?.code === "string" ? errorEnvelope.code : void 0;
|
|
144
|
+
const nestedMessage = typeof errorEnvelope?.message === "string" ? errorEnvelope.message : void 0;
|
|
145
|
+
const messageText = (typeof message.message === "string" ? message.message : void 0) ?? nestedMessage ?? "stream error";
|
|
146
|
+
return Object.assign(new Error(messageText), code === void 0 ? void 0 : { code });
|
|
147
|
+
};
|
|
148
|
+
const buildSubscriptionError = (message) => {
|
|
149
|
+
const errorEnvelope = message.error;
|
|
150
|
+
const code = typeof errorEnvelope?.code === "string" ? errorEnvelope.code : void 0;
|
|
151
|
+
const nestedMessage = typeof errorEnvelope?.message === "string" ? errorEnvelope.message : void 0;
|
|
152
|
+
const messageText = (typeof message.message === "string" ? message.message : void 0) ?? nestedMessage ?? "subscription error";
|
|
153
|
+
return { message: messageText, ...code === void 0 ? {} : { code } };
|
|
154
|
+
};
|
|
155
|
+
const sharedDecoder = new TextDecoder();
|
|
156
|
+
const decodeServerFrame = (raw) => {
|
|
157
|
+
if (typeof raw === "string") {
|
|
158
|
+
return raw;
|
|
159
|
+
}
|
|
160
|
+
if (raw instanceof ArrayBuffer) {
|
|
161
|
+
return sharedDecoder.decode(raw);
|
|
162
|
+
}
|
|
163
|
+
return void 0;
|
|
164
|
+
};
|
|
165
|
+
const sendOn = (conn, message) => {
|
|
166
|
+
if (!conn.socket || conn.wsState !== "open") {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
conn.socket.send(JSON.stringify(message));
|
|
171
|
+
return true;
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
class LunoraClient {
|
|
177
|
+
url;
|
|
178
|
+
wsUrl;
|
|
179
|
+
wsToken;
|
|
180
|
+
/** Better-auth base path (trailing slash stripped) for the `get-session` lookup. */
|
|
181
|
+
authBasePath;
|
|
182
|
+
fetchImpl;
|
|
183
|
+
WebSocketImpl;
|
|
184
|
+
bookmark;
|
|
185
|
+
reconnectOptions;
|
|
186
|
+
/** Keepalive cadence (ms); `0` disables the heartbeat. See {@link LunoraClientOptions.heartbeatIntervalMs}. */
|
|
187
|
+
heartbeatIntervalMs;
|
|
188
|
+
offlineQueue;
|
|
189
|
+
onPersistenceError;
|
|
190
|
+
persistence;
|
|
191
|
+
/** Durable read cache (Pillar 2); `undefined` when `queryCache` is omitted or `false`. */
|
|
192
|
+
queryCache;
|
|
193
|
+
/**
|
|
194
|
+
* Values restored from the `queryCache` at construction, keyed by the
|
|
195
|
+
* read-cache key, awaiting the `subscribe()` that will consume them. A
|
|
196
|
+
* key is consumed (deleted) the first time its subscription is created, so
|
|
197
|
+
* the cache only ever seeds the initial value — live frames take over after.
|
|
198
|
+
*/
|
|
199
|
+
hydratedQueryCache = /* @__PURE__ */ new Map();
|
|
200
|
+
/**
|
|
201
|
+
* Coalesced read-cache writes: the latest value per key, flushed to
|
|
202
|
+
* the `queryCache` on a short debounce so a burst of deltas persists once.
|
|
203
|
+
*/
|
|
204
|
+
pendingCacheWrites = /* @__PURE__ */ new Map();
|
|
205
|
+
cacheFlushTimer;
|
|
206
|
+
subscriptions = new SubscriptionRegistry();
|
|
207
|
+
/** One {@link ShardConnection} per shard key (keyed by `shardKey ?? ""`). */
|
|
208
|
+
connections = /* @__PURE__ */ new Map();
|
|
209
|
+
/** Default `connect`-envelope context applied to a shard with no explicit override. */
|
|
210
|
+
defaultConnectionContext;
|
|
211
|
+
/**
|
|
212
|
+
* Per-shard `connect`-envelope context registered via `setConnectionContext`
|
|
213
|
+
* (keyed by `shardKey ?? ""`), overriding `defaultConnectionContext`. Sent
|
|
214
|
+
* on every socket open so it replays across reconnects, and forwarded to the
|
|
215
|
+
* server's `onConnect`/`onDisconnect` lifecycle hooks. This holds only the
|
|
216
|
+
* imperative (last-writer-wins) override; refcounted holders registered via
|
|
217
|
+
* `acquireConnectionContext` live in `connectionContextHolders` and take
|
|
218
|
+
* precedence — see `effectiveConnectionContext`.
|
|
219
|
+
*/
|
|
220
|
+
connectionContexts = /* @__PURE__ */ new Map();
|
|
221
|
+
/**
|
|
222
|
+
* Per-shard stack of refcounted connection-context holders (keyed by
|
|
223
|
+
* `shardKey ?? ""`), registered via `acquireConnectionContext`. Each holder
|
|
224
|
+
* is an opaque token carrying its `context`; the most-recently acquired
|
|
225
|
+
* holder wins (last-writer-wins among live holders), and the context is only
|
|
226
|
+
* cleared for a shard once its last holder releases — so two concurrently
|
|
227
|
+
* mounted presence hooks on the same shard can't stomp each other's context
|
|
228
|
+
* on cleanup. A holder is identified by reference identity so a release
|
|
229
|
+
* removes exactly the right one regardless of stack position.
|
|
230
|
+
*/
|
|
231
|
+
connectionContextHolders = /* @__PURE__ */ new Map();
|
|
232
|
+
// `null` is the public sentinel for "signed out" across getAuthToken /
|
|
233
|
+
// setAuthToken / onAuthTokenChange — part of the exported API contract.
|
|
234
|
+
// eslint-disable-next-line unicorn/no-null -- public auth-token contract sentinel
|
|
235
|
+
authToken = null;
|
|
236
|
+
/**
|
|
237
|
+
* Identity stamp recorded against each queued offline mutation, keyed by
|
|
238
|
+
* the queue-assigned mutation id. Captured at enqueue from the auth token
|
|
239
|
+
* in effect at the time, and re-checked at flush so a queued write can
|
|
240
|
+
* never replay under a different identity than the one that issued it.
|
|
241
|
+
* See `identityFingerprint` for the fingerprint shape.
|
|
242
|
+
*/
|
|
243
|
+
queuedIdentities = /* @__PURE__ */ new Map();
|
|
244
|
+
closed = false;
|
|
245
|
+
/** Subscribers to auth-token changes (see `onAuthTokenChange`). */
|
|
246
|
+
authTokenListeners = /* @__PURE__ */ new Set();
|
|
247
|
+
/** Subscribers to aggregate connection-status changes (see `onConnectionStatus`). */
|
|
248
|
+
statusListeners = /* @__PURE__ */ new Set();
|
|
249
|
+
/** Subscribers notified when the server drops a socket for an expired token (see `onTokenExpired`). */
|
|
250
|
+
tokenExpiredListeners = /* @__PURE__ */ new Set();
|
|
251
|
+
/**
|
|
252
|
+
* Whisper-topic handlers, keyed by `connectionKey(shardKey)` → topic → set
|
|
253
|
+
* of callbacks. Membership doubles as the resubscribe set replayed on every
|
|
254
|
+
* (re)connect so a topic survives a socket bounce.
|
|
255
|
+
*/
|
|
256
|
+
whisperHandlers = /* @__PURE__ */ new Map();
|
|
257
|
+
/** Last status broadcast, so we only notify listeners on an actual change. */
|
|
258
|
+
lastStatus = "idle";
|
|
259
|
+
nextSubId = 0;
|
|
260
|
+
nextStreamId = 0;
|
|
261
|
+
/**
|
|
262
|
+
* In-flight client-side stream readers, keyed by the stream id sent on the
|
|
263
|
+
* wire. The handle drives the underlying iterator queue and `shardKey`
|
|
264
|
+
* tells us which socket to push the cancel frame onto when the consumer
|
|
265
|
+
* calls `.cancel()` or the iterator is garbage-collected.
|
|
266
|
+
*/
|
|
267
|
+
streams = /* @__PURE__ */ new Map();
|
|
268
|
+
constructor(options) {
|
|
269
|
+
this.url = options.url;
|
|
270
|
+
this.wsUrl = options.wsUrl ?? joinUrl(deriveWsUrl(options.url), WS_PATH);
|
|
271
|
+
this.wsToken = options.wsToken;
|
|
272
|
+
const authBase = options.authBasePath ?? DEFAULT_AUTH_BASE_PATH;
|
|
273
|
+
this.authBasePath = authBase.endsWith("/") ? authBase.slice(0, -1) : authBase;
|
|
274
|
+
this.fetchImpl = options.fetch ?? (typeof fetch === "function" ? fetch.bind(globalThis) : void 0);
|
|
275
|
+
this.WebSocketImpl = options.WebSocket ?? (typeof WebSocket === "function" ? WebSocket : void 0);
|
|
276
|
+
this.bookmark = options.bookmarkStorage ?? createInMemoryBookmarkStorage();
|
|
277
|
+
this.reconnectOptions = options.reconnect;
|
|
278
|
+
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
279
|
+
this.defaultConnectionContext = options.connectionContext;
|
|
280
|
+
this.persistence = options.persistence;
|
|
281
|
+
this.queryCache = options.queryCache === false ? void 0 : options.queryCache;
|
|
282
|
+
this.onPersistenceError = options.offlineQueue?.onPersistenceError;
|
|
283
|
+
this.offlineQueue = new OfflineQueue(options.offlineQueue, options.persistence);
|
|
284
|
+
if (this.persistence) {
|
|
285
|
+
queueMicrotask(() => {
|
|
286
|
+
this.hydratePersistedQueue().catch(() => void 0);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
if (this.queryCache) {
|
|
290
|
+
queueMicrotask(() => {
|
|
291
|
+
this.hydrateQueryCache().catch(() => void 0);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// --- Auth helpers -------------------------------------------------------
|
|
296
|
+
/**
|
|
297
|
+
* Set (or clear) the bearer token sent on every HTTP RPC. Notifies any
|
|
298
|
+
* {@link onAuthTokenChange} listeners so React hooks like `useAuth` stay in
|
|
299
|
+
* sync across all mounted instances.
|
|
300
|
+
*
|
|
301
|
+
* Does NOT update the WebSocket auth — the WS token is fixed at upgrade
|
|
302
|
+
* time and lives in the URL. To refresh live WS auth, call
|
|
303
|
+
* {@link setWsToken} explicitly, which closes existing shard sockets to
|
|
304
|
+
* force a reconnect with the new credential.
|
|
305
|
+
*/
|
|
306
|
+
setAuthToken(token) {
|
|
307
|
+
if (this.authToken === token) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
this.authToken = token;
|
|
311
|
+
this.rejectQueuedForIdentityChange();
|
|
312
|
+
for (const listener of this.authTokenListeners) {
|
|
313
|
+
try {
|
|
314
|
+
listener(token);
|
|
315
|
+
} catch {
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
getAuthToken() {
|
|
320
|
+
return this.authToken;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Subscribe to auth-token changes. Returns an unsubscribe function. The
|
|
324
|
+
* listener is NOT invoked on registration — use {@link getAuthToken} for
|
|
325
|
+
* the current value.
|
|
326
|
+
*/
|
|
327
|
+
onAuthTokenChange(listener) {
|
|
328
|
+
this.authTokenListeners.add(listener);
|
|
329
|
+
return () => {
|
|
330
|
+
this.authTokenListeners.delete(listener);
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Fetch the currently authenticated user from better-auth's `get-session`
|
|
335
|
+
* endpoint, returning the `user` record or `null` when signed out. Sends
|
|
336
|
+
* the stored bearer token (if any) and `credentials: "include"` so a
|
|
337
|
+
* cookie-session is also honoured. A network/parse failure or a non-OK
|
|
338
|
+
* response resolves to `null` rather than throwing — callers treat "couldn't
|
|
339
|
+
* resolve identity" as "signed out".
|
|
340
|
+
*
|
|
341
|
+
* Framework-agnostic: pair it with {@link onAuthTokenChange} to refetch when
|
|
342
|
+
* the token changes (that's what `@lunora/react`'s `useAuth` does).
|
|
343
|
+
*/
|
|
344
|
+
async getCurrentUser() {
|
|
345
|
+
if (this.closed || !this.fetchImpl) {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
const headers = {};
|
|
349
|
+
if (this.authToken) {
|
|
350
|
+
headers["authorization"] = `Bearer ${this.authToken}`;
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
const response = await this.fetchImpl(joinUrl(this.url, `${this.authBasePath}${GET_SESSION_PATH}`), {
|
|
354
|
+
credentials: "include",
|
|
355
|
+
headers,
|
|
356
|
+
method: "GET"
|
|
357
|
+
});
|
|
358
|
+
if (!response.ok) {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
const body = await response.json();
|
|
362
|
+
return body?.user ?? null;
|
|
363
|
+
} catch {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Replace the token appended to WS upgrade URLs as `?token=…` and close
|
|
369
|
+
* every open shard socket so the reconnect picks up the new value. Call
|
|
370
|
+
* this whenever the user's WS credential changes (rotating the admin token
|
|
371
|
+
* in the studio, switching workspaces, etc.). Bearer tokens for HTTP
|
|
372
|
+
* RPC are independent — see {@link setAuthToken}.
|
|
373
|
+
*/
|
|
374
|
+
setWsToken(token) {
|
|
375
|
+
if (this.wsToken === token) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
this.wsToken = token;
|
|
379
|
+
for (const conn of this.connections.values()) {
|
|
380
|
+
if (conn.socket) {
|
|
381
|
+
try {
|
|
382
|
+
conn.socket.close();
|
|
383
|
+
} catch {
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Register (or clear, with `undefined`) the app context sent in the `connect`
|
|
390
|
+
* envelope for a shard's socket, overriding the client-wide
|
|
391
|
+
* {@link LunoraClientOptions.connectionContext}. The server forwards it to the
|
|
392
|
+
* `onConnect`/`onDisconnect` lifecycle hooks as `event.context` — e.g.
|
|
393
|
+
* `@lunora/react`'s `usePresence` registers `{ roomId, sessionId }` so the
|
|
394
|
+
* presence row is removed the instant the socket drops, with no TTL lag.
|
|
395
|
+
*
|
|
396
|
+
* Stored per shard and replayed on every (re)connect. When a socket for the
|
|
397
|
+
* shard is already open, a fresh `connect` envelope is sent immediately so the
|
|
398
|
+
* server sees the new context without waiting for a reconnect.
|
|
399
|
+
*/
|
|
400
|
+
setConnectionContext(context, options = {}) {
|
|
401
|
+
const key = connectionKey(options.shardKey);
|
|
402
|
+
if (context === void 0) {
|
|
403
|
+
this.connectionContexts.delete(key);
|
|
404
|
+
} else {
|
|
405
|
+
this.connectionContexts.set(key, context);
|
|
406
|
+
}
|
|
407
|
+
this.refreshConnectionContext(key);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Refcounted variant of {@link setConnectionContext}: register a connection
|
|
411
|
+
* `context` for a shard and get back a release function. Unlike the imperative
|
|
412
|
+
* setter, the context is only cleared once the *last* acquired holder releases
|
|
413
|
+
* it — so two components (e.g. two mounted `usePresence` hooks) on the same
|
|
414
|
+
* shard no longer clobber each other's context when one of them unmounts. The
|
|
415
|
+
* most-recently acquired live holder wins (last-writer-wins), and releasing
|
|
416
|
+
* the top holder falls back to the previous one rather than clearing.
|
|
417
|
+
*
|
|
418
|
+
* With a single holder the behaviour is identical to a
|
|
419
|
+
* `setConnectionContext(context)` / `setConnectionContext(undefined)` pair.
|
|
420
|
+
* Releasing more than once is a no-op (the holder is matched by reference, so
|
|
421
|
+
* a double release can't drop a different holder).
|
|
422
|
+
*/
|
|
423
|
+
acquireConnectionContext(context, options = {}) {
|
|
424
|
+
const key = connectionKey(options.shardKey);
|
|
425
|
+
const holder = { context };
|
|
426
|
+
const holders = this.connectionContextHolders.get(key);
|
|
427
|
+
if (holders) {
|
|
428
|
+
holders.push(holder);
|
|
429
|
+
} else {
|
|
430
|
+
this.connectionContextHolders.set(key, [holder]);
|
|
431
|
+
}
|
|
432
|
+
this.refreshConnectionContext(key);
|
|
433
|
+
let released = false;
|
|
434
|
+
return () => {
|
|
435
|
+
if (released) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
released = true;
|
|
439
|
+
const live = this.connectionContextHolders.get(key);
|
|
440
|
+
if (!live) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const index = live.indexOf(holder);
|
|
444
|
+
if (index !== -1) {
|
|
445
|
+
live.splice(index, 1);
|
|
446
|
+
}
|
|
447
|
+
if (live.length === 0) {
|
|
448
|
+
this.connectionContextHolders.delete(key);
|
|
449
|
+
}
|
|
450
|
+
this.refreshConnectionContext(key);
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
// --- Whispering ---------------------------------------------------------
|
|
454
|
+
/**
|
|
455
|
+
* Join a whisper `topic` and receive every ephemeral message other members
|
|
456
|
+
* broadcast to it on the same shard (typing indicators, live cursors,
|
|
457
|
+
* presence pings). Whispers never touch the server's durable state — there's
|
|
458
|
+
* no query, no row, no CDC entry. Returns an unsubscribe function; the topic
|
|
459
|
+
* is left on the server once its last local handler unsubscribes.
|
|
460
|
+
*
|
|
461
|
+
* `handler` receives the raw `data` and the sender's verified `from` user id
|
|
462
|
+
* (omitted for an anonymous sender). The topic is scoped to `options.shardKey`
|
|
463
|
+
* (the default shard when omitted) — use the same shard you target with the
|
|
464
|
+
* matching queries/mutations so members land on the same Durable Object.
|
|
465
|
+
*
|
|
466
|
+
* Security: whisper topics are NOT access-controlled beyond the shard
|
|
467
|
+
* boundary — any client that can open a socket to the shard can join, read,
|
|
468
|
+
* and inject on any topic name. `from` is server-stamped and unforgeable, but
|
|
469
|
+
* do not put data on a whisper topic that some shard members shouldn't see,
|
|
470
|
+
* and don't trust a whisper's `data` as authorization. Use a query/mutation
|
|
471
|
+
* (with RLS) for anything privileged; whispers are for transient awareness.
|
|
472
|
+
*/
|
|
473
|
+
whisperSubscribe(topic, handler, options = {}) {
|
|
474
|
+
const key = connectionKey(options.shardKey);
|
|
475
|
+
let byTopic = this.whisperHandlers.get(key);
|
|
476
|
+
if (!byTopic) {
|
|
477
|
+
byTopic = /* @__PURE__ */ new Map();
|
|
478
|
+
this.whisperHandlers.set(key, byTopic);
|
|
479
|
+
}
|
|
480
|
+
let handlers = byTopic.get(topic);
|
|
481
|
+
const first = handlers === void 0;
|
|
482
|
+
if (!handlers) {
|
|
483
|
+
handlers = /* @__PURE__ */ new Set();
|
|
484
|
+
byTopic.set(topic, handlers);
|
|
485
|
+
}
|
|
486
|
+
handlers.add(handler);
|
|
487
|
+
this.ensureSocket(options.shardKey);
|
|
488
|
+
if (first) {
|
|
489
|
+
const conn = this.getConnection(options.shardKey);
|
|
490
|
+
if (conn) {
|
|
491
|
+
sendOn(conn, { topic, type: "whisper_subscribe" });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return () => {
|
|
495
|
+
const stillByTopic = this.whisperHandlers.get(key);
|
|
496
|
+
const stillHandlers = stillByTopic?.get(topic);
|
|
497
|
+
if (!stillHandlers?.delete(handler) || stillHandlers.size > 0) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
stillByTopic?.delete(topic);
|
|
501
|
+
if (stillByTopic?.size === 0) {
|
|
502
|
+
this.whisperHandlers.delete(key);
|
|
503
|
+
}
|
|
504
|
+
const conn = this.getConnection(options.shardKey);
|
|
505
|
+
if (conn) {
|
|
506
|
+
sendOn(conn, { topic, type: "whisper_unsubscribe" });
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Broadcast an ephemeral `data` payload to the other members of a whisper
|
|
512
|
+
* `topic` on `options.shardKey`'s shard. Fire-and-forget: the frame is
|
|
513
|
+
* dropped when the shard socket isn't open (whispers are transient, never
|
|
514
|
+
* queued), and the server silently drops it if the sender exceeds its
|
|
515
|
+
* whisper rate budget. The sender never receives its own whisper. Omitting
|
|
516
|
+
* `data` delivers JSON `null` to receivers (not `undefined`).
|
|
517
|
+
*/
|
|
518
|
+
whisper(topic, data, options = {}) {
|
|
519
|
+
this.ensureSocket(options.shardKey);
|
|
520
|
+
const conn = this.getConnection(options.shardKey);
|
|
521
|
+
if (conn) {
|
|
522
|
+
sendOn(conn, { data, topic, type: "whisper" });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Subscribe to token-expiry events: invoked whenever the server drops a
|
|
527
|
+
* shard socket because the connection's credential lapsed (close code
|
|
528
|
+
* `4001`). The client already reconnects automatically (re-resolving
|
|
529
|
+
* identity from the cookie/token in effect); use this to refresh a
|
|
530
|
+
* short-lived token first — e.g. call {@link setWsToken} / {@link setAuthToken}
|
|
531
|
+
* with a freshly minted one. Returns an unsubscribe function.
|
|
532
|
+
*/
|
|
533
|
+
onTokenExpired(listener) {
|
|
534
|
+
this.tokenExpiredListeners.add(listener);
|
|
535
|
+
return () => {
|
|
536
|
+
this.tokenExpiredListeners.delete(listener);
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
// --- Connection status --------------------------------------------------
|
|
540
|
+
/**
|
|
541
|
+
* Current aggregate live-socket status across all shard connections. See
|
|
542
|
+
* {@link ConnectionStatus}.
|
|
543
|
+
*/
|
|
544
|
+
connectionStatus() {
|
|
545
|
+
return this.computeStatus();
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Subscribe to aggregate connection-status changes. Invokes `listener`
|
|
549
|
+
* immediately with the current status, then on every transition. Returns an
|
|
550
|
+
* unsubscribe function.
|
|
551
|
+
*/
|
|
552
|
+
onConnectionStatus(listener) {
|
|
553
|
+
this.statusListeners.add(listener);
|
|
554
|
+
listener(this.computeStatus());
|
|
555
|
+
return () => {
|
|
556
|
+
this.statusListeners.delete(listener);
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
// --- RPC ---------------------------------------------------------------
|
|
560
|
+
async query(function_, args, options = {}) {
|
|
561
|
+
if (this.closed) {
|
|
562
|
+
throw new Error("LunoraClient is closed");
|
|
563
|
+
}
|
|
564
|
+
return await this.rpc(function_.__lunoraRef, args, options.shardKey, { attachBookmark: true });
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Invoke a mutation. Errors propagate as rejections.
|
|
568
|
+
*
|
|
569
|
+
* Offline-queue semantics: a mutation is queued (and replayed on reconnect)
|
|
570
|
+
* only when the targeted shard's socket was open at least once already
|
|
571
|
+
* (`wasEverConnected`), so the registry / resubscribe handshake has run.
|
|
572
|
+
* Mutations issued before the very first WS connect to a shard fail fast.
|
|
573
|
+
* Opt into queueing-before-first-connect via
|
|
574
|
+
* `OfflineQueueOptions.queueBeforeFirstConnect`.
|
|
575
|
+
*/
|
|
576
|
+
async mutation(function_, args, options = {}) {
|
|
577
|
+
if (this.closed) {
|
|
578
|
+
throw new Error("LunoraClient is closed");
|
|
579
|
+
}
|
|
580
|
+
const argsRecord = args;
|
|
581
|
+
const mutationId = nextId();
|
|
582
|
+
const optimisticRollbacks = this.applyOptimisticUpdates(function_.__lunoraRef, argsRecord, options.shardKey, options.optimistic);
|
|
583
|
+
if (options.optimisticUpdate) {
|
|
584
|
+
this.applyOptimisticUpdate(options.optimisticUpdate, args, options.shardKey, optimisticRollbacks);
|
|
585
|
+
}
|
|
586
|
+
const conn = this.getConnection(options.shardKey);
|
|
587
|
+
const wsState = conn?.wsState ?? "idle";
|
|
588
|
+
const hasSocket = conn?.socket !== void 0;
|
|
589
|
+
const wasEverConnected = conn?.wasEverConnected ?? false;
|
|
590
|
+
const { queueBeforeFirstConnect } = this.offlineQueue;
|
|
591
|
+
const connectedGate = wasEverConnected || queueBeforeFirstConnect;
|
|
592
|
+
const shouldQueueOffline = this.WebSocketImpl !== void 0 && connectedGate;
|
|
593
|
+
const midReconnect = wsState === "connecting" && connectedGate;
|
|
594
|
+
if (wsState !== "open" && !hasSocket && shouldQueueOffline || midReconnect) {
|
|
595
|
+
const issuingIdentity = this.identityFingerprint();
|
|
596
|
+
return new Promise((resolve, reject) => {
|
|
597
|
+
const entry = {
|
|
598
|
+
args: argsRecord,
|
|
599
|
+
functionPath: function_.__lunoraRef,
|
|
600
|
+
// Reuse the call's idempotency key as the queue id so the
|
|
601
|
+
// replay carries the same `x-lunora-mutation-id` the server
|
|
602
|
+
// dedups on.
|
|
603
|
+
id: mutationId,
|
|
604
|
+
// Persist the stamp alongside the record so a hydrated write
|
|
605
|
+
// can only replay under the identity that queued it.
|
|
606
|
+
identity: issuingIdentity,
|
|
607
|
+
reject: (error) => {
|
|
608
|
+
this.queuedIdentities.delete(mutationId);
|
|
609
|
+
for (let index = optimisticRollbacks.length - 1; index >= 0; index -= 1) {
|
|
610
|
+
optimisticRollbacks[index]?.();
|
|
611
|
+
}
|
|
612
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
613
|
+
},
|
|
614
|
+
resolve,
|
|
615
|
+
shardKey: options.shardKey
|
|
616
|
+
};
|
|
617
|
+
this.offlineQueue.enqueue(entry);
|
|
618
|
+
if (entry.id !== void 0) {
|
|
619
|
+
this.queuedIdentities.set(entry.id, issuingIdentity);
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
try {
|
|
624
|
+
return await this.rpc(function_.__lunoraRef, argsRecord, options.shardKey, { captureBookmark: true, mutationId });
|
|
625
|
+
} catch (error) {
|
|
626
|
+
for (let index = optimisticRollbacks.length - 1; index >= 0; index -= 1) {
|
|
627
|
+
optimisticRollbacks[index]?.();
|
|
628
|
+
}
|
|
629
|
+
throw error;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
async action(function_, args, options = {}) {
|
|
633
|
+
if (this.closed) {
|
|
634
|
+
throw new Error("LunoraClient is closed");
|
|
635
|
+
}
|
|
636
|
+
return await this.rpc(function_.__lunoraRef, args, options.shardKey);
|
|
637
|
+
}
|
|
638
|
+
// --- Advisor admin ------------------------------------------------------
|
|
639
|
+
/**
|
|
640
|
+
* Read the cross-shard request distribution for a `.shardBy(...)` table —
|
|
641
|
+
* the feed the studio's `hot_shard` advisor lint consumes. Hits the
|
|
642
|
+
* admin-gated `POST /_lunora/admin/shard-traffic` endpoint, which fans the
|
|
643
|
+
* cheap per-shard `getMetrics` read out across every live shard and returns
|
|
644
|
+
* each shard's `{ shardKey, requests }` total (a failed shard surfaces with
|
|
645
|
+
* `requests: 0`). Requires the worker to be built with a `queryCoordinator`
|
|
646
|
+
* and `adminToken`, and this client's auth token to match; defaults any
|
|
647
|
+
* absent field so an older worker yields an empty-but-valid shape.
|
|
648
|
+
*/
|
|
649
|
+
async shardTraffic(table) {
|
|
650
|
+
if (this.closed) {
|
|
651
|
+
throw new Error("LunoraClient is closed");
|
|
652
|
+
}
|
|
653
|
+
const body = await this.adminFetch(SHARD_TRAFFIC_PATH, "POST", { table });
|
|
654
|
+
return { failed: body.failed ?? 0, ok: body.ok ?? 0, shards: body.shards ?? [] };
|
|
655
|
+
}
|
|
656
|
+
// --- Scheduler admin ----------------------------------------------------
|
|
657
|
+
/**
|
|
658
|
+
* List the functions queued via `runAfter` / `runAt`, soonest-due last
|
|
659
|
+
* (the worker returns them in storage order). Hits the admin-gated
|
|
660
|
+
* `/_lunora/admin/scheduled` endpoint, so the worker must be built with a
|
|
661
|
+
* `schedulerDO` namespace and `adminToken`, and this client's auth token
|
|
662
|
+
* must match. Powers `@lunora/studio`'s scheduled-jobs panel.
|
|
663
|
+
*/
|
|
664
|
+
async listScheduledJobs() {
|
|
665
|
+
if (this.closed) {
|
|
666
|
+
throw new Error("LunoraClient is closed");
|
|
667
|
+
}
|
|
668
|
+
const body = await this.adminFetch(SCHEDULED_PATH, "GET");
|
|
669
|
+
return body.records ?? [];
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Read the app-level workpool backlog that powers `@lunora/studio`'s SLO
|
|
673
|
+
* view: per-pool `{ name, queued, inFlight, maxConcurrency }` plus the
|
|
674
|
+
* app-wide `backlog` (total queued) and `inFlight` (total held slots) sums.
|
|
675
|
+
* Hits the admin-gated `GET /_lunora/admin/scheduled/status` endpoint, so the
|
|
676
|
+
* same preconditions as {@link listScheduledJobs} apply (a `schedulerDO`
|
|
677
|
+
* namespace + `adminToken` on the worker and a matching auth token here).
|
|
678
|
+
* Defaults any absent field so an older worker still yields a valid shape.
|
|
679
|
+
*/
|
|
680
|
+
async schedulerStatus() {
|
|
681
|
+
if (this.closed) {
|
|
682
|
+
throw new Error("LunoraClient is closed");
|
|
683
|
+
}
|
|
684
|
+
const body = await this.adminFetch(SCHEDULED_STATUS_PATH, "GET");
|
|
685
|
+
return {
|
|
686
|
+
backlog: body.backlog ?? 0,
|
|
687
|
+
inFlight: body.inFlight ?? 0,
|
|
688
|
+
pools: body.pools ?? []
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
/** Cancel a pending scheduled job by id. Returns whether a job was removed. */
|
|
692
|
+
async cancelScheduledJob(id) {
|
|
693
|
+
if (this.closed) {
|
|
694
|
+
throw new Error("LunoraClient is closed");
|
|
695
|
+
}
|
|
696
|
+
const body = await this.adminFetch(SCHEDULED_CANCEL_PATH, "POST", { id });
|
|
697
|
+
return { cancelled: body.cancelled === true };
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* List the dead-letter jobs: schedules that exhausted their retry budget
|
|
701
|
+
* and were parked instead of dropped. These never appear in
|
|
702
|
+
* {@link listScheduledJobs} (their live header is gone), so this is the only
|
|
703
|
+
* way the studio surfaces a permanently-failed job. Hits the admin-gated
|
|
704
|
+
* `GET /_lunora/admin/scheduled/dead`; same preconditions as
|
|
705
|
+
* {@link listScheduledJobs}. Powers `@lunora/studio`'s dead-letter panel.
|
|
706
|
+
*/
|
|
707
|
+
async listDeadJobs() {
|
|
708
|
+
if (this.closed) {
|
|
709
|
+
throw new Error("LunoraClient is closed");
|
|
710
|
+
}
|
|
711
|
+
const body = await this.adminFetch(SCHEDULED_DEAD_PATH, "GET");
|
|
712
|
+
return body.records ?? [];
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Resurrect a dead-letter job by id: it re-enters the schedule with a fresh
|
|
716
|
+
* retry budget and fires on the next drain. Returns whether a parked record
|
|
717
|
+
* matched. Hits the admin-gated `POST /_lunora/admin/scheduled/dead/retry`.
|
|
718
|
+
*/
|
|
719
|
+
async retryDeadJob(id) {
|
|
720
|
+
if (this.closed) {
|
|
721
|
+
throw new Error("LunoraClient is closed");
|
|
722
|
+
}
|
|
723
|
+
const body = await this.adminFetch(SCHEDULED_DEAD_RETRY_PATH, "POST", { id });
|
|
724
|
+
return { retried: body.retried === true };
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Permanently drop a dead-letter job by id (the operator has decided not to
|
|
728
|
+
* recover it). Returns whether a parked record was removed. Hits the
|
|
729
|
+
* admin-gated `POST /_lunora/admin/scheduled/dead/cancel`.
|
|
730
|
+
*/
|
|
731
|
+
async removeDeadJob(id) {
|
|
732
|
+
if (this.closed) {
|
|
733
|
+
throw new Error("LunoraClient is closed");
|
|
734
|
+
}
|
|
735
|
+
const body = await this.adminFetch(SCHEDULED_DEAD_CANCEL_PATH, "POST", { id });
|
|
736
|
+
return { removed: body.removed === true };
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* List a workflow's instances via the admin Workflows proxy
|
|
740
|
+
* (`/_lunora/admin/workflows/instances`) — the Cloudflare control-plane data
|
|
741
|
+
* the `Workflow` binding can't expose. Requires the worker to be built with a
|
|
742
|
+
* `workflowsClient` (Cloudflare account id + API token); otherwise the proxy
|
|
743
|
+
* responds 501 and this rejects. `name` is the deployed workflow name.
|
|
744
|
+
*/
|
|
745
|
+
async listWorkflowInstances(options) {
|
|
746
|
+
if (this.closed) {
|
|
747
|
+
throw new Error("LunoraClient is closed");
|
|
748
|
+
}
|
|
749
|
+
const query = new URLSearchParams({ name: options.name });
|
|
750
|
+
if (options.status !== void 0) {
|
|
751
|
+
query.set("status", options.status);
|
|
752
|
+
}
|
|
753
|
+
if (options.page !== void 0) {
|
|
754
|
+
query.set("page", String(options.page));
|
|
755
|
+
}
|
|
756
|
+
if (options.perPage !== void 0) {
|
|
757
|
+
query.set("perPage", String(options.perPage));
|
|
758
|
+
}
|
|
759
|
+
const body = await this.adminFetch(`${WORKFLOWS_INSTANCES_PATH}?${query.toString()}`, "GET");
|
|
760
|
+
return { instances: body.instances ?? [], page: body.page ?? 1, perPage: body.perPage ?? options.perPage ?? 0, totalCount: body.totalCount };
|
|
761
|
+
}
|
|
762
|
+
/** Read one workflow instance with its step timeline (`/_lunora/admin/workflows/instance`). */
|
|
763
|
+
async getWorkflowInstance(options) {
|
|
764
|
+
if (this.closed) {
|
|
765
|
+
throw new Error("LunoraClient is closed");
|
|
766
|
+
}
|
|
767
|
+
const query = new URLSearchParams({ id: options.id, name: options.name });
|
|
768
|
+
const body = await this.adminFetch(`${WORKFLOWS_INSTANCE_PATH}?${query.toString()}`, "GET");
|
|
769
|
+
return {
|
|
770
|
+
createdOn: body.createdOn,
|
|
771
|
+
endedOn: body.endedOn,
|
|
772
|
+
error: body.error,
|
|
773
|
+
id: body.id ?? options.id,
|
|
774
|
+
output: body.output,
|
|
775
|
+
params: body.params,
|
|
776
|
+
startedOn: body.startedOn,
|
|
777
|
+
status: body.status ?? "unknown",
|
|
778
|
+
steps: body.steps ?? []
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
/** Pause / resume / terminate a workflow instance (`/_lunora/admin/workflows/status`). Needs an Edit-scoped Cloudflare token. */
|
|
782
|
+
async setWorkflowInstanceStatus(options) {
|
|
783
|
+
if (this.closed) {
|
|
784
|
+
throw new Error("LunoraClient is closed");
|
|
785
|
+
}
|
|
786
|
+
const body = await this.adminFetch(WORKFLOWS_STATUS_PATH, "POST", { action: options.action, id: options.id, name: options.name });
|
|
787
|
+
return { status: body.status ?? "unknown" };
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Subscribe to the live scheduled-jobs list over the SchedulerDO's admin
|
|
791
|
+
* WebSocket. `onJobs` fires with the full list on connect and on every
|
|
792
|
+
* change (schedule / cancel / alarm-fire). Reconnects with the client's
|
|
793
|
+
* configured backoff. Requires `wsToken` to be set to the admin token (the
|
|
794
|
+
* browser can't send an `Authorization` header on a WS). Returns an
|
|
795
|
+
* unsubscribe function that closes the socket and stops reconnecting.
|
|
796
|
+
*/
|
|
797
|
+
subscribeScheduledJobs(onJobs) {
|
|
798
|
+
if (this.closed) {
|
|
799
|
+
throw new Error("LunoraClient is closed");
|
|
800
|
+
}
|
|
801
|
+
if (this.WebSocketImpl === void 0) {
|
|
802
|
+
return () => void 0;
|
|
803
|
+
}
|
|
804
|
+
const base = joinUrl(deriveWsUrl(this.url), SCHEDULED_WS_PATH);
|
|
805
|
+
const reconnect = createReconnect(this.reconnectOptions);
|
|
806
|
+
let socket;
|
|
807
|
+
let timer;
|
|
808
|
+
let closed = false;
|
|
809
|
+
const connect = () => {
|
|
810
|
+
if (closed || this.WebSocketImpl === void 0) {
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
const url = this.wsToken === void 0 ? base : `${base}?token=${encodeURIComponent(this.wsToken)}`;
|
|
814
|
+
socket = new this.WebSocketImpl(url);
|
|
815
|
+
socket.addEventListener("open", () => {
|
|
816
|
+
reconnect.reset();
|
|
817
|
+
});
|
|
818
|
+
socket.addEventListener("message", (event) => {
|
|
819
|
+
try {
|
|
820
|
+
const message = JSON.parse(typeof event.data === "string" ? event.data : "");
|
|
821
|
+
if (message.type === "jobs" && Array.isArray(message.records)) {
|
|
822
|
+
onJobs(message.records);
|
|
823
|
+
}
|
|
824
|
+
} catch {
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
socket.addEventListener("close", () => {
|
|
828
|
+
socket = void 0;
|
|
829
|
+
if (!closed) {
|
|
830
|
+
timer = setTimeout(connect, reconnect.next());
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
socket.addEventListener("error", () => {
|
|
834
|
+
});
|
|
835
|
+
};
|
|
836
|
+
connect();
|
|
837
|
+
return () => {
|
|
838
|
+
closed = true;
|
|
839
|
+
if (timer !== void 0) {
|
|
840
|
+
clearTimeout(timer);
|
|
841
|
+
}
|
|
842
|
+
socket?.close();
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
// --- Functions admin ----------------------------------------------------
|
|
846
|
+
/**
|
|
847
|
+
* List the registered public functions (queries / mutations / actions) with
|
|
848
|
+
* their kinds. Hits the admin-gated `GET /_lunora/admin/functions` endpoint —
|
|
849
|
+
* the worker must be built with a `functions` registry and `adminToken`, and
|
|
850
|
+
* this client's auth token must match. Powers `@lunora/studio`'s function
|
|
851
|
+
* runner auto-discovery.
|
|
852
|
+
*/
|
|
853
|
+
async listFunctions() {
|
|
854
|
+
if (this.closed) {
|
|
855
|
+
throw new Error("LunoraClient is closed");
|
|
856
|
+
}
|
|
857
|
+
const body = await this.adminFetch(FUNCTIONS_PATH, "GET");
|
|
858
|
+
return body.functions ?? [];
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* List the code-defined cron triggers (the `cronJobs()` map injected on the
|
|
862
|
+
* worker), each flattened to its firing `cron` expression. Hits the
|
|
863
|
+
* admin-gated `GET /_lunora/admin/cron-jobs` endpoint — the worker must be
|
|
864
|
+
* built with a `cronJobs` map and `adminToken`, and this client's auth token
|
|
865
|
+
* must match. These are static (Cloudflare exposes no runtime cron
|
|
866
|
+
* introspection), so the studio renders them read-only alongside the dynamic
|
|
867
|
+
* scheduler jobs.
|
|
868
|
+
*/
|
|
869
|
+
async getCronJobs() {
|
|
870
|
+
if (this.closed) {
|
|
871
|
+
throw new Error("LunoraClient is closed");
|
|
872
|
+
}
|
|
873
|
+
const body = await this.adminFetch(CRON_JOBS_PATH, "GET");
|
|
874
|
+
return body.jobs ?? [];
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Manually fire one code-defined cron job by name — the same dispatch the
|
|
878
|
+
* scheduled trigger runs (dispatch the function, or start the durable
|
|
879
|
+
* workflow), on demand. Hits the admin-gated `POST /_lunora/admin/cron-jobs/run`
|
|
880
|
+
* endpoint; the worker must be built with a `cronJobs` map and `adminToken`,
|
|
881
|
+
* and this client's auth token must match. Resolves when the job has run (a
|
|
882
|
+
* function job's shard response is 2xx, or the workflow instance was created)
|
|
883
|
+
* and rejects with the dispatch error otherwise.
|
|
884
|
+
*/
|
|
885
|
+
async runCronJob(name) {
|
|
886
|
+
if (this.closed) {
|
|
887
|
+
throw new Error("LunoraClient is closed");
|
|
888
|
+
}
|
|
889
|
+
const body = await this.adminFetch(CRON_JOBS_RUN_PATH, "POST", { name });
|
|
890
|
+
return { name: body.name ?? name, ran: body.ran === true };
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Fetch the generated OpenAPI 3.1 document. Hits the admin-gated
|
|
894
|
+
* `GET /_lunora/admin/openapi` endpoint — the worker must be built with an
|
|
895
|
+
* `openApiSpec` and `adminToken`, and this client's auth token must match.
|
|
896
|
+
* Powers `@lunora/studio`'s API-reference (Scalar) view. When the worker has
|
|
897
|
+
* no spec wired, the endpoint still resolves with an empty-but-valid OpenAPI
|
|
898
|
+
* document (no `paths`), so callers can render a "not configured" state.
|
|
899
|
+
*/
|
|
900
|
+
async fetchOpenApi() {
|
|
901
|
+
if (this.closed) {
|
|
902
|
+
throw new Error("LunoraClient is closed");
|
|
903
|
+
}
|
|
904
|
+
return await this.adminFetch(OPENAPI_PATH, "GET");
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Fetch the generated OpenRPC 1.x document. Hits the admin-gated
|
|
908
|
+
* `GET /_lunora/admin/openrpc` endpoint — the worker must be built with an
|
|
909
|
+
* `openRpcSpec` and `adminToken`, and this client's auth token must match.
|
|
910
|
+
* OpenRPC is the RPC-native spec (a `methods` array over the JSON-RPC-shaped
|
|
911
|
+
* `POST /_lunora/rpc` transport); it documents the RPC functions only.
|
|
912
|
+
* Powers `@lunora/studio`'s OpenRPC API-reference view. When the worker has
|
|
913
|
+
* no spec wired, the endpoint still resolves with an empty-but-valid OpenRPC
|
|
914
|
+
* document (no `methods`), so callers can render a "not configured" state.
|
|
915
|
+
*/
|
|
916
|
+
async fetchOpenRpc() {
|
|
917
|
+
if (this.closed) {
|
|
918
|
+
throw new Error("LunoraClient is closed");
|
|
919
|
+
}
|
|
920
|
+
return await this.adminFetch(OPENRPC_PATH, "GET");
|
|
921
|
+
}
|
|
922
|
+
// --- Storage admin ------------------------------------------------------
|
|
923
|
+
/**
|
|
924
|
+
* List objects in the storage bucket, optionally under a `prefix` and from a
|
|
925
|
+
* pagination `cursor`. Hits the admin-gated `GET /_lunora/admin/storage`
|
|
926
|
+
* endpoint — the worker must be built with a `storageList` function and
|
|
927
|
+
* `adminToken`, and this client's auth token must match. Powers
|
|
928
|
+
* `@lunora/studio`'s file browser.
|
|
929
|
+
*/
|
|
930
|
+
async listStorageObjects(options = {}) {
|
|
931
|
+
if (this.closed) {
|
|
932
|
+
throw new Error("LunoraClient is closed");
|
|
933
|
+
}
|
|
934
|
+
const params = new URLSearchParams();
|
|
935
|
+
if (options.prefix !== void 0 && options.prefix !== "") {
|
|
936
|
+
params.set("prefix", options.prefix);
|
|
937
|
+
}
|
|
938
|
+
if (options.cursor !== void 0 && options.cursor !== "") {
|
|
939
|
+
params.set("cursor", options.cursor);
|
|
940
|
+
}
|
|
941
|
+
if (options.limit !== void 0) {
|
|
942
|
+
params.set("limit", String(options.limit));
|
|
943
|
+
}
|
|
944
|
+
if (options.bucket !== void 0 && options.bucket !== "") {
|
|
945
|
+
params.set("bucket", options.bucket);
|
|
946
|
+
}
|
|
947
|
+
const query = params.toString();
|
|
948
|
+
const path = query === "" ? STORAGE_PATH : `${STORAGE_PATH}?${query}`;
|
|
949
|
+
const body = await this.adminFetch(path, "GET");
|
|
950
|
+
return { cursor: body.cursor, objects: body.objects ?? [] };
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Delete one object from the storage bucket by key. Hits the admin-gated
|
|
954
|
+
* `DELETE /_lunora/admin/storage?key=…` endpoint — the worker must be built
|
|
955
|
+
* with a `storageDelete` function and `adminToken`. Powers the studio file
|
|
956
|
+
* browser's per-row delete; resolves `{ deleted, key }`.
|
|
957
|
+
*/
|
|
958
|
+
async deleteStorageObject(key, options) {
|
|
959
|
+
if (this.closed) {
|
|
960
|
+
throw new Error("LunoraClient is closed");
|
|
961
|
+
}
|
|
962
|
+
const path = `${STORAGE_PATH}?key=${encodeURIComponent(key)}${bucketQuery(options?.bucket)}`;
|
|
963
|
+
const body = await this.adminFetch(path, "DELETE");
|
|
964
|
+
return { deleted: body.deleted ?? true, key: body.key ?? key };
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* List the storage bucket names the worker exposes, for the studio file
|
|
968
|
+
* browser's bucket picker. Hits the admin-gated
|
|
969
|
+
* `GET /_lunora/admin/storage/buckets` endpoint — always resolves (an empty
|
|
970
|
+
* array when the worker configures no `storageBuckets`, i.e. single-bucket).
|
|
971
|
+
*/
|
|
972
|
+
async listStorageBuckets() {
|
|
973
|
+
if (this.closed) {
|
|
974
|
+
throw new Error("LunoraClient is closed");
|
|
975
|
+
}
|
|
976
|
+
const body = await this.adminFetch(STORAGE_BUCKETS_PATH, "GET");
|
|
977
|
+
return body.buckets ?? [];
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Upload one object to the storage bucket. Hits the admin-gated
|
|
981
|
+
* `PUT /_lunora/admin/storage?key=…` endpoint with the raw body and an
|
|
982
|
+
* optional `contentType` header — the worker must be built with a
|
|
983
|
+
* `storageUpload` function and `adminToken`. Powers the studio file
|
|
984
|
+
* browser's upload control; resolves `{ etag?, key }`.
|
|
985
|
+
*/
|
|
986
|
+
async uploadStorageObject(options) {
|
|
987
|
+
if (this.closed) {
|
|
988
|
+
throw new Error("LunoraClient is closed");
|
|
989
|
+
}
|
|
990
|
+
const path = `${STORAGE_PATH}?key=${encodeURIComponent(options.key)}${bucketQuery(options.bucket)}`;
|
|
991
|
+
const body = await this.adminFetch(path, "PUT", options.body, options.contentType);
|
|
992
|
+
return { etag: body.etag, key: body.key ?? options.key };
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Build a (signed or public) URL for one object. Hits the admin-gated
|
|
996
|
+
* `GET /_lunora/admin/storage/url?key=…` endpoint — the worker must be built
|
|
997
|
+
* with a `storageSignedUrl` function and `adminToken`. Powers the studio
|
|
998
|
+
* file browser's copy-URL action; resolves the URL string.
|
|
999
|
+
*
|
|
1000
|
+
* `options.expiresInSeconds` requests a share-link lifetime, which is
|
|
1001
|
+
* validated/clamped server-side. The options object mirrors the worker's
|
|
1002
|
+
* `StorageSignedUrlFunction` options (a `password` / download-limit are noted
|
|
1003
|
+
* as future fields there).
|
|
1004
|
+
*/
|
|
1005
|
+
async signedStorageUrl(key, options) {
|
|
1006
|
+
if (this.closed) {
|
|
1007
|
+
throw new Error("LunoraClient is closed");
|
|
1008
|
+
}
|
|
1009
|
+
const expiresInSeconds = options?.expiresInSeconds;
|
|
1010
|
+
const expiryQuery = expiresInSeconds === void 0 ? "" : `&expiresIn=${encodeURIComponent(expiresInSeconds.toString())}`;
|
|
1011
|
+
const path = `${STORAGE_URL_PATH}?key=${encodeURIComponent(key)}${expiryQuery}${bucketQuery(options?.bucket)}`;
|
|
1012
|
+
const body = await this.adminFetch(path, "GET");
|
|
1013
|
+
if (typeof body.url !== "string") {
|
|
1014
|
+
throw new TypeError("LunoraClient: storage URL endpoint returned no `url`");
|
|
1015
|
+
}
|
|
1016
|
+
return body.url;
|
|
1017
|
+
}
|
|
1018
|
+
// --- Global (D1) tables admin -------------------------------------------
|
|
1019
|
+
/**
|
|
1020
|
+
* List the `.global()` (D1-backed) tables with their row counts. Hits the
|
|
1021
|
+
* admin-gated `GET /_lunora/admin/global/tables` endpoint — the worker must
|
|
1022
|
+
* be built with a `globalIntrospector` and `adminToken`. Powers the data
|
|
1023
|
+
* browser's global mode.
|
|
1024
|
+
*/
|
|
1025
|
+
async listGlobalTables() {
|
|
1026
|
+
if (this.closed) {
|
|
1027
|
+
throw new Error("LunoraClient is closed");
|
|
1028
|
+
}
|
|
1029
|
+
return await this.adminFetch(GLOBAL_TABLES_PATH, "GET");
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Read a page of rows from one `.global()` table. `filters` AND-narrows the
|
|
1033
|
+
* page to rows matching each `column = value` eq constraint — the drill-down a
|
|
1034
|
+
* facet-value click applies; the array is JSON-encoded into the `filters`
|
|
1035
|
+
* query param and the values are bound server-side.
|
|
1036
|
+
*/
|
|
1037
|
+
async readGlobalTablePage(options) {
|
|
1038
|
+
if (this.closed) {
|
|
1039
|
+
throw new Error("LunoraClient is closed");
|
|
1040
|
+
}
|
|
1041
|
+
const params = new URLSearchParams({ table: options.table });
|
|
1042
|
+
if (options.limit !== void 0) {
|
|
1043
|
+
params.set("limit", String(options.limit));
|
|
1044
|
+
}
|
|
1045
|
+
if (options.offset !== void 0) {
|
|
1046
|
+
params.set("offset", String(options.offset));
|
|
1047
|
+
}
|
|
1048
|
+
if (options.filters !== void 0 && options.filters.length > 0) {
|
|
1049
|
+
params.set("filters", JSON.stringify(options.filters));
|
|
1050
|
+
}
|
|
1051
|
+
return await this.adminFetch(`${GLOBAL_TABLE_PATH}?${params.toString()}`, "GET");
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Summarise the distinct values of one column in a `.global()` table over the
|
|
1055
|
+
* active view (the same eq `filters` the browser is previewing) — the global
|
|
1056
|
+
* twin of the shard browser's facet. Hits the admin-gated
|
|
1057
|
+
* `GET /_lunora/admin/global/facet` endpoint; `column` is validated + bound
|
|
1058
|
+
* server-side. Powers the global data browser's facet sidebar.
|
|
1059
|
+
*/
|
|
1060
|
+
async facetGlobalColumn(options) {
|
|
1061
|
+
if (this.closed) {
|
|
1062
|
+
throw new Error("LunoraClient is closed");
|
|
1063
|
+
}
|
|
1064
|
+
const params = new URLSearchParams({ column: options.column, table: options.table });
|
|
1065
|
+
if (options.limit !== void 0) {
|
|
1066
|
+
params.set("limit", String(options.limit));
|
|
1067
|
+
}
|
|
1068
|
+
if (options.filters !== void 0 && options.filters.length > 0) {
|
|
1069
|
+
params.set("filters", JSON.stringify(options.filters));
|
|
1070
|
+
}
|
|
1071
|
+
return await this.adminFetch(`${GLOBAL_FACET_PATH}?${params.toString()}`, "GET");
|
|
1072
|
+
}
|
|
1073
|
+
// --- Vector indexes admin -----------------------------------------------
|
|
1074
|
+
/**
|
|
1075
|
+
* List the schema's Vectorize indexes with their declared shape (table,
|
|
1076
|
+
* field, dimensions, metric, metadata) and live stats (vector count,
|
|
1077
|
+
* processing watermark) when the binding is reachable. Hits the admin-gated
|
|
1078
|
+
* `GET /_lunora/admin/vector/indexes` endpoint — the worker must be built
|
|
1079
|
+
* with a `vectorIntrospector` and `adminToken`. Powers the studio's vector
|
|
1080
|
+
* browser. Vectorize can't enumerate indexes at runtime, so this list comes
|
|
1081
|
+
* from the generated `LUNORA_VECTOR_INDEXES` registry.
|
|
1082
|
+
*/
|
|
1083
|
+
async listVectorIndexes() {
|
|
1084
|
+
if (this.closed) {
|
|
1085
|
+
throw new Error("LunoraClient is closed");
|
|
1086
|
+
}
|
|
1087
|
+
const body = await this.adminFetch(VECTOR_INDEXES_PATH, "GET");
|
|
1088
|
+
return body.indexes ?? [];
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Run a nearest-neighbour similarity query against one vector index: the
|
|
1092
|
+
* worker embeds `text` via the index's embedder and returns the top matches.
|
|
1093
|
+
* Hits the admin-gated `POST /_lunora/admin/vector/query` endpoint. Throws
|
|
1094
|
+
* `VECTOR_QUERY_UNSUPPORTED` when the worker's introspector has no embedder
|
|
1095
|
+
* wired (the index lists read-only).
|
|
1096
|
+
*/
|
|
1097
|
+
async queryVectorIndex(options) {
|
|
1098
|
+
if (this.closed) {
|
|
1099
|
+
throw new Error("LunoraClient is closed");
|
|
1100
|
+
}
|
|
1101
|
+
const body = await this.adminFetch(VECTOR_QUERY_PATH, "POST", options);
|
|
1102
|
+
return body.matches ?? [];
|
|
1103
|
+
}
|
|
1104
|
+
// --- Auth admin ---------------------------------------------------------
|
|
1105
|
+
/**
|
|
1106
|
+
* List authenticated users, paged and optionally searched / filtered / sorted.
|
|
1107
|
+
* Hits the admin-gated `GET /_lunora/admin/auth/users` endpoint — the worker
|
|
1108
|
+
* must be built with an `authAdmin` and `adminToken`. Powers the studio's
|
|
1109
|
+
* users dashboard.
|
|
1110
|
+
*/
|
|
1111
|
+
async listAuthUsers(options = {}) {
|
|
1112
|
+
if (this.closed) {
|
|
1113
|
+
throw new Error("LunoraClient is closed");
|
|
1114
|
+
}
|
|
1115
|
+
const path = withQuery(AUTH_USERS_PATH, {
|
|
1116
|
+
filterField: options.filterField,
|
|
1117
|
+
filterValue: options.filterValue,
|
|
1118
|
+
limit: options.limit,
|
|
1119
|
+
offset: options.offset,
|
|
1120
|
+
search: options.search,
|
|
1121
|
+
searchField: options.searchField,
|
|
1122
|
+
sortBy: options.sortBy,
|
|
1123
|
+
sortDirection: options.sortDirection
|
|
1124
|
+
});
|
|
1125
|
+
return await this.adminFetch(path, "GET");
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Create a user. Hits the admin-gated `POST /_lunora/admin/auth/users/create`
|
|
1129
|
+
* endpoint (requires the worker's `authAdmin` to implement `createUser`).
|
|
1130
|
+
* `data` carries any app-defined `user.additionalFields`.
|
|
1131
|
+
*/
|
|
1132
|
+
async createAuthUser(input) {
|
|
1133
|
+
return await this.adminFetch(AUTH_CREATE_USER_PATH, "POST", input);
|
|
1134
|
+
}
|
|
1135
|
+
/** Set a user's role (string, or array joined comma-wise server-side). */
|
|
1136
|
+
async setAuthUserRole(input) {
|
|
1137
|
+
return await this.adminFetch(AUTH_SET_ROLE_PATH, "POST", input);
|
|
1138
|
+
}
|
|
1139
|
+
/** Ban a user. `expiresInSeconds` sets a temporary ban; omit it for a permanent one. Revokes the user's live sessions. */
|
|
1140
|
+
async banAuthUser(input) {
|
|
1141
|
+
return await this.adminFetch(AUTH_BAN_PATH, "POST", input);
|
|
1142
|
+
}
|
|
1143
|
+
/** Lift a user's ban. */
|
|
1144
|
+
async unbanAuthUser(input) {
|
|
1145
|
+
return await this.adminFetch(AUTH_UNBAN_PATH, "POST", input);
|
|
1146
|
+
}
|
|
1147
|
+
/** Set a user's password (admin override — no current-password challenge). */
|
|
1148
|
+
async setAuthUserPassword(input) {
|
|
1149
|
+
await this.adminFetch(AUTH_SET_PASSWORD_PATH, "POST", input);
|
|
1150
|
+
}
|
|
1151
|
+
/** Permanently delete a user and revoke their sessions. */
|
|
1152
|
+
async removeAuthUser(input) {
|
|
1153
|
+
await this.adminFetch(AUTH_REMOVE_USER_PATH, "POST", input);
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Mint an impersonation session for a user, returning its bearer `token`.
|
|
1157
|
+
* The caller is responsible for using the token (e.g. setting the session
|
|
1158
|
+
* cookie); the server performs no cookie round-trip.
|
|
1159
|
+
*/
|
|
1160
|
+
async impersonateAuthUser(input) {
|
|
1161
|
+
return await this.adminFetch(AUTH_IMPERSONATE_PATH, "POST", input);
|
|
1162
|
+
}
|
|
1163
|
+
/** Revoke a single session by its id (force sign-out of one device). */
|
|
1164
|
+
async revokeAuthSession(input) {
|
|
1165
|
+
await this.adminFetch(AUTH_REVOKE_SESSION_PATH, "POST", input);
|
|
1166
|
+
}
|
|
1167
|
+
/** Revoke every session for a user (force sign-out everywhere). */
|
|
1168
|
+
async revokeAuthUserSessions(input) {
|
|
1169
|
+
await this.adminFetch(AUTH_REVOKE_SESSIONS_PATH, "POST", input);
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Report which auth dashboard surfaces are available — derived server-side
|
|
1173
|
+
* from the enabled better-auth plugins. The studio renders only the panels
|
|
1174
|
+
* whose capability is `true`.
|
|
1175
|
+
*/
|
|
1176
|
+
async getAuthCapabilities() {
|
|
1177
|
+
return await this.adminFetch(AUTH_CAPABILITIES_PATH, "GET");
|
|
1178
|
+
}
|
|
1179
|
+
/** Update a user's fields (name/email/app-defined `additionalFields`). */
|
|
1180
|
+
async updateAuthUser(input) {
|
|
1181
|
+
return await this.adminFetch(AUTH_UPDATE_USER_PATH, "POST", input);
|
|
1182
|
+
}
|
|
1183
|
+
/** List a user's linked accounts (credential / OAuth providers). Token material is stripped server-side. */
|
|
1184
|
+
async listAuthAccounts(input) {
|
|
1185
|
+
return await this.adminFetch(withQuery(AUTH_ACCOUNTS_PATH, { userId: input.userId }), "GET");
|
|
1186
|
+
}
|
|
1187
|
+
/** Unlink a linked account from a user. */
|
|
1188
|
+
async unlinkAuthAccount(input) {
|
|
1189
|
+
await this.adminFetch(AUTH_UNLINK_ACCOUNT_PATH, "POST", input);
|
|
1190
|
+
}
|
|
1191
|
+
/** List a user's registered passkeys (requires the passkey plugin). */
|
|
1192
|
+
async listAuthPasskeys(input) {
|
|
1193
|
+
return await this.adminFetch(withQuery(AUTH_PASSKEYS_PATH, { userId: input.userId }), "GET");
|
|
1194
|
+
}
|
|
1195
|
+
/** Delete a passkey by id (requires the passkey plugin). */
|
|
1196
|
+
async deleteAuthPasskey(input) {
|
|
1197
|
+
await this.adminFetch(AUTH_DELETE_PASSKEY_PATH, "POST", input);
|
|
1198
|
+
}
|
|
1199
|
+
/** Disable two-factor auth for a user (requires the two-factor plugin). */
|
|
1200
|
+
async disableAuthTwoFactor(input) {
|
|
1201
|
+
await this.adminFetch(AUTH_DISABLE_2FA_PATH, "POST", input);
|
|
1202
|
+
}
|
|
1203
|
+
/** List organizations, paged (requires the organization plugin). */
|
|
1204
|
+
async listAuthOrganizations(options = {}) {
|
|
1205
|
+
return await this.adminFetch(withQuery(AUTH_ORGS_PATH, { limit: options.limit, offset: options.offset }), "GET");
|
|
1206
|
+
}
|
|
1207
|
+
/** List the members of an organization (requires the organization plugin). */
|
|
1208
|
+
async listAuthOrgMembers(input) {
|
|
1209
|
+
const path = withQuery(AUTH_ORG_MEMBERS_PATH, { limit: input.limit, offset: input.offset, organizationId: input.organizationId });
|
|
1210
|
+
return await this.adminFetch(path, "GET");
|
|
1211
|
+
}
|
|
1212
|
+
/** List an organization's pending invitations (requires the organization plugin). */
|
|
1213
|
+
async listAuthOrgInvitations(input) {
|
|
1214
|
+
const path = withQuery(AUTH_ORG_INVITATIONS_PATH, { limit: input.limit, offset: input.offset, organizationId: input.organizationId });
|
|
1215
|
+
return await this.adminFetch(path, "GET");
|
|
1216
|
+
}
|
|
1217
|
+
/** Remove a member from an organization. */
|
|
1218
|
+
async removeAuthOrgMember(input) {
|
|
1219
|
+
await this.adminFetch(AUTH_REMOVE_MEMBER_PATH, "POST", input);
|
|
1220
|
+
}
|
|
1221
|
+
/** Cancel a pending organization invitation. */
|
|
1222
|
+
async cancelAuthOrgInvitation(input) {
|
|
1223
|
+
await this.adminFetch(AUTH_CANCEL_INVITATION_PATH, "POST", input);
|
|
1224
|
+
}
|
|
1225
|
+
/** List auth sessions, paged and optionally filtered to one user. */
|
|
1226
|
+
async listAuthSessions(options = {}) {
|
|
1227
|
+
if (this.closed) {
|
|
1228
|
+
throw new Error("LunoraClient is closed");
|
|
1229
|
+
}
|
|
1230
|
+
const params = new URLSearchParams();
|
|
1231
|
+
if (options.userId !== void 0 && options.userId !== "") {
|
|
1232
|
+
params.set("userId", options.userId);
|
|
1233
|
+
}
|
|
1234
|
+
if (options.limit !== void 0) {
|
|
1235
|
+
params.set("limit", String(options.limit));
|
|
1236
|
+
}
|
|
1237
|
+
if (options.offset !== void 0) {
|
|
1238
|
+
params.set("offset", String(options.offset));
|
|
1239
|
+
}
|
|
1240
|
+
const query = params.toString();
|
|
1241
|
+
return await this.adminFetch(query === "" ? AUTH_SESSIONS_PATH : `${AUTH_SESSIONS_PATH}?${query}`, "GET");
|
|
1242
|
+
}
|
|
1243
|
+
// --- Subscriptions ------------------------------------------------------
|
|
1244
|
+
subscribe(function_, args, callback, options = {}) {
|
|
1245
|
+
if (this.closed) {
|
|
1246
|
+
throw new Error("LunoraClient is closed");
|
|
1247
|
+
}
|
|
1248
|
+
const argsRecord = args ?? {};
|
|
1249
|
+
const key = SubscriptionRegistry.key(function_.__lunoraRef, argsRecord, options.shardKey);
|
|
1250
|
+
let state = this.subscriptions.get(key);
|
|
1251
|
+
const subscriptionCallback = callback;
|
|
1252
|
+
const errorCallback = options.onError;
|
|
1253
|
+
if (!state) {
|
|
1254
|
+
this.nextSubId += 1;
|
|
1255
|
+
const id = `sub_${this.nextSubId.toString()}`;
|
|
1256
|
+
const argsKey = stableStringify(argsRecord);
|
|
1257
|
+
const cached = this.takeHydratedCache(function_.__lunoraRef, argsKey, options.shardKey);
|
|
1258
|
+
state = {
|
|
1259
|
+
acked: false,
|
|
1260
|
+
args: argsRecord,
|
|
1261
|
+
argsKey,
|
|
1262
|
+
callbacks: /* @__PURE__ */ new Set(),
|
|
1263
|
+
errorCallbacks: /* @__PURE__ */ new Set(),
|
|
1264
|
+
fn: function_,
|
|
1265
|
+
id,
|
|
1266
|
+
lastValue: cached?.value,
|
|
1267
|
+
serverCursor: cached?.serverCursor,
|
|
1268
|
+
serverVersion: 0,
|
|
1269
|
+
shardKey: options.shardKey,
|
|
1270
|
+
...cached?.serverEpoch === void 0 ? {} : { serverEpoch: cached.serverEpoch }
|
|
1271
|
+
};
|
|
1272
|
+
this.subscriptions.add(state);
|
|
1273
|
+
}
|
|
1274
|
+
state.callbacks.add(subscriptionCallback);
|
|
1275
|
+
if (errorCallback) {
|
|
1276
|
+
state.errorCallbacks.add(errorCallback);
|
|
1277
|
+
}
|
|
1278
|
+
if (state.lastValue !== void 0) {
|
|
1279
|
+
try {
|
|
1280
|
+
subscriptionCallback(state.lastValue);
|
|
1281
|
+
} catch {
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
this.ensureSocket(options.shardKey);
|
|
1285
|
+
this.sendSubscribeIfOpen(state);
|
|
1286
|
+
const subscriptionState = state;
|
|
1287
|
+
return () => {
|
|
1288
|
+
subscriptionState.callbacks.delete(subscriptionCallback);
|
|
1289
|
+
if (errorCallback) {
|
|
1290
|
+
subscriptionState.errorCallbacks.delete(errorCallback);
|
|
1291
|
+
}
|
|
1292
|
+
if (subscriptionState.callbacks.size === 0) {
|
|
1293
|
+
const conn = this.getConnection(subscriptionState.shardKey);
|
|
1294
|
+
const ok = conn ? sendOn(conn, { id: subscriptionState.id, type: "unsubscribe" }) : false;
|
|
1295
|
+
if (!ok && conn) {
|
|
1296
|
+
conn.pendingUnsubscribes.push(subscriptionState.id);
|
|
1297
|
+
}
|
|
1298
|
+
this.subscriptions.remove(subscriptionState);
|
|
1299
|
+
}
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Open a streaming query. The function reference must be a
|
|
1304
|
+
* `kind:"stream"` registration (built with `c.query.input(...).stream(...)`);
|
|
1305
|
+
* the type constraint catches accidental use of a query/mutation/action
|
|
1306
|
+
* reference at compile time. The returned iterable yields one element per
|
|
1307
|
+
* chunk frame the server pushes, terminating when the server sends
|
|
1308
|
+
* `complete` or the consumer calls `.cancel()`. Errors arrive as a
|
|
1309
|
+
* rejection on the next `next()`.
|
|
1310
|
+
*
|
|
1311
|
+
* Streams ride the same WS as subscriptions and share the unsubscribe
|
|
1312
|
+
* channel: cancelling sends `{type:"unsubscribe", id}` with the stream id,
|
|
1313
|
+
* which the DO recognises as an abort signal for the in-flight iterator.
|
|
1314
|
+
*
|
|
1315
|
+
* Stream-start frames buffered while the socket is (re)connecting are
|
|
1316
|
+
* capped at {@link MAX_PENDING_STREAMS} per connection — overflowing the
|
|
1317
|
+
* cap drops the oldest queued frame (and fails its consumer) so a stuck
|
|
1318
|
+
* reconnect can't OOM the page.
|
|
1319
|
+
*/
|
|
1320
|
+
stream(function_, args, options = {}) {
|
|
1321
|
+
if (this.closed) {
|
|
1322
|
+
throw new Error("LunoraClient is closed");
|
|
1323
|
+
}
|
|
1324
|
+
if (this.WebSocketImpl === void 0) {
|
|
1325
|
+
throw new Error("LunoraClient: streams require a WebSocket implementation");
|
|
1326
|
+
}
|
|
1327
|
+
this.nextStreamId += 1;
|
|
1328
|
+
const id = `stream_${this.nextStreamId.toString()}`;
|
|
1329
|
+
const { shardKey } = options;
|
|
1330
|
+
const argsRecord = args ?? {};
|
|
1331
|
+
const { handle, iterable } = createStream({
|
|
1332
|
+
maxBuffer: options.maxBuffer,
|
|
1333
|
+
onCancel: () => {
|
|
1334
|
+
const conn2 = this.getConnection(shardKey);
|
|
1335
|
+
if (conn2) {
|
|
1336
|
+
sendOn(conn2, { id, type: "unsubscribe" });
|
|
1337
|
+
}
|
|
1338
|
+
this.streams.delete(id);
|
|
1339
|
+
}
|
|
1340
|
+
});
|
|
1341
|
+
this.streams.set(id, { handle, shardKey });
|
|
1342
|
+
this.ensureSocket(shardKey);
|
|
1343
|
+
const conn = this.getConnection(shardKey);
|
|
1344
|
+
const message = {
|
|
1345
|
+
id,
|
|
1346
|
+
query: { args: argsRecord, functionPath: function_.__lunoraRef, shardKey },
|
|
1347
|
+
type: "stream"
|
|
1348
|
+
};
|
|
1349
|
+
const sentImmediately = conn?.wsState === "open" && sendOn(conn, message);
|
|
1350
|
+
if (!sentImmediately && conn) {
|
|
1351
|
+
conn.pendingStreams = conn.pendingStreams ?? [];
|
|
1352
|
+
while (conn.pendingStreams.length >= MAX_PENDING_STREAMS) {
|
|
1353
|
+
const dropped = conn.pendingStreams.shift();
|
|
1354
|
+
const droppedId = dropped?.id;
|
|
1355
|
+
const droppedStream = droppedId ? this.streams.get(droppedId) : void 0;
|
|
1356
|
+
if (droppedStream) {
|
|
1357
|
+
droppedStream.handle.fail(
|
|
1358
|
+
Object.assign(new Error("stream-start frame evicted while socket was unreachable"), { code: "STREAM_QUEUE_OVERFLOW" })
|
|
1359
|
+
);
|
|
1360
|
+
this.streams.delete(droppedId);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
conn.pendingStreams.push(message);
|
|
1364
|
+
}
|
|
1365
|
+
return iterable;
|
|
1366
|
+
}
|
|
1367
|
+
close() {
|
|
1368
|
+
this.closed = true;
|
|
1369
|
+
for (const stream of this.streams.values()) {
|
|
1370
|
+
stream.handle.fail(Object.assign(new Error("LunoraClient closed"), { code: "CLIENT_CLOSED" }));
|
|
1371
|
+
}
|
|
1372
|
+
this.streams.clear();
|
|
1373
|
+
for (const conn of this.connections.values()) {
|
|
1374
|
+
if (conn.reconnectTimer !== void 0) {
|
|
1375
|
+
clearTimeout(conn.reconnectTimer);
|
|
1376
|
+
conn.reconnectTimer = void 0;
|
|
1377
|
+
}
|
|
1378
|
+
this.stopHeartbeat(conn);
|
|
1379
|
+
if (conn.socket) {
|
|
1380
|
+
try {
|
|
1381
|
+
conn.socket.close();
|
|
1382
|
+
} catch {
|
|
1383
|
+
}
|
|
1384
|
+
conn.socket = void 0;
|
|
1385
|
+
}
|
|
1386
|
+
conn.wsState = "closed";
|
|
1387
|
+
}
|
|
1388
|
+
this.offlineQueue.clear();
|
|
1389
|
+
this.queuedIdentities.clear();
|
|
1390
|
+
if (this.cacheFlushTimer !== void 0) {
|
|
1391
|
+
clearTimeout(this.cacheFlushTimer);
|
|
1392
|
+
this.cacheFlushTimer = void 0;
|
|
1393
|
+
}
|
|
1394
|
+
if (this.pendingCacheWrites.size > 0) {
|
|
1395
|
+
this.flushQueryCacheWrites().catch(() => void 0);
|
|
1396
|
+
}
|
|
1397
|
+
this.authTokenListeners.clear();
|
|
1398
|
+
this.statusListeners.clear();
|
|
1399
|
+
this.tokenExpiredListeners.clear();
|
|
1400
|
+
this.whisperHandlers.clear();
|
|
1401
|
+
}
|
|
1402
|
+
// --- Internals ----------------------------------------------------------
|
|
1403
|
+
/**
|
|
1404
|
+
* Restore offline mutations persisted in a prior session and open a socket
|
|
1405
|
+
* for each shard they target so they flush once the WS reconnects. Failures
|
|
1406
|
+
* are swallowed — a broken durable store must not stop the client booting.
|
|
1407
|
+
*/
|
|
1408
|
+
async hydratePersistedQueue() {
|
|
1409
|
+
try {
|
|
1410
|
+
const shardKeys = await this.offlineQueue.hydrate();
|
|
1411
|
+
for (const shardKey of shardKeys) {
|
|
1412
|
+
this.ensureSocket(shardKey);
|
|
1413
|
+
}
|
|
1414
|
+
} catch {
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* Load every cached query into {@link hydratedQueryCache} so the next
|
|
1419
|
+
* `subscribe()` for each key seeds its initial value off disk. A
|
|
1420
|
+
* subscription created before this resolves simply misses the cache (it
|
|
1421
|
+
* gets a live snapshot as before); the gate at seed time also drops any
|
|
1422
|
+
* entry whose stamped identity no longer matches the current one.
|
|
1423
|
+
*/
|
|
1424
|
+
async hydrateQueryCache() {
|
|
1425
|
+
if (!this.queryCache) {
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
try {
|
|
1429
|
+
const entries = await this.queryCache.load();
|
|
1430
|
+
for (const { key, ...entry } of entries) {
|
|
1431
|
+
this.hydratedQueryCache.set(key, entry);
|
|
1432
|
+
}
|
|
1433
|
+
} catch {
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Consume the hydrated read-cache entry for a key (if any), gated on
|
|
1438
|
+
* identity. The entry is removed whether or not it matches — the cache only
|
|
1439
|
+
* ever seeds a subscription's first value. A mismatch (the cache was written
|
|
1440
|
+
* under a different identity) yields `undefined` so a signed-out cache never
|
|
1441
|
+
* leaks into a new session.
|
|
1442
|
+
*/
|
|
1443
|
+
takeHydratedCache(functionPath, argsKey, shardKey) {
|
|
1444
|
+
const key = queryCacheKey(functionPath, argsKey, shardKey);
|
|
1445
|
+
const entry = this.hydratedQueryCache.get(key);
|
|
1446
|
+
if (entry === void 0) {
|
|
1447
|
+
return void 0;
|
|
1448
|
+
}
|
|
1449
|
+
this.hydratedQueryCache.delete(key);
|
|
1450
|
+
return entry.identity === this.identityFingerprint() ? entry : void 0;
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Queue a coalesced read-cache write for a subscription's current value.
|
|
1454
|
+
* Latest-wins per key; flushed on a short debounce so a delta burst writes
|
|
1455
|
+
* once. No-op when the read cache is disabled or the value is undefined
|
|
1456
|
+
* (nothing to render offline).
|
|
1457
|
+
*/
|
|
1458
|
+
persistQueryValue(state) {
|
|
1459
|
+
if (!this.queryCache || state.lastValue === void 0) {
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
const key = queryCacheKey(state.fn.__lunoraRef, state.argsKey, state.shardKey);
|
|
1463
|
+
this.pendingCacheWrites.set(key, {
|
|
1464
|
+
identity: this.identityFingerprint(),
|
|
1465
|
+
serverCursor: state.serverCursor,
|
|
1466
|
+
ts: Date.now(),
|
|
1467
|
+
value: state.lastValue,
|
|
1468
|
+
...state.serverEpoch === void 0 ? {} : { serverEpoch: state.serverEpoch }
|
|
1469
|
+
});
|
|
1470
|
+
this.cacheFlushTimer ??= setTimeout(() => {
|
|
1471
|
+
this.flushQueryCacheWrites().catch(() => void 0);
|
|
1472
|
+
}, QUERY_CACHE_DEBOUNCE_MS);
|
|
1473
|
+
}
|
|
1474
|
+
/** Drain {@link pendingCacheWrites} to the durable store. */
|
|
1475
|
+
async flushQueryCacheWrites() {
|
|
1476
|
+
this.cacheFlushTimer = void 0;
|
|
1477
|
+
const { queryCache } = this;
|
|
1478
|
+
if (!queryCache) {
|
|
1479
|
+
this.pendingCacheWrites.clear();
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
const batch = [...this.pendingCacheWrites.entries()];
|
|
1483
|
+
this.pendingCacheWrites.clear();
|
|
1484
|
+
await Promise.allSettled(batch.map(([key, entry]) => queryCache.put(key, entry)));
|
|
1485
|
+
}
|
|
1486
|
+
/** Derive the aggregate status from the per-shard socket states. */
|
|
1487
|
+
computeStatus() {
|
|
1488
|
+
const conns = [...this.connections.values()];
|
|
1489
|
+
if (conns.length === 0) {
|
|
1490
|
+
return "idle";
|
|
1491
|
+
}
|
|
1492
|
+
if (conns.some((conn) => conn.wsState === "open")) {
|
|
1493
|
+
return "connected";
|
|
1494
|
+
}
|
|
1495
|
+
if (conns.some((conn) => conn.wsState === "connecting")) {
|
|
1496
|
+
return "connecting";
|
|
1497
|
+
}
|
|
1498
|
+
return "offline";
|
|
1499
|
+
}
|
|
1500
|
+
/** Recompute the aggregate status and notify listeners if it changed. */
|
|
1501
|
+
emitConnectionStatus() {
|
|
1502
|
+
const next = this.computeStatus();
|
|
1503
|
+
if (next === this.lastStatus) {
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
this.lastStatus = next;
|
|
1507
|
+
for (const listener of this.statusListeners) {
|
|
1508
|
+
try {
|
|
1509
|
+
listener(next);
|
|
1510
|
+
} catch {
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* Apply an optimistic update to every subscription that matches the
|
|
1516
|
+
* mutation's function ref, shard key, and args, returning the rollback
|
|
1517
|
+
* callbacks to invoke if the mutation later fails. Scoping to the same
|
|
1518
|
+
* (fn, shardKey, args) keeps one user's mutation from clobbering another
|
|
1519
|
+
* subscriber's value on the same function (e.g. two users on different rooms).
|
|
1520
|
+
*/
|
|
1521
|
+
applyOptimisticUpdates(functionRef, argsRecord, mutationShardKey, optimistic) {
|
|
1522
|
+
const optimisticRollbacks = [];
|
|
1523
|
+
if (!optimistic) {
|
|
1524
|
+
return optimisticRollbacks;
|
|
1525
|
+
}
|
|
1526
|
+
const mutationArgsKey = stableStringify(argsRecord);
|
|
1527
|
+
for (const state of this.subscriptions.all()) {
|
|
1528
|
+
if (state.fn.__lunoraRef !== functionRef || state.shardKey !== mutationShardKey || state.argsKey !== mutationArgsKey) {
|
|
1529
|
+
continue;
|
|
1530
|
+
}
|
|
1531
|
+
const rollback = applyOptimisticToState(state, optimistic);
|
|
1532
|
+
if (rollback) {
|
|
1533
|
+
optimisticRollbacks.push(rollback);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
return optimisticRollbacks;
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* Run a Convex-parity `optimisticUpdate` callback against a localStore bound
|
|
1540
|
+
* to the live subscription registry, appending each `setQuery` write's
|
|
1541
|
+
* rollback to `optimisticRollbacks` (the same LIFO list the legacy path uses,
|
|
1542
|
+
* unwound on settle/error). A throwing callback unwinds its own partial
|
|
1543
|
+
* writes — LIFO over just the rollbacks it produced — and is swallowed, so a
|
|
1544
|
+
* buggy optimistic update can never fail the mutation or leave a partial
|
|
1545
|
+
* patch live, mirroring the legacy transform's throw handling.
|
|
1546
|
+
*/
|
|
1547
|
+
applyOptimisticUpdate(optimisticUpdate, args, shardKey, optimisticRollbacks) {
|
|
1548
|
+
const { rollbacks, store } = createLocalStore(this.subscriptions, shardKey, writeOptimisticToState, stableStringify);
|
|
1549
|
+
try {
|
|
1550
|
+
optimisticUpdate(store, args);
|
|
1551
|
+
} catch {
|
|
1552
|
+
for (let index = rollbacks.length - 1; index >= 0; index -= 1) {
|
|
1553
|
+
rollbacks[index]?.();
|
|
1554
|
+
}
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
optimisticRollbacks.push(...rollbacks);
|
|
1558
|
+
}
|
|
1559
|
+
getConnection(shardKey) {
|
|
1560
|
+
return this.connections.get(connectionKey(shardKey));
|
|
1561
|
+
}
|
|
1562
|
+
getOrCreateConnection(shardKey) {
|
|
1563
|
+
const key = connectionKey(shardKey);
|
|
1564
|
+
let conn = this.connections.get(key);
|
|
1565
|
+
if (!conn) {
|
|
1566
|
+
conn = {
|
|
1567
|
+
heartbeatTimer: void 0,
|
|
1568
|
+
pendingUnsubscribes: [],
|
|
1569
|
+
reconnect: createReconnect(this.reconnectOptions),
|
|
1570
|
+
reconnectTimer: void 0,
|
|
1571
|
+
shardKey,
|
|
1572
|
+
socket: void 0,
|
|
1573
|
+
wasEverConnected: false,
|
|
1574
|
+
wsState: "idle"
|
|
1575
|
+
};
|
|
1576
|
+
this.connections.set(key, conn);
|
|
1577
|
+
}
|
|
1578
|
+
return conn;
|
|
1579
|
+
}
|
|
1580
|
+
wsUrlFor(shardKey) {
|
|
1581
|
+
const params = [];
|
|
1582
|
+
if (shardKey !== void 0) {
|
|
1583
|
+
params.push(`shard=${encodeURIComponent(shardKey)}`);
|
|
1584
|
+
}
|
|
1585
|
+
if (this.wsToken !== void 0) {
|
|
1586
|
+
params.push(`token=${encodeURIComponent(this.wsToken)}`);
|
|
1587
|
+
}
|
|
1588
|
+
if (params.length === 0) {
|
|
1589
|
+
return this.wsUrl;
|
|
1590
|
+
}
|
|
1591
|
+
const separator = this.wsUrl.includes("?") ? "&" : "?";
|
|
1592
|
+
return `${this.wsUrl}${separator}${params.join("&")}`;
|
|
1593
|
+
}
|
|
1594
|
+
/**
|
|
1595
|
+
* Build the outbound RPC headers: JSON content type, optional bearer auth,
|
|
1596
|
+
* the optional mutation-replay idempotency key, and the D1 read-your-writes
|
|
1597
|
+
* bookmark when the caller opted into `attachBookmark`. The mutation id
|
|
1598
|
+
* rides both the direct send and any offline-queue replay of the same write,
|
|
1599
|
+
* so a mutation the server already committed returns its cached result
|
|
1600
|
+
* instead of running twice.
|
|
1601
|
+
*/
|
|
1602
|
+
rpcRequestHeaders(flags) {
|
|
1603
|
+
const headers = { "content-type": "application/json" };
|
|
1604
|
+
if (this.authToken) {
|
|
1605
|
+
headers["authorization"] = `Bearer ${this.authToken}`;
|
|
1606
|
+
}
|
|
1607
|
+
if (flags.mutationId) {
|
|
1608
|
+
headers["x-lunora-mutation-id"] = flags.mutationId;
|
|
1609
|
+
}
|
|
1610
|
+
if (flags.attachBookmark) {
|
|
1611
|
+
const bookmark = this.bookmark.get();
|
|
1612
|
+
if (bookmark) {
|
|
1613
|
+
headers["x-d1-bookmark"] = bookmark;
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
return headers;
|
|
1617
|
+
}
|
|
1618
|
+
async rpc(functionPath, args, shardKey, flags = {}) {
|
|
1619
|
+
if (!this.fetchImpl) {
|
|
1620
|
+
throw new Error("LunoraClient: no `fetch` implementation available");
|
|
1621
|
+
}
|
|
1622
|
+
const headers = this.rpcRequestHeaders(flags);
|
|
1623
|
+
const response = await this.fetchImpl(joinUrl(this.url, RPC_PATH), {
|
|
1624
|
+
body: JSON.stringify({ args, functionPath, shardKey }),
|
|
1625
|
+
headers,
|
|
1626
|
+
method: "POST"
|
|
1627
|
+
});
|
|
1628
|
+
if (flags.captureBookmark) {
|
|
1629
|
+
const value = response.headers.get("x-d1-bookmark");
|
|
1630
|
+
if (value) {
|
|
1631
|
+
this.bookmark.set(value);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
let body;
|
|
1635
|
+
try {
|
|
1636
|
+
body = await response.json();
|
|
1637
|
+
} catch {
|
|
1638
|
+
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
1639
|
+
throw new Error(`LunoraClient: response was not JSON (status ${response.status.toString()}${statusText})`);
|
|
1640
|
+
}
|
|
1641
|
+
if ("error" in body) {
|
|
1642
|
+
const error = new Error(body.error.message);
|
|
1643
|
+
error.code = body.error.code;
|
|
1644
|
+
throw error;
|
|
1645
|
+
}
|
|
1646
|
+
if (!response.ok) {
|
|
1647
|
+
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
1648
|
+
throw new Error(`LunoraClient: request failed (status ${response.status.toString()}${statusText})`);
|
|
1649
|
+
}
|
|
1650
|
+
return body.result;
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* Authenticated request to a non-RPC admin endpoint (the scheduler list /
|
|
1654
|
+
* cancel routes). Attaches the bearer token, parses JSON, and surfaces the
|
|
1655
|
+
* worker's `{ error: { code, message } }` envelope as a coded `Error` —
|
|
1656
|
+
* mirroring {@link rpc} so callers see the same failure shape.
|
|
1657
|
+
*/
|
|
1658
|
+
async adminFetch(path, method, payload, contentType) {
|
|
1659
|
+
if (!this.fetchImpl) {
|
|
1660
|
+
throw new Error("LunoraClient: no `fetch` implementation available");
|
|
1661
|
+
}
|
|
1662
|
+
const headers = {};
|
|
1663
|
+
if (this.authToken) {
|
|
1664
|
+
headers["authorization"] = `Bearer ${this.authToken}`;
|
|
1665
|
+
}
|
|
1666
|
+
const isBinary = payload instanceof ArrayBuffer || payload instanceof Blob;
|
|
1667
|
+
let requestBody;
|
|
1668
|
+
if (payload === void 0) {
|
|
1669
|
+
requestBody = void 0;
|
|
1670
|
+
} else if (isBinary) {
|
|
1671
|
+
requestBody = payload;
|
|
1672
|
+
if (contentType !== void 0) {
|
|
1673
|
+
headers["content-type"] = contentType;
|
|
1674
|
+
}
|
|
1675
|
+
} else {
|
|
1676
|
+
requestBody = JSON.stringify(payload);
|
|
1677
|
+
headers["content-type"] = "application/json";
|
|
1678
|
+
}
|
|
1679
|
+
const response = await this.fetchImpl(joinUrl(this.url, path), {
|
|
1680
|
+
body: requestBody,
|
|
1681
|
+
headers,
|
|
1682
|
+
method
|
|
1683
|
+
});
|
|
1684
|
+
let body;
|
|
1685
|
+
try {
|
|
1686
|
+
body = await response.json();
|
|
1687
|
+
} catch {
|
|
1688
|
+
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
1689
|
+
throw new Error(`LunoraClient: response was not JSON (status ${response.status.toString()}${statusText})`);
|
|
1690
|
+
}
|
|
1691
|
+
if (typeof body === "object" && body !== null && "error" in body) {
|
|
1692
|
+
const envelope = body.error;
|
|
1693
|
+
const error = new Error(envelope.message ?? "admin request failed");
|
|
1694
|
+
error.code = envelope.code;
|
|
1695
|
+
throw error;
|
|
1696
|
+
}
|
|
1697
|
+
if (!response.ok) {
|
|
1698
|
+
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
1699
|
+
throw new Error(`LunoraClient: admin request failed (status ${response.status.toString()}${statusText})`);
|
|
1700
|
+
}
|
|
1701
|
+
return body;
|
|
1702
|
+
}
|
|
1703
|
+
/**
|
|
1704
|
+
* Resolve the effective connection context for a shard: the most-recently
|
|
1705
|
+
* acquired refcounted holder ({@link acquireConnectionContext}) wins, falling
|
|
1706
|
+
* back to the imperative {@link setConnectionContext} override, then the
|
|
1707
|
+
* client-wide default. Returns `undefined` when none apply.
|
|
1708
|
+
*/
|
|
1709
|
+
effectiveConnectionContext(key) {
|
|
1710
|
+
const holders = this.connectionContextHolders.get(key);
|
|
1711
|
+
if (holders && holders.length > 0) {
|
|
1712
|
+
return holders[holders.length - 1]?.context;
|
|
1713
|
+
}
|
|
1714
|
+
return this.connectionContexts.get(key) ?? this.defaultConnectionContext;
|
|
1715
|
+
}
|
|
1716
|
+
/** Re-send the `connect` envelope for a shard whose effective context just changed (if its socket is open). */
|
|
1717
|
+
refreshConnectionContext(key) {
|
|
1718
|
+
const conn = this.connections.get(key);
|
|
1719
|
+
if (conn?.wsState === "open") {
|
|
1720
|
+
this.sendConnectEnvelope(conn);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
/**
|
|
1724
|
+
* Send the one-shot `connect` envelope on an open shard socket. Always sent
|
|
1725
|
+
* once per socket open, so the server's `onConnect` hooks fire symmetrically
|
|
1726
|
+
* with `onDisconnect` (which the DO dispatches unconditionally at close for
|
|
1727
|
+
* every lifecycle-aware socket). The DO no-ops cheaply when no `onConnect`
|
|
1728
|
+
* hooks are registered, so the single frame costs nothing in the common case.
|
|
1729
|
+
*
|
|
1730
|
+
* The shard's registered context (or the client-wide default) rides along
|
|
1731
|
+
* when one is set — the DO records it on the attachment for replay to
|
|
1732
|
+
* `onDisconnect`. A socket with no registered context still announces itself;
|
|
1733
|
+
* the envelope simply omits `context`, which is optional on the wire.
|
|
1734
|
+
* Register a context — e.g. `setConnectionContext({})` — to attach app state
|
|
1735
|
+
* to the lifecycle dispatch.
|
|
1736
|
+
*/
|
|
1737
|
+
sendConnectEnvelope(conn) {
|
|
1738
|
+
const context = this.effectiveConnectionContext(connectionKey(conn.shardKey));
|
|
1739
|
+
sendOn(conn, {
|
|
1740
|
+
id: "connect",
|
|
1741
|
+
type: "connect",
|
|
1742
|
+
...context === void 0 ? {} : { context }
|
|
1743
|
+
});
|
|
1744
|
+
}
|
|
1745
|
+
ensureSocket(shardKey) {
|
|
1746
|
+
if (this.closed || this.WebSocketImpl === void 0) {
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
const conn = this.getOrCreateConnection(shardKey);
|
|
1750
|
+
if (conn.wsState === "open" || conn.wsState === "connecting") {
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
conn.wsState = "connecting";
|
|
1754
|
+
this.emitConnectionStatus();
|
|
1755
|
+
const socket = new this.WebSocketImpl(this.wsUrlFor(shardKey));
|
|
1756
|
+
conn.socket = socket;
|
|
1757
|
+
socket.addEventListener("open", () => {
|
|
1758
|
+
conn.wsState = "open";
|
|
1759
|
+
conn.wasEverConnected = true;
|
|
1760
|
+
conn.reconnect.reset();
|
|
1761
|
+
this.emitConnectionStatus();
|
|
1762
|
+
this.sendConnectEnvelope(conn);
|
|
1763
|
+
this.markShardPendingAck(shardKey);
|
|
1764
|
+
for (const state of this.subscriptions.all()) {
|
|
1765
|
+
if (connectionKey(state.shardKey) === connectionKey(shardKey)) {
|
|
1766
|
+
this.sendSubscribeIfOpen(state);
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
if (conn.pendingUnsubscribes.length > 0) {
|
|
1770
|
+
const pending = conn.pendingUnsubscribes;
|
|
1771
|
+
conn.pendingUnsubscribes = [];
|
|
1772
|
+
for (const id of pending) {
|
|
1773
|
+
sendOn(conn, { id, type: "unsubscribe" });
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
if (conn.pendingStreams && conn.pendingStreams.length > 0) {
|
|
1777
|
+
const pending = conn.pendingStreams;
|
|
1778
|
+
conn.pendingStreams = [];
|
|
1779
|
+
for (const message of pending) {
|
|
1780
|
+
sendOn(conn, message);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
const byTopic = this.whisperHandlers.get(connectionKey(shardKey));
|
|
1784
|
+
if (byTopic) {
|
|
1785
|
+
for (const topic of byTopic.keys()) {
|
|
1786
|
+
sendOn(conn, { topic, type: "whisper_subscribe" });
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
this.flushOfflineQueue(shardKey).catch(() => void 0);
|
|
1790
|
+
this.startHeartbeat(conn);
|
|
1791
|
+
});
|
|
1792
|
+
socket.addEventListener("message", (event) => {
|
|
1793
|
+
this.handleServerMessage(event.data, shardKey);
|
|
1794
|
+
});
|
|
1795
|
+
socket.addEventListener("close", (event) => {
|
|
1796
|
+
if (event?.code === 4001) {
|
|
1797
|
+
this.notifyTokenExpired();
|
|
1798
|
+
}
|
|
1799
|
+
this.handleDisconnect(conn);
|
|
1800
|
+
});
|
|
1801
|
+
socket.addEventListener("error", () => {
|
|
1802
|
+
if (conn.wsState === "connecting" || conn.wsState === "open") {
|
|
1803
|
+
this.handleDisconnect(conn);
|
|
1804
|
+
}
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
handleDisconnect(conn) {
|
|
1808
|
+
if (this.closed) {
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
if (conn.wsState === "idle" || conn.wsState === "closed") {
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
this.stopHeartbeat(conn);
|
|
1815
|
+
conn.socket = void 0;
|
|
1816
|
+
conn.wsState = "idle";
|
|
1817
|
+
this.emitConnectionStatus();
|
|
1818
|
+
this.markShardPendingAck(conn.shardKey);
|
|
1819
|
+
if (this.WebSocketImpl === void 0) {
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
const delay = conn.reconnect.next();
|
|
1823
|
+
conn.reconnectTimer = setTimeout(() => {
|
|
1824
|
+
conn.reconnectTimer = void 0;
|
|
1825
|
+
this.ensureSocket(conn.shardKey);
|
|
1826
|
+
}, delay);
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* Begin the keepalive heartbeat on an open connection. Each tick sends a
|
|
1830
|
+
* {@link WS_KEEPALIVE_PING} text frame the server answers from its
|
|
1831
|
+
* hibernation auto-response without waking the DO. A no-op when the
|
|
1832
|
+
* heartbeat is disabled (an interval of zero or less); idempotent — any
|
|
1833
|
+
* existing timer is cleared first so a reconnect can't leak intervals.
|
|
1834
|
+
*/
|
|
1835
|
+
startHeartbeat(conn) {
|
|
1836
|
+
this.stopHeartbeat(conn);
|
|
1837
|
+
if (this.heartbeatIntervalMs <= 0) {
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
conn.heartbeatTimer = setInterval(() => {
|
|
1841
|
+
if (conn.wsState !== "open" || !conn.socket) {
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
try {
|
|
1845
|
+
conn.socket.send(WS_KEEPALIVE_PING);
|
|
1846
|
+
} catch {
|
|
1847
|
+
}
|
|
1848
|
+
}, this.heartbeatIntervalMs);
|
|
1849
|
+
}
|
|
1850
|
+
/** Clear a connection's keepalive timer, if any. Safe to call repeatedly. */
|
|
1851
|
+
// eslint-disable-next-line class-methods-use-this -- cohesive connection helper; pairs with startHeartbeat
|
|
1852
|
+
stopHeartbeat(conn) {
|
|
1853
|
+
if (conn.heartbeatTimer !== void 0) {
|
|
1854
|
+
clearInterval(conn.heartbeatTimer);
|
|
1855
|
+
conn.heartbeatTimer = void 0;
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
/** Mark every subscription bound to `shardKey` as needing a fresh ack. */
|
|
1859
|
+
markShardPendingAck(shardKey) {
|
|
1860
|
+
const key = connectionKey(shardKey);
|
|
1861
|
+
for (const state of this.subscriptions.all()) {
|
|
1862
|
+
if (connectionKey(state.shardKey) === key) {
|
|
1863
|
+
state.acked = false;
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
sendSubscribeIfOpen(state) {
|
|
1868
|
+
const conn = this.getConnection(state.shardKey);
|
|
1869
|
+
if (conn?.wsState !== "open" || state.acked) {
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
const table = state.fn.__lunoraTable ?? state.fn.__lunoraRef;
|
|
1873
|
+
sendOn(conn, {
|
|
1874
|
+
id: state.id,
|
|
1875
|
+
// `sinceSeq` rides along when we hold a persisted cursor for this
|
|
1876
|
+
// sub (a hydrated read or an earlier frame), so the server can
|
|
1877
|
+
// resume instead of re-snapshotting. Omitted on a cold sub.
|
|
1878
|
+
query: {
|
|
1879
|
+
args: state.args,
|
|
1880
|
+
functionPath: state.fn.__lunoraRef,
|
|
1881
|
+
table,
|
|
1882
|
+
...state.serverCursor === void 0 ? {} : { sinceSeq: state.serverCursor },
|
|
1883
|
+
...state.serverEpoch === void 0 ? {} : { sinceEpoch: state.serverEpoch }
|
|
1884
|
+
},
|
|
1885
|
+
type: "subscribe"
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
handleServerMessage(raw, shardKey) {
|
|
1889
|
+
const text = decodeServerFrame(raw);
|
|
1890
|
+
if (text === void 0) {
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
let message;
|
|
1894
|
+
try {
|
|
1895
|
+
message = JSON.parse(text);
|
|
1896
|
+
} catch {
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
switch (message.type) {
|
|
1900
|
+
case "ack": {
|
|
1901
|
+
const state = this.subscriptions.getById(message.id);
|
|
1902
|
+
if (state) {
|
|
1903
|
+
state.acked = true;
|
|
1904
|
+
}
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
case "chunk": {
|
|
1908
|
+
const { data, id } = message;
|
|
1909
|
+
const stream = this.streams.get(id);
|
|
1910
|
+
stream?.handle.push(data);
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
case "complete": {
|
|
1914
|
+
this.handleCompleteMessage(message.id);
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
case "data":
|
|
1918
|
+
case "delta": {
|
|
1919
|
+
this.handleDataMessage(message);
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
case "error": {
|
|
1923
|
+
this.handleErrorMessage(message);
|
|
1924
|
+
break;
|
|
1925
|
+
}
|
|
1926
|
+
case "resume": {
|
|
1927
|
+
this.handleResumeMessage(message);
|
|
1928
|
+
break;
|
|
1929
|
+
}
|
|
1930
|
+
case "whisper": {
|
|
1931
|
+
this.dispatchWhisper(message, shardKey);
|
|
1932
|
+
break;
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
handleErrorMessage(message) {
|
|
1937
|
+
const errorCode = message.error?.code;
|
|
1938
|
+
if (errorCode === "TOKEN_EXPIRED") {
|
|
1939
|
+
this.notifyTokenExpired();
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
const { id } = message;
|
|
1943
|
+
const stream = id === void 0 ? void 0 : this.streams.get(id);
|
|
1944
|
+
if (stream && id !== void 0) {
|
|
1945
|
+
stream.handle.fail(buildStreamError(message));
|
|
1946
|
+
this.streams.delete(id);
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
const state = id === void 0 ? void 0 : this.subscriptions.getById(id);
|
|
1950
|
+
if (state) {
|
|
1951
|
+
const error = buildSubscriptionError(message);
|
|
1952
|
+
for (const errorCallback of state.errorCallbacks) {
|
|
1953
|
+
try {
|
|
1954
|
+
errorCallback(error);
|
|
1955
|
+
} catch {
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
handleDataMessage(message) {
|
|
1961
|
+
const { id } = message;
|
|
1962
|
+
const state = id ? this.subscriptions.getById(id) : void 0;
|
|
1963
|
+
if (!state) {
|
|
1964
|
+
return;
|
|
1965
|
+
}
|
|
1966
|
+
const payload = this.resolveDataPayload(message, state);
|
|
1967
|
+
state.lastValue = payload;
|
|
1968
|
+
state.serverVersion += 1;
|
|
1969
|
+
if (message.cursor !== void 0) {
|
|
1970
|
+
state.serverCursor = message.cursor;
|
|
1971
|
+
}
|
|
1972
|
+
if (message.epoch !== void 0) {
|
|
1973
|
+
state.serverEpoch = message.epoch;
|
|
1974
|
+
}
|
|
1975
|
+
this.persistQueryValue(state);
|
|
1976
|
+
for (const callback of state.callbacks) {
|
|
1977
|
+
try {
|
|
1978
|
+
callback(payload);
|
|
1979
|
+
} catch {
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
/**
|
|
1984
|
+
* Handle a `resume` frame (Pillar 1b): the server proved nothing the
|
|
1985
|
+
* subscription reads changed since our `sinceSeq`, so the cached value is
|
|
1986
|
+
* still current. We keep `lastValue` as-is, mark the sub acked, and advance
|
|
1987
|
+
* the cursor (re-persisting so the next reconnect resumes from the newer
|
|
1988
|
+
* watermark). No callback fires — the value didn't change, and `subscribe()`
|
|
1989
|
+
* already replayed the cached value to every consumer synchronously.
|
|
1990
|
+
*/
|
|
1991
|
+
handleResumeMessage(message) {
|
|
1992
|
+
const state = this.subscriptions.getById(message.id);
|
|
1993
|
+
if (!state) {
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
state.acked = true;
|
|
1997
|
+
if (message.cursor !== void 0 && message.cursor !== state.serverCursor || message.epoch !== void 0 && message.epoch !== state.serverEpoch) {
|
|
1998
|
+
if (message.cursor !== void 0) {
|
|
1999
|
+
state.serverCursor = message.cursor;
|
|
2000
|
+
}
|
|
2001
|
+
if (message.epoch !== void 0) {
|
|
2002
|
+
state.serverEpoch = message.epoch;
|
|
2003
|
+
}
|
|
2004
|
+
this.persistQueryValue(state);
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
/**
|
|
2008
|
+
* Resolve the value to publish for a `data`/`delta` frame.
|
|
2009
|
+
*
|
|
2010
|
+
* A `data` frame is an authoritative snapshot (the server re-execution path)
|
|
2011
|
+
* and always replaces the cached value wholesale. A `delta` frame carrying a
|
|
2012
|
+
* structured `MutationDelta` (the `broadcastDelta` row-change path) is
|
|
2013
|
+
* merged incrementally into the cached list — preserving order, no dup/loss —
|
|
2014
|
+
* so each subscription (including every paginated page) updates by delta
|
|
2015
|
+
* rather than a full re-send. We fall back to full replacement when the
|
|
2016
|
+
* delta isn't a recognisable row change, when there's no cached value yet,
|
|
2017
|
+
* or when it can't be applied cleanly against the current cached shape.
|
|
2018
|
+
*/
|
|
2019
|
+
// eslint-disable-next-line class-methods-use-this -- instance method for symmetry with the other message handlers; reads no shared client state
|
|
2020
|
+
resolveDataPayload(message, state) {
|
|
2021
|
+
if ("data" in message && message.data !== void 0) {
|
|
2022
|
+
return message.data;
|
|
2023
|
+
}
|
|
2024
|
+
const { delta } = message;
|
|
2025
|
+
if (isMutationDelta(delta) && state.lastValue !== void 0) {
|
|
2026
|
+
const merged = applyDelta(state.lastValue, delta);
|
|
2027
|
+
if (merged !== void 0) {
|
|
2028
|
+
return merged;
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
return delta;
|
|
2032
|
+
}
|
|
2033
|
+
/** Route an inbound whisper to the topic's handlers on the originating shard. */
|
|
2034
|
+
dispatchWhisper(message, shardKey) {
|
|
2035
|
+
const handlers = this.whisperHandlers.get(connectionKey(shardKey))?.get(message.topic);
|
|
2036
|
+
if (!handlers) {
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
for (const handler of handlers) {
|
|
2040
|
+
try {
|
|
2041
|
+
handler(message.data, message.from);
|
|
2042
|
+
} catch {
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
/** Notify every {@link onTokenExpired} listener (best-effort, listener throws swallowed). */
|
|
2047
|
+
notifyTokenExpired() {
|
|
2048
|
+
for (const listener of this.tokenExpiredListeners) {
|
|
2049
|
+
try {
|
|
2050
|
+
listener();
|
|
2051
|
+
} catch {
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
handleCompleteMessage(id) {
|
|
2056
|
+
const stream = this.streams.get(id);
|
|
2057
|
+
if (stream) {
|
|
2058
|
+
stream.handle.complete();
|
|
2059
|
+
this.streams.delete(id);
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
const state = this.subscriptions.getById(id);
|
|
2063
|
+
if (state) {
|
|
2064
|
+
this.subscriptions.remove(state);
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
unpersist(id) {
|
|
2068
|
+
if (id) {
|
|
2069
|
+
this.persistence?.remove(id).catch((error) => {
|
|
2070
|
+
reportPersistenceError(this.onPersistenceError, "remove", error, id);
|
|
2071
|
+
});
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2075
|
+
* Stable, non-reversible fingerprint of the current auth identity used to
|
|
2076
|
+
* stamp queued offline writes. `null` (signed out) is its own identity and
|
|
2077
|
+
* never matches a bearer-token fingerprint. The raw token is never stored;
|
|
2078
|
+
* a length-prefixed FNV-1a hash is enough to detect an identity *change*
|
|
2079
|
+
* without keeping the credential around in the queue map.
|
|
2080
|
+
*/
|
|
2081
|
+
// `null` is the distinct "signed out" identity (separate from `undefined`,
|
|
2082
|
+
// which means "not stamped / hydrated"); the two must not be conflated.
|
|
2083
|
+
identityFingerprint() {
|
|
2084
|
+
const token = this.authToken;
|
|
2085
|
+
if (token === null) {
|
|
2086
|
+
return null;
|
|
2087
|
+
}
|
|
2088
|
+
let hash = 2166136261;
|
|
2089
|
+
for (let index = 0; index < token.length; index += 1) {
|
|
2090
|
+
hash ^= token.charCodeAt(index);
|
|
2091
|
+
hash = Math.imul(hash, 16777619);
|
|
2092
|
+
}
|
|
2093
|
+
return `${token.length.toString(36)}:${(hash >>> 0).toString(36)}`;
|
|
2094
|
+
}
|
|
2095
|
+
/**
|
|
2096
|
+
* Drain every in-memory offline write and reject it because the auth
|
|
2097
|
+
* identity changed. Durable entries are also dropped from persistence so a
|
|
2098
|
+
* later `hydrate` can't resurrect another user's writes. Stamps are cleared
|
|
2099
|
+
* alongside. Persisted entries restored without a live awaiter still get
|
|
2100
|
+
* unpersisted here.
|
|
2101
|
+
*/
|
|
2102
|
+
rejectQueuedForIdentityChange() {
|
|
2103
|
+
const drained = this.offlineQueue.drain();
|
|
2104
|
+
for (const item of drained) {
|
|
2105
|
+
this.queuedIdentities.delete(item.id ?? "");
|
|
2106
|
+
this.unpersist(item.id);
|
|
2107
|
+
const error = new Error("offline mutation discarded: auth identity changed before replay");
|
|
2108
|
+
error.code = "OFFLINE_IDENTITY_CHANGED";
|
|
2109
|
+
item.reject(error);
|
|
2110
|
+
}
|
|
2111
|
+
this.clearQueryCacheForIdentityChange();
|
|
2112
|
+
}
|
|
2113
|
+
/**
|
|
2114
|
+
* Drop the durable read cache on an identity change so a cached value stamped
|
|
2115
|
+
* under the previous identity can never hydrate into a new session. Clears
|
|
2116
|
+
* the in-flight write batch and the not-yet-consumed hydrated entries too;
|
|
2117
|
+
* the durable `clear()` is best-effort.
|
|
2118
|
+
*/
|
|
2119
|
+
clearQueryCacheForIdentityChange() {
|
|
2120
|
+
if (this.cacheFlushTimer !== void 0) {
|
|
2121
|
+
clearTimeout(this.cacheFlushTimer);
|
|
2122
|
+
this.cacheFlushTimer = void 0;
|
|
2123
|
+
}
|
|
2124
|
+
this.pendingCacheWrites.clear();
|
|
2125
|
+
this.hydratedQueryCache.clear();
|
|
2126
|
+
this.queryCache?.clear().catch(() => void 0);
|
|
2127
|
+
}
|
|
2128
|
+
async flushOfflineQueue(shardKey) {
|
|
2129
|
+
const key = connectionKey(shardKey);
|
|
2130
|
+
const drained = this.offlineQueue.drain((item) => connectionKey(item.shardKey) === key);
|
|
2131
|
+
for (let index = 0; index < drained.length; index += 1) {
|
|
2132
|
+
const item = drained[index];
|
|
2133
|
+
if (!item) {
|
|
2134
|
+
continue;
|
|
2135
|
+
}
|
|
2136
|
+
const currentIdentity = this.identityFingerprint();
|
|
2137
|
+
const liveStamp = item.id === void 0 ? void 0 : this.queuedIdentities.get(item.id);
|
|
2138
|
+
const stamped = liveStamp === void 0 ? item.identity : liveStamp;
|
|
2139
|
+
if (stamped !== void 0 && stamped !== currentIdentity) {
|
|
2140
|
+
this.queuedIdentities.delete(item.id ?? "");
|
|
2141
|
+
this.unpersist(item.id);
|
|
2142
|
+
const error = new Error("offline mutation skipped: auth identity changed before replay");
|
|
2143
|
+
error.code = "OFFLINE_IDENTITY_CHANGED";
|
|
2144
|
+
item.reject(error);
|
|
2145
|
+
continue;
|
|
2146
|
+
}
|
|
2147
|
+
this.queuedIdentities.delete(item.id ?? "");
|
|
2148
|
+
try {
|
|
2149
|
+
const value = await this.rpc(item.functionPath, item.args, item.shardKey, { captureBookmark: true, mutationId: item.id });
|
|
2150
|
+
this.unpersist(item.id);
|
|
2151
|
+
item.resolve(value);
|
|
2152
|
+
} catch (error) {
|
|
2153
|
+
if (error.code !== void 0) {
|
|
2154
|
+
this.unpersist(item.id);
|
|
2155
|
+
item.reject(error);
|
|
2156
|
+
continue;
|
|
2157
|
+
}
|
|
2158
|
+
this.offlineQueue.requeue(drained.slice(index));
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
export { LunoraClient };
|