@reatomized/convex 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +110 -2
  3. package/dist/browser/logging.d.ts +39 -0
  4. package/dist/browser/logging.js +117 -0
  5. package/dist/browser/reatom/auth.d.ts +75 -0
  6. package/dist/browser/reatom/auth.js +329 -0
  7. package/dist/browser/reatom/client.d.ts +348 -0
  8. package/dist/browser/reatom/client.js +1138 -0
  9. package/dist/browser/reatom/connection_hooks.d.ts +11 -0
  10. package/dist/browser/reatom/connection_hooks.js +15 -0
  11. package/dist/browser/reatom/errors.d.ts +3 -0
  12. package/dist/browser/reatom/errors.js +8 -0
  13. package/dist/browser/reatom/index.d.ts +6 -0
  14. package/dist/browser/reatom/index.js +2 -0
  15. package/dist/browser/reatom/optimistic.d.ts +44 -0
  16. package/dist/browser/reatom/optimistic.js +70 -0
  17. package/dist/browser/reatom/paginated.d.ts +56 -0
  18. package/dist/browser/reatom/paginated.js +162 -0
  19. package/dist/browser/reatom/socket.d.ts +52 -0
  20. package/dist/browser/reatom/socket.js +523 -0
  21. package/dist/browser/sync/function_result.d.ts +21 -0
  22. package/dist/browser/sync/function_result.js +1 -0
  23. package/dist/browser/sync/metrics.d.ts +9 -0
  24. package/dist/browser/sync/metrics.js +43 -0
  25. package/dist/browser/sync/pagination.d.ts +22 -0
  26. package/dist/browser/sync/pagination.js +20 -0
  27. package/dist/browser/sync/protocol.d.ts +206 -0
  28. package/dist/browser/sync/protocol.js +75 -0
  29. package/dist/browser/sync/session.d.ts +1 -0
  30. package/dist/browser/sync/session.js +10 -0
  31. package/dist/browser/sync/udf_path_utils.d.ts +30 -0
  32. package/dist/browser/sync/udf_path_utils.js +38 -0
  33. package/dist/common/index.d.ts +11 -0
  34. package/dist/common/index.js +52 -0
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.js +1 -0
  37. package/dist/vendor/jwt-decode/LICENSE +21 -0
  38. package/dist/vendor/jwt-decode/index.d.ts +23 -0
  39. package/dist/vendor/jwt-decode/index.js +57 -0
  40. package/dist/vendor/long.d.ts +16 -0
  41. package/dist/vendor/long.js +297 -0
  42. package/package.json +59 -4
@@ -0,0 +1,1138 @@
1
+ /**
2
+ * A Convex sync client whose state lives in Reatom atoms.
3
+ *
4
+ * The query set is the set of connected query atoms: reading a query's
5
+ * `result` in a subscribed graph subscribes it on the server, and losing the
6
+ * last reader unsubscribes it. Query set changes made in one microtask go out
7
+ * as one `ModifyQuerySet`. A server transition updates only the queries it
8
+ * names; optimistic updates are replayed only while some are pending.
9
+ *
10
+ * State is per Reatom context: call the client's methods inside a frame of
11
+ * the context that owns it.
12
+ */
13
+ import { action, atom, computed, context, noop, peek, wrap, } from "@reatom/core";
14
+ import { version } from "../../index.js";
15
+ import { parseArgs, validateDeploymentUrl } from "../../common/index.js";
16
+ import { getFunctionName, } from "convex/server";
17
+ import { convexToJson, jsonToConvex } from "convex/values";
18
+ import { jwtDecode } from "../../vendor/jwt-decode/index.js";
19
+ import { Long } from "../../vendor/long.js";
20
+ import { instantiateDefaultLogger, instantiateNoopLogger, logFatalError, logForFunction, } from "../logging.js";
21
+ import { getMarksReport, mark as markPerformance, } from "../sync/metrics.js";
22
+ import { newSessionId } from "../sync/session.js";
23
+ import { canonicalizeUdfPath, serializePaginatedPathAndArgs, } from "../sync/udf_path_utils.js";
24
+ import { reatomAuth } from "./auth.js";
25
+ import { withConnectionHooks } from "./connection_hooks.js";
26
+ import { functionError } from "./errors.js";
27
+ import { OptimisticLocalStoreImpl, } from "./optimistic.js";
28
+ import { reatomPaginatedQuery, } from "./paginated.js";
29
+ import { reatomSocket } from "./socket.js";
30
+ const functionName = (ref) => typeof ref === "string" ? ref : getFunctionName(ref);
31
+ const INITIAL_VERSION = {
32
+ querySet: 0,
33
+ ts: Long.fromNumber(0),
34
+ identity: 0,
35
+ };
36
+ /** Entries by token, held weakly where the runtime allows it. */
37
+ class WeakCache {
38
+ refs = new Map();
39
+ registry = typeof FinalizationRegistry === "undefined"
40
+ ? null
41
+ : new FinalizationRegistry((token) => {
42
+ if (!this.refs.get(token)?.deref())
43
+ this.refs.delete(token);
44
+ });
45
+ get(token) {
46
+ return this.refs.get(token)?.deref();
47
+ }
48
+ set(value) {
49
+ if (typeof WeakRef === "undefined") {
50
+ this.refs.set(value.token, { deref: () => value });
51
+ return;
52
+ }
53
+ this.refs.set(value.token, new WeakRef(value));
54
+ this.registry?.register(value, value.token);
55
+ }
56
+ /** Forgets an unused value where it can't be collected. */
57
+ release(value) {
58
+ if (typeof WeakRef === "undefined" && this.get(value.token) === value) {
59
+ this.refs.delete(value.token);
60
+ }
61
+ }
62
+ }
63
+ function webSocketUri(address) {
64
+ const i = address.search("://");
65
+ if (i === -1) {
66
+ throw new Error("Provided address was not an absolute URL.");
67
+ }
68
+ const origin = address.substring(i + 3);
69
+ const protocol = address.substring(0, i);
70
+ let wsProtocol;
71
+ if (protocol === "http") {
72
+ wsProtocol = "ws";
73
+ }
74
+ else if (protocol === "https") {
75
+ wsProtocol = "wss";
76
+ }
77
+ else {
78
+ throw new Error(`Unknown parent protocol ${protocol}`);
79
+ }
80
+ return `${wsProtocol}://${origin}/api/${version}/sync`;
81
+ }
82
+ /**
83
+ * Create a Convex client whose state lives in Reatom atoms.
84
+ *
85
+ * The client connects on first use in a context: a query read, a request,
86
+ * `setAuth` or a `connectionState` subscriber. Call its methods inside a
87
+ * Reatom frame; each context gets its own connection.
88
+ *
89
+ * ```ts
90
+ * const convex = reatomConvexClient(import.meta.env.VITE_CONVEX_URL);
91
+ * const notes = computed(() => convex.query(api.notes.list, {}).data());
92
+ * ```
93
+ *
94
+ * @param address - The url of your Convex deployment, e.g.
95
+ * `https://small-mouse-123.convex.cloud`.
96
+ * @param options - See {@link BaseConvexClientOptions}.
97
+ * @param name - A prefix for the client's atom names.
98
+ *
99
+ * @public
100
+ */
101
+ export function reatomConvexClient(address, options = {}, name = "convex") {
102
+ if (typeof address === "object") {
103
+ throw new Error("Passing a ClientConfig object is no longer supported. Pass the URL of the Convex deployment as a string directly.");
104
+ }
105
+ if (options.skipConvexDeploymentUrlCheck !== true) {
106
+ validateDeploymentUrl(address);
107
+ }
108
+ if (!options.webSocketConstructor && typeof WebSocket === "undefined") {
109
+ throw new Error("No WebSocket global variable defined! To use Convex in an environment without WebSocket try the HTTP client: https://docs.convex.dev/api/classes/browser.ConvexHttpClient");
110
+ }
111
+ const webSocketConstructor = options.webSocketConstructor || WebSocket;
112
+ const debug = options.reportDebugInfoToConvex ?? false;
113
+ const logger = options.logger === false
114
+ ? instantiateNoopLogger({ verbose: options.verbose ?? false })
115
+ : options.logger !== true && options.logger
116
+ ? options.logger
117
+ : instantiateDefaultLogger({ verbose: options.verbose ?? false });
118
+ const wsUri = webSocketUri(address);
119
+ const hasWindow = typeof window !== "undefined" &&
120
+ typeof window.addEventListener !== "undefined";
121
+ if (!hasWindow && options.unsavedChangesWarning === true) {
122
+ throw new Error("unsavedChangesWarning requested, but window.addEventListener not found! Remove {unsavedChangesWarning: true} from Convex client options.");
123
+ }
124
+ const querySetVersion = atom(0, `${name}.querySetVersion`);
125
+ const identityVersion = atom(0, `${name}.identityVersion`);
126
+ /** The version of the query results received on the current connection. */
127
+ const remoteVersion = atom(INITIAL_VERSION, `${name}.remoteVersion`);
128
+ const maxObservedTimestamp = atom(undefined, `${name}.maxObservedTimestamp`);
129
+ /** The token the server is told about. */
130
+ const authToken = atom(undefined, `${name}.authToken`);
131
+ /**
132
+ * Server side of the two-phase auth: `null` while a token waits for the
133
+ * server, then whether it accepted it. `false` without auth.
134
+ */
135
+ const isAuthenticated = atom(false, `${name}.isAuthenticated`);
136
+ /** The server rejected a confirmed token and a replacement is being fetched. */
137
+ const isRefreshingAuth = atom(false, `${name}.isRefreshingAuth`);
138
+ /** Mutations and actions sent or waiting to be sent. */
139
+ const requests = atom(new Map(), `${name}.requests`);
140
+ /** Optimistic updates of mutations the client has not seen reflected yet. */
141
+ const optimisticUpdates = atom([], `${name}.optimisticUpdates`);
142
+ const runtime = atom(() => ({
143
+ root: context(),
144
+ started: false,
145
+ closed: false,
146
+ sessionId: newSessionId(),
147
+ entries: new WeakCache(),
148
+ paginated: new WeakCache(),
149
+ byId: new Map(),
150
+ nextQueryId: 0,
151
+ pending: new Map(),
152
+ flushScheduled: false,
153
+ flush: noop,
154
+ paused: false,
155
+ outstandingQueriesOlderThanRestart: new Set(),
156
+ outstandingAuthOlderThanRestart: false,
157
+ clearAuthOnResume: false,
158
+ requestsOlderThanRestart: new Set(),
159
+ overlay: new Map(),
160
+ nextRequestId: 0,
161
+ firstMessageReceived: false,
162
+ transitionHandlers: new Set(),
163
+ onBeforeUnload: null,
164
+ }), `${name}._runtime`);
165
+ const lookup = (token) => peek(runtime).entries.get(token);
166
+ /** Shows `raw` behind `entry.result`; returns whether it changed. */
167
+ const setVisible = (entry, raw) => {
168
+ if (entry.visible === raw)
169
+ return false;
170
+ entry.visible = raw;
171
+ entry.result.set(raw);
172
+ return true;
173
+ };
174
+ const emitTransition = (transition) => {
175
+ const handlers = peek(runtime).transitionHandlers;
176
+ if (handlers.size === 0)
177
+ return;
178
+ const value = transition();
179
+ for (const handler of handlers)
180
+ handler(value);
181
+ };
182
+ const mark = (markName) => {
183
+ if (debug)
184
+ markPerformance(markName, peek(runtime).sessionId);
185
+ };
186
+ // Optimistic updates
187
+ const serverResults = {
188
+ get(token) {
189
+ const entry = lookup(token);
190
+ return entry?.id === null ? undefined : entry?.server;
191
+ },
192
+ *all() {
193
+ for (const entry of peek(runtime).byId.values()) {
194
+ if (entry.server === undefined)
195
+ continue;
196
+ yield {
197
+ token: entry.token,
198
+ udfPath: entry.udfPath,
199
+ args: entry.args,
200
+ result: entry.server,
201
+ };
202
+ }
203
+ },
204
+ };
205
+ /**
206
+ * Shows the server results of `changed` under the optimistic updates still
207
+ * pending, dropping those of `completed` requests.
208
+ */
209
+ const ingest = (changed, completed,
210
+ // Queries the server removed: shown, but not reported, as in convex@1.48.0.
211
+ removed = new Set()) => {
212
+ const rt = peek(runtime);
213
+ let updates = optimisticUpdates();
214
+ if (completed.size > 0 && updates.length > 0) {
215
+ updates = optimisticUpdates.set(updates.filter(({ mutationId }) => !completed.has(mutationId)));
216
+ }
217
+ const queries = [];
218
+ const show = (entry, raw) => {
219
+ if (setVisible(entry, raw) && !removed.has(entry)) {
220
+ queries.push({
221
+ token: entry.token,
222
+ modification: { kind: "Updated", result: raw },
223
+ });
224
+ }
225
+ };
226
+ if (updates.length === 0 && rt.overlay.size === 0) {
227
+ for (const entry of changed)
228
+ show(entry, entry.server);
229
+ }
230
+ else {
231
+ const previous = rt.overlay;
232
+ const overlay = (rt.overlay = new Map());
233
+ const store = new OptimisticLocalStoreImpl(overlay, serverResults);
234
+ for (const { update } of updates)
235
+ update(store);
236
+ const tokens = new Set([...previous.keys(), ...overlay.keys()]);
237
+ for (const entry of changed)
238
+ tokens.add(entry.token);
239
+ for (const token of tokens) {
240
+ const entry = lookup(token);
241
+ const local = overlay.get(token);
242
+ if (entry) {
243
+ show(entry, local ? local.result : entry.server);
244
+ }
245
+ else if (local && local.result !== previous.get(token)?.result) {
246
+ queries.push({
247
+ token,
248
+ modification: { kind: "Updated", result: local.result },
249
+ });
250
+ }
251
+ }
252
+ }
253
+ emitTransition(() => ({
254
+ queries,
255
+ reflectedMutations: Array.from(completed, ([requestId, result]) => ({
256
+ requestId,
257
+ result,
258
+ })),
259
+ timestamp: remoteVersion().ts,
260
+ }));
261
+ };
262
+ const applyOptimisticUpdate = (update, mutationId) => {
263
+ optimisticUpdates.set((list) => [...list, { update, mutationId }]);
264
+ const { overlay } = peek(runtime);
265
+ const store = new OptimisticLocalStoreImpl(overlay, serverResults);
266
+ update(store);
267
+ const queries = [];
268
+ for (const token of store.modifiedQueries) {
269
+ const local = overlay.get(token);
270
+ const entry = lookup(token);
271
+ if (entry)
272
+ setVisible(entry, local.result);
273
+ queries.push({
274
+ token,
275
+ modification: { kind: "Updated", result: local.result },
276
+ });
277
+ }
278
+ emitTransition(() => ({
279
+ queries,
280
+ reflectedMutations: [],
281
+ timestamp: remoteVersion().ts,
282
+ }));
283
+ };
284
+ // Query set
285
+ const addQuery = (entry) => ({
286
+ type: "Add",
287
+ queryId: entry.id,
288
+ udfPath: entry.udfPath,
289
+ args: [convexToJson(entry.args)],
290
+ journal: entry.journal,
291
+ componentPath: entry.componentPath,
292
+ });
293
+ /** The server no longer has `entry`, or never will. */
294
+ const forget = (entry) => {
295
+ const { rt } = entry;
296
+ rt.byId.delete(entry.id);
297
+ rt.outstandingQueriesOlderThanRestart.delete(entry.id);
298
+ entry.id = null;
299
+ entry.op = null;
300
+ entry.server = undefined;
301
+ entry.journal = undefined;
302
+ setVisible(entry, rt.overlay.get(entry.token)?.result);
303
+ rt.entries.release(entry);
304
+ };
305
+ const takePending = (rt) => {
306
+ const modifications = [];
307
+ for (const entry of rt.pending.values()) {
308
+ if (entry.op === "Add") {
309
+ modifications.push(addQuery(entry));
310
+ entry.op = null;
311
+ }
312
+ else {
313
+ modifications.push({ type: "Remove", queryId: entry.id });
314
+ forget(entry);
315
+ }
316
+ }
317
+ rt.pending.clear();
318
+ return modifications;
319
+ };
320
+ const flush = () => {
321
+ const rt = peek(runtime);
322
+ rt.flushScheduled = false;
323
+ if (rt.pending.size === 0 || rt.paused || rt.closed)
324
+ return;
325
+ const baseVersion = querySetVersion();
326
+ const modification = {
327
+ type: "ModifyQuerySet",
328
+ baseVersion,
329
+ newVersion: baseVersion + 1,
330
+ modifications: takePending(rt),
331
+ };
332
+ querySetVersion.set(baseVersion + 1);
333
+ socket.send(modification);
334
+ };
335
+ const scheduleFlush = (rt) => {
336
+ if (rt.flushScheduled)
337
+ return;
338
+ rt.flushScheduled = true;
339
+ if (rt.flush === noop)
340
+ rt.flush = wrap(flush, rt.root);
341
+ queueMicrotask(rt.flush);
342
+ };
343
+ const connectEntry = (entry) => {
344
+ const { rt } = entry;
345
+ if (!rt.started)
346
+ start();
347
+ if (rt.closed)
348
+ return;
349
+ // Resubscribed before its removal went out: the server still has it.
350
+ if (entry.op === "Remove") {
351
+ entry.op = null;
352
+ rt.pending.delete(entry.id);
353
+ return;
354
+ }
355
+ const queryId = rt.nextQueryId++;
356
+ entry.id = queryId;
357
+ entry.op = "Add";
358
+ rt.byId.set(queryId, entry);
359
+ rt.pending.set(queryId, entry);
360
+ scheduleFlush(rt);
361
+ };
362
+ const disconnectEntry = (entry) => {
363
+ const { rt } = entry;
364
+ if (entry.id === null)
365
+ return;
366
+ // Never sent: nothing to remove.
367
+ if (entry.op === "Add") {
368
+ rt.pending.delete(entry.id);
369
+ forget(entry);
370
+ return;
371
+ }
372
+ rt.outstandingQueriesOlderThanRestart.delete(entry.id);
373
+ entry.op = "Remove";
374
+ rt.pending.set(entry.id, entry);
375
+ scheduleFlush(rt);
376
+ };
377
+ const createEntry = (rt, token, udfPath, args, queryOptions) => {
378
+ const visible = rt.overlay.get(token)?.result;
379
+ const result = atom(visible, `${name}.query#${udfPath}`);
380
+ let data;
381
+ let error;
382
+ let status;
383
+ const entry = {
384
+ rt,
385
+ connected: false,
386
+ retained: 0,
387
+ token,
388
+ udfPath,
389
+ args,
390
+ result,
391
+ componentPath: queryOptions?.componentPath,
392
+ id: null,
393
+ op: null,
394
+ server: undefined,
395
+ visible,
396
+ journal: queryOptions?.journal,
397
+ get data() {
398
+ return (data ??= computed(() => {
399
+ const current = result();
400
+ return current?.success ? current.value : undefined;
401
+ }, `${result.name}.data`));
402
+ },
403
+ get error() {
404
+ return (error ??= computed(() => {
405
+ const current = result();
406
+ return current && !current.success
407
+ ? functionError("query", udfPath, current)
408
+ : undefined;
409
+ }, `${result.name}.error`));
410
+ },
411
+ get status() {
412
+ return (status ??= computed(() => {
413
+ const current = result();
414
+ return current === undefined
415
+ ? "loading"
416
+ : current.success
417
+ ? "success"
418
+ : "error";
419
+ }, `${result.name}.status`));
420
+ },
421
+ };
422
+ result.extend(withConnectionHooks(() => {
423
+ entry.connected = true;
424
+ if (entry.retained === 0)
425
+ connectEntry(entry);
426
+ }, () => {
427
+ entry.connected = false;
428
+ if (entry.retained === 0)
429
+ disconnectEntry(entry);
430
+ }));
431
+ rt.entries.set(entry);
432
+ return entry;
433
+ };
434
+ /** The entry for a query, created on first use. */
435
+ const queryEntry = (udfPath, args, queryOptions) => {
436
+ const rt = peek(runtime);
437
+ const canonical = canonicalizeUdfPath(udfPath);
438
+ // `serializePathAndArgs` without canonicalizing the path again.
439
+ const token = JSON.stringify({
440
+ udfPath: canonical,
441
+ args: convexToJson(args),
442
+ });
443
+ const existing = rt.entries.get(token);
444
+ if (existing === undefined) {
445
+ return createEntry(rt, token, canonical, args, queryOptions);
446
+ }
447
+ // A journal only applies to a new subscription.
448
+ if (existing.id === null && queryOptions?.journal !== undefined) {
449
+ existing.journal = queryOptions.journal;
450
+ }
451
+ return existing;
452
+ };
453
+ // Identity
454
+ const setAuthToken = (token) => {
455
+ authToken.set(token);
456
+ const baseVersion = identityVersion();
457
+ if (!peek(runtime).paused)
458
+ identityVersion.set(baseVersion + 1);
459
+ return { type: "Authenticate", baseVersion, ...token };
460
+ };
461
+ const clearAuthToken = () => {
462
+ const rt = peek(runtime);
463
+ authToken.set(undefined);
464
+ rt.outstandingAuthOlderThanRestart = false;
465
+ const baseVersion = identityVersion();
466
+ if (!rt.paused)
467
+ identityVersion.set(baseVersion + 1);
468
+ return { type: "Authenticate", tokenType: "None", baseVersion };
469
+ };
470
+ const authSync = {
471
+ isCurrentOrNewerAuthVersion: (version) => version >= identityVersion(),
472
+ markAuthCompletion: () => {
473
+ peek(runtime).outstandingAuthOlderThanRestart = false;
474
+ },
475
+ token: () => authToken()?.value,
476
+ hasAuth: () => authToken() !== undefined,
477
+ authenticate: (value) => {
478
+ socket.send(setAuthToken({ tokenType: "User", value }));
479
+ },
480
+ dropAuth: () => void clearAuthToken(),
481
+ clearAuth: () => {
482
+ // A paused socket drops the message.
483
+ if (peek(runtime).paused)
484
+ peek(runtime).clearAuthOnResume = true;
485
+ socket.send(clearAuthToken());
486
+ },
487
+ stopSocket: () => socket.stop(),
488
+ tryRestartSocket: () => socket.tryRestart(),
489
+ pauseSocket: () => {
490
+ socket.pause();
491
+ peek(runtime).paused = true;
492
+ },
493
+ resumeSocket: () => socket.resume(),
494
+ };
495
+ // Requests
496
+ const updateRequests = (update) => requests.set((current) => {
497
+ const next = new Map(current);
498
+ update(next);
499
+ return next;
500
+ });
501
+ const request = (message) => {
502
+ const sent = socket.send(message);
503
+ return new Promise((resolve) => {
504
+ updateRequests((all) => all.set(message.requestId, {
505
+ message,
506
+ status: sent ? "Requested" : "NotSent",
507
+ requestedAt: new Date(),
508
+ resolve,
509
+ }));
510
+ });
511
+ };
512
+ /** Returns the result when the request is done and its update can be dropped. */
513
+ const onResponse = (response) => {
514
+ const rt = peek(runtime);
515
+ const current = requests().get(response.requestId);
516
+ // A request no longer tracked, e.g. a mutation answered again after a
517
+ // reconnect, or one `restart` already re-sent and completed.
518
+ if (current === undefined || current.status === "Completed")
519
+ return null;
520
+ const udfType = current.message.type === "Mutation" ? "mutation" : "action";
521
+ const udfPath = current.message.udfPath;
522
+ for (const line of response.logLines) {
523
+ logForFunction(logger, "info", udfType, udfPath, line);
524
+ }
525
+ let result;
526
+ if (response.success) {
527
+ result = {
528
+ success: true,
529
+ logLines: response.logLines,
530
+ value: jsonToConvex(response.result),
531
+ };
532
+ }
533
+ else {
534
+ const errorMessage = response.result;
535
+ logForFunction(logger, "error", udfType, udfPath, errorMessage);
536
+ result = {
537
+ success: false,
538
+ errorMessage,
539
+ errorData: response.errorData !== undefined
540
+ ? jsonToConvex(response.errorData)
541
+ : undefined,
542
+ logLines: response.logLines,
543
+ };
544
+ }
545
+ // A failed mutation changed nothing, and actions are not ordered with
546
+ // queries, so both resolve now.
547
+ if (response.type === "ActionResponse" || !response.success) {
548
+ current.resolve(result);
549
+ updateRequests((all) => all.delete(response.requestId));
550
+ rt.requestsOlderThanRestart.delete(response.requestId);
551
+ return { requestId: response.requestId, result };
552
+ }
553
+ // A mutation resolves once a transition reaches its timestamp, so the
554
+ // caller reads its own write.
555
+ updateRequests((all) => all.set(response.requestId, {
556
+ ...current,
557
+ status: "Completed",
558
+ result,
559
+ ts: response.ts,
560
+ }));
561
+ return null;
562
+ };
563
+ const removeCompleted = (ts) => {
564
+ const rt = peek(runtime);
565
+ const completed = new Map();
566
+ for (const [requestId, current] of requests()) {
567
+ if (current.status !== "Completed" || !current.ts.lessThanOrEqual(ts)) {
568
+ continue;
569
+ }
570
+ current.resolve(current.result);
571
+ completed.set(requestId, current.result);
572
+ rt.requestsOlderThanRestart.delete(requestId);
573
+ }
574
+ if (completed.size > 0) {
575
+ updateRequests((all) => completed.forEach((_, id) => all.delete(id)));
576
+ }
577
+ return completed;
578
+ };
579
+ /**
580
+ * Requests to send on a new connection. Mutations are idempotent and go
581
+ * again, even completed ones, so the server transitions the client past
582
+ * them. In-flight actions might have run, so they fail.
583
+ */
584
+ const restartRequests = () => {
585
+ const rt = peek(runtime);
586
+ rt.requestsOlderThanRestart = new Set(requests().keys());
587
+ const messages = [];
588
+ const lost = [];
589
+ updateRequests((all) => {
590
+ for (const [requestId, current] of all) {
591
+ if (current.status === "NotSent") {
592
+ all.set(requestId, { ...current, status: "Requested" });
593
+ messages.push(current.message);
594
+ }
595
+ else if (current.message.type === "Mutation") {
596
+ messages.push(current.message);
597
+ }
598
+ else {
599
+ all.delete(requestId);
600
+ rt.requestsOlderThanRestart.delete(requestId);
601
+ lost.push(current);
602
+ }
603
+ }
604
+ });
605
+ for (const { resolve } of lost) {
606
+ resolve({
607
+ success: false,
608
+ errorMessage: "Connection lost while action was in flight",
609
+ logLines: [],
610
+ });
611
+ }
612
+ return messages;
613
+ };
614
+ const resumeRequests = () => {
615
+ const messages = [];
616
+ updateRequests((all) => {
617
+ for (const [requestId, current] of all) {
618
+ if (current.status !== "NotSent")
619
+ continue;
620
+ all.set(requestId, { ...current, status: "Requested" });
621
+ messages.push(current.message);
622
+ }
623
+ });
624
+ return messages;
625
+ };
626
+ const hasIncompleteRequests = () => {
627
+ for (const current of requests().values()) {
628
+ if (current.status === "Requested")
629
+ return true;
630
+ }
631
+ return false;
632
+ };
633
+ const timeOfOldestInflightRequest = (pending) => {
634
+ if (pending.size === 0)
635
+ return null;
636
+ let oldest = Date.now();
637
+ for (const current of pending.values()) {
638
+ if (current.status !== "Completed") {
639
+ oldest = Math.min(oldest, current.requestedAt.getTime());
640
+ }
641
+ }
642
+ return new Date(oldest);
643
+ };
644
+ const tryReportLongDisconnect = () => {
645
+ if (!debug)
646
+ return;
647
+ const oldest = timeOfOldestInflightRequest(requests());
648
+ if (oldest === null || Date.now() - oldest.getTime() <= 60 * 1000)
649
+ return;
650
+ fetch(`${address}/api/debug_event`, {
651
+ method: "POST",
652
+ headers: {
653
+ "Content-Type": "application/json",
654
+ "Convex-Client": `npm-${version}`,
655
+ },
656
+ body: JSON.stringify({ event: "LongWebsocketDisconnect" }),
657
+ })
658
+ .then((response) => {
659
+ if (!response.ok) {
660
+ logger.warn("Analytics request failed with response:", response.body);
661
+ }
662
+ })
663
+ .catch((error) => {
664
+ logger.warn("Analytics response failed with error:", error);
665
+ });
666
+ };
667
+ // Socket
668
+ const observedTimestamp = (observedTs) => {
669
+ const max = maxObservedTimestamp();
670
+ if (max === undefined || max.lessThanOrEqual(observedTs)) {
671
+ maxObservedTimestamp.set(observedTs);
672
+ }
673
+ };
674
+ const hasSyncedPastLastReconnect = () => {
675
+ const rt = peek(runtime);
676
+ return (rt.requestsOlderThanRestart.size === 0 &&
677
+ rt.outstandingQueriesOlderThanRestart.size === 0 &&
678
+ !rt.outstandingAuthOlderThanRestart);
679
+ };
680
+ const onOpen = (reconnectMetadata) => {
681
+ const rt = peek(runtime);
682
+ mark("convexWebSocketOpen");
683
+ socket.send({
684
+ ...reconnectMetadata,
685
+ type: "Connect",
686
+ sessionId: rt.sessionId,
687
+ maxObservedTimestamp: maxObservedTimestamp(),
688
+ });
689
+ // A new connection starts from an empty query set and no identity:
690
+ // re-add every query, resend mutations and reauthenticate.
691
+ remoteVersion.set(INITIAL_VERSION);
692
+ rt.paused = false;
693
+ rt.clearAuthOnResume = false;
694
+ for (const entry of rt.pending.values()) {
695
+ if (entry.op === "Remove")
696
+ forget(entry);
697
+ else
698
+ entry.op = null;
699
+ }
700
+ rt.pending.clear();
701
+ rt.outstandingQueriesOlderThanRestart = new Set(rt.byId.keys());
702
+ const querySet = {
703
+ type: "ModifyQuerySet",
704
+ baseVersion: 0,
705
+ newVersion: 1,
706
+ modifications: Array.from(rt.byId.values(), addQuery),
707
+ };
708
+ querySetVersion.set(1);
709
+ const token = authToken();
710
+ if (token === undefined) {
711
+ // The server starts without an identity too.
712
+ identityVersion.set(0);
713
+ }
714
+ else {
715
+ rt.outstandingAuthOlderThanRestart = true;
716
+ identityVersion.set(1);
717
+ socket.send({
718
+ type: "Authenticate",
719
+ baseVersion: 0,
720
+ ...token,
721
+ });
722
+ }
723
+ socket.send(querySet);
724
+ for (const message of restartRequests())
725
+ socket.send(message);
726
+ };
727
+ const onResume = () => {
728
+ const rt = peek(runtime);
729
+ let querySet;
730
+ if (rt.pending.size > 0) {
731
+ const baseVersion = querySetVersion();
732
+ querySet = {
733
+ type: "ModifyQuerySet",
734
+ baseVersion,
735
+ newVersion: baseVersion + 1,
736
+ modifications: takePending(rt),
737
+ };
738
+ querySetVersion.set(baseVersion + 1);
739
+ }
740
+ const token = authToken();
741
+ let authenticate;
742
+ if (token !== undefined) {
743
+ const baseVersion = identityVersion();
744
+ identityVersion.set(baseVersion + 1);
745
+ authenticate = {
746
+ type: "Authenticate",
747
+ baseVersion,
748
+ ...token,
749
+ };
750
+ }
751
+ else if (rt.clearAuthOnResume) {
752
+ const baseVersion = identityVersion();
753
+ identityVersion.set(baseVersion + 1);
754
+ authenticate = { type: "Authenticate", tokenType: "None", baseVersion };
755
+ }
756
+ rt.clearAuthOnResume = false;
757
+ rt.paused = false;
758
+ if (authenticate)
759
+ socket.send(authenticate);
760
+ if (querySet)
761
+ socket.send(querySet);
762
+ for (const message of resumeRequests())
763
+ socket.send(message);
764
+ };
765
+ const onTransition = (message) => {
766
+ const rt = peek(runtime);
767
+ observedTimestamp(message.endVersion.ts);
768
+ auth.onTransition(message);
769
+ const start = message.startVersion;
770
+ const current = remoteVersion();
771
+ if (current.querySet !== start.querySet ||
772
+ current.ts.notEquals(start.ts) ||
773
+ current.identity !== start.identity) {
774
+ throw new Error(`Invalid start version: ${start.ts.toString()}:${start.querySet}:${start.identity}, transitioning from ${current.ts.toString()}:${current.querySet}:${current.identity}`);
775
+ }
776
+ const changed = [];
777
+ const removed = new Set();
778
+ for (const modification of message.modifications) {
779
+ rt.outstandingQueriesOlderThanRestart.delete(modification.queryId);
780
+ // Results for queries already unsubscribed are dropped.
781
+ const entry = rt.byId.get(modification.queryId);
782
+ if (entry === undefined)
783
+ continue;
784
+ switch (modification.type) {
785
+ case "QueryUpdated": {
786
+ for (const line of modification.logLines) {
787
+ logForFunction(logger, "info", "query", entry.udfPath, line);
788
+ }
789
+ entry.server = {
790
+ success: true,
791
+ value: jsonToConvex(modification.value ?? null),
792
+ logLines: modification.logLines,
793
+ };
794
+ if (modification.journal !== undefined) {
795
+ entry.journal = modification.journal;
796
+ }
797
+ break;
798
+ }
799
+ case "QueryFailed": {
800
+ for (const line of modification.logLines) {
801
+ logForFunction(logger, "info", "query", entry.udfPath, line);
802
+ }
803
+ const { errorData } = modification;
804
+ entry.server = {
805
+ success: false,
806
+ errorMessage: modification.errorMessage,
807
+ errorData: errorData !== undefined ? jsonToConvex(errorData) : undefined,
808
+ logLines: modification.logLines,
809
+ };
810
+ if (modification.journal !== undefined) {
811
+ entry.journal = modification.journal;
812
+ }
813
+ break;
814
+ }
815
+ case "QueryRemoved": {
816
+ entry.server = undefined;
817
+ removed.add(entry);
818
+ break;
819
+ }
820
+ default: {
821
+ modification;
822
+ throw new Error(`Invalid modification ${modification.type}`);
823
+ }
824
+ }
825
+ changed.push(entry);
826
+ }
827
+ remoteVersion.set(message.endVersion);
828
+ ingest(changed, removeCompleted(message.endVersion.ts), removed);
829
+ };
830
+ const onMessage = (serverMessage) => {
831
+ const rt = peek(runtime);
832
+ // Once per client: metrics grow with reconnects.
833
+ if (!rt.firstMessageReceived) {
834
+ rt.firstMessageReceived = true;
835
+ mark("convexFirstMessageReceived");
836
+ if (debug) {
837
+ socket.send({
838
+ type: "Event",
839
+ eventType: "ClientConnect",
840
+ event: getMarksReport(rt.sessionId),
841
+ });
842
+ }
843
+ }
844
+ switch (serverMessage.type) {
845
+ case "Transition": {
846
+ onTransition(serverMessage);
847
+ break;
848
+ }
849
+ case "MutationResponse": {
850
+ if (serverMessage.success) {
851
+ observedTimestamp(serverMessage.ts);
852
+ }
853
+ const completed = onResponse(serverMessage);
854
+ if (completed !== null) {
855
+ ingest([], new Map([[completed.requestId, completed.result]]));
856
+ }
857
+ break;
858
+ }
859
+ case "ActionResponse": {
860
+ onResponse(serverMessage);
861
+ break;
862
+ }
863
+ case "AuthError": {
864
+ auth.onAuthError(serverMessage);
865
+ break;
866
+ }
867
+ case "FatalError": {
868
+ const error = logFatalError(logger, serverMessage.error);
869
+ void socket.terminate();
870
+ throw error;
871
+ }
872
+ default: {
873
+ serverMessage;
874
+ }
875
+ }
876
+ return hasSyncedPastLastReconnect();
877
+ };
878
+ const socket = reatomSocket(wsUri, { webSocketConstructor, logger, debug }, {
879
+ onOpen,
880
+ onResume,
881
+ onMessage,
882
+ onServerDisconnectError: options.onServerDisconnectError,
883
+ }, `${name}.socket`);
884
+ const auth = reatomAuth(authSync, {
885
+ logger,
886
+ refreshTokenLeewaySeconds: options.authRefreshTokenLeewaySeconds ?? 10,
887
+ initialAuthTokenReuse: options.initialAuthTokenReuse ?? false,
888
+ }, `${name}.auth`);
889
+ /** Opens the socket on first use in this context. */
890
+ const start = () => {
891
+ const rt = peek(runtime);
892
+ if (rt.started || rt.closed)
893
+ return;
894
+ rt.started = true;
895
+ if (hasWindow && options.unsavedChangesWarning !== false) {
896
+ // Prompt on tab close while requests are unconfirmed.
897
+ rt.onBeforeUnload = wrap((e) => {
898
+ if (!hasIncompleteRequests())
899
+ return;
900
+ e.preventDefault();
901
+ // Most browsers don't show this message, but some still need it set.
902
+ const confirmationMessage = "Are you sure you want to leave? Your changes may not be saved.";
903
+ (e || window.event).returnValue = confirmationMessage;
904
+ return confirmationMessage;
905
+ }, rt.root);
906
+ window.addEventListener("beforeunload", rt.onBeforeUnload);
907
+ }
908
+ socket.start();
909
+ mark("convexClientConstructed");
910
+ // Wait for the first auth token.
911
+ if (options.expectAuth)
912
+ authSync.pauseSocket();
913
+ };
914
+ const connectionState = computed(() => {
915
+ const pending = requests();
916
+ let inflightMutations = 0;
917
+ for (const current of pending.values()) {
918
+ if (current.message.type === "Mutation")
919
+ inflightMutations++;
920
+ }
921
+ return {
922
+ hasInflightRequests: pending.size > 0,
923
+ isWebSocketConnected: socket.state().state === "ready",
924
+ hasEverConnected: socket.hasEverConnected(),
925
+ connectionCount: socket.connectionCount(),
926
+ connectionRetries: socket.retries(),
927
+ timeOfOldestInflightRequest: timeOfOldestInflightRequest(pending),
928
+ inflightMutations,
929
+ inflightActions: pending.size - inflightMutations,
930
+ };
931
+ }, `${name}.connectionState`).extend(withConnectionHooks(start, noop));
932
+ const authClaims = computed(() => {
933
+ const token = authToken();
934
+ if (token?.tokenType !== "User")
935
+ return undefined;
936
+ let decoded;
937
+ try {
938
+ decoded = jwtDecode(token.value);
939
+ }
940
+ catch {
941
+ decoded = {};
942
+ }
943
+ return { token: token.value, decoded };
944
+ }, `${name}.authClaims`);
945
+ const enqueueMutation = (udfPath, args, mutationOptions, componentPath) => {
946
+ start();
947
+ const mutationArgs = parseArgs(args);
948
+ tryReportLongDisconnect();
949
+ const rt = peek(runtime);
950
+ const requestId = rt.nextRequestId++;
951
+ const optimisticUpdate = mutationOptions?.optimisticUpdate;
952
+ if (optimisticUpdate !== undefined) {
953
+ applyOptimisticUpdate((store) => {
954
+ const result = optimisticUpdate(store, mutationArgs);
955
+ if (result instanceof Promise) {
956
+ logger.warn("Optimistic update handler returned a Promise. Optimistic updates should be synchronous.");
957
+ }
958
+ }, requestId);
959
+ }
960
+ const mutationPromise = request({
961
+ type: "Mutation",
962
+ requestId,
963
+ udfPath,
964
+ componentPath,
965
+ args: [convexToJson(mutationArgs)],
966
+ });
967
+ return { requestId, mutationPromise };
968
+ };
969
+ const actionInternal = (udfPath, args, componentPath) => {
970
+ start();
971
+ const actionArgs = parseArgs(args);
972
+ const requestId = peek(runtime).nextRequestId++;
973
+ tryReportLongDisconnect();
974
+ return request({
975
+ type: "Action",
976
+ requestId,
977
+ udfPath,
978
+ componentPath,
979
+ args: [convexToJson(actionArgs)],
980
+ });
981
+ };
982
+ const runMutation = action(async (udfPath, args, mutationOptions) => {
983
+ const result = await wrap(enqueueMutation(udfPath, args, mutationOptions).mutationPromise);
984
+ if (!result.success)
985
+ throw functionError("mutation", udfPath, result);
986
+ return result.value;
987
+ }, `${name}.mutation`);
988
+ const runAction = action(async (udfPath, args) => {
989
+ const result = await wrap(actionInternal(udfPath, args));
990
+ if (!result.success)
991
+ throw functionError("action", udfPath, result);
992
+ return result.value;
993
+ }, `${name}.action`);
994
+ const setAuth = action((fetchToken, onChange, onRefreshChange) => {
995
+ start();
996
+ isAuthenticated.set(null);
997
+ isRefreshingAuth.set(false);
998
+ // The flow outlives the caller's frame, so it runs in the root.
999
+ wrap(() => void auth.setConfig(fetchToken, (value) => {
1000
+ isAuthenticated.set(value);
1001
+ onChange?.(value);
1002
+ }, (value) => {
1003
+ isRefreshingAuth.set(value);
1004
+ onRefreshChange?.(value);
1005
+ }), peek(runtime).root)();
1006
+ }, `${name}.setAuth`);
1007
+ const clearAuth = action(() => {
1008
+ start();
1009
+ auth.stop();
1010
+ // Forget the token before resuming a pause for a token fetch, so the
1011
+ // resume doesn't send it, then tell the server.
1012
+ authToken.set(undefined);
1013
+ socket.resume();
1014
+ socket.tryRestart();
1015
+ authSync.clearAuth();
1016
+ isAuthenticated.set(false);
1017
+ isRefreshingAuth.set(false);
1018
+ }, `${name}.clearAuth`);
1019
+ const setAdminAuth = action((value, fakeUserIdentity) => {
1020
+ start();
1021
+ socket.send(setAuthToken({
1022
+ tokenType: "Admin",
1023
+ value,
1024
+ impersonating: fakeUserIdentity,
1025
+ }));
1026
+ }, `${name}.setAdminAuth`);
1027
+ const close = action(() => {
1028
+ const rt = peek(runtime);
1029
+ rt.closed = true;
1030
+ auth.stop();
1031
+ if (rt.onBeforeUnload) {
1032
+ window.removeEventListener("beforeunload", rt.onBeforeUnload);
1033
+ rt.onBeforeUnload = null;
1034
+ }
1035
+ if (!rt.started)
1036
+ return Promise.resolve();
1037
+ return socket.terminate();
1038
+ }, `${name}.close`);
1039
+ /** The paginated query for `options`, created on first use. */
1040
+ const paginatedQueryEntry = (udfPath, args, paginatedOptions) => {
1041
+ const canonical = canonicalizeUdfPath(udfPath);
1042
+ const token = serializePaginatedPathAndArgs(canonical, args, paginatedOptions);
1043
+ const { paginated } = peek(runtime);
1044
+ const existing = paginated.get(token);
1045
+ if (existing !== undefined)
1046
+ return existing;
1047
+ const created = reatomPaginatedQuery((pageUdfPath, pageArgs, paginationOpts) => queryEntry(pageUdfPath, { ...pageArgs, paginationOpts }), canonical, args, paginatedOptions, `${name}.paginatedQuery#${canonical}`);
1048
+ paginated.set(created);
1049
+ return created;
1050
+ };
1051
+ /** A result held locally, with optimistic updates applied. */
1052
+ const rawQueryResultByToken = (queryToken) => {
1053
+ const entry = lookup(queryToken);
1054
+ return entry !== undefined
1055
+ ? entry.visible
1056
+ : peek(runtime).overlay.get(queryToken)?.result;
1057
+ };
1058
+ /** Like {@link rawQueryResultByToken}, but throws a failed query's error. */
1059
+ const localQueryResultByToken = (queryToken) => {
1060
+ const result = rawQueryResultByToken(queryToken);
1061
+ if (result === undefined)
1062
+ return undefined;
1063
+ if (result.success)
1064
+ return result.value;
1065
+ const udfPath = lookup(queryToken)?.udfPath ??
1066
+ peek(runtime).overlay.get(queryToken).udfPath;
1067
+ throw functionError("query", udfPath, result);
1068
+ };
1069
+ const client = {
1070
+ name,
1071
+ url: address,
1072
+ connectionState,
1073
+ isAuthenticated,
1074
+ isRefreshingAuth,
1075
+ authClaims,
1076
+ socketState: socket.state,
1077
+ query: (query, args, queryOptions) => queryEntry(functionName(query), parseArgs(args), queryOptions),
1078
+ paginatedQuery: (query, args, paginatedOptions) => paginatedQueryEntry(functionName(query), parseArgs(args), {
1079
+ initialNumItems: paginatedOptions.initialNumItems,
1080
+ id: paginatedOptions.id ?? -1,
1081
+ }),
1082
+ mutation: (mutation, args, mutationOptions) => runMutation(functionName(mutation), args, mutationOptions),
1083
+ action: (action, args) => runAction(functionName(action), args),
1084
+ setAuth,
1085
+ clearAuth,
1086
+ close,
1087
+ };
1088
+ INTERNALS.set(client, {
1089
+ setAdminAuth,
1090
+ localQueryResultByToken,
1091
+ rawQueryResultByToken,
1092
+ hasLocalQueryResultByToken: (queryToken) => rawQueryResultByToken(queryToken) !== undefined,
1093
+ queryJournal: (queryToken) => {
1094
+ const entry = lookup(queryToken);
1095
+ return entry?.id === null ? undefined : entry?.journal;
1096
+ },
1097
+ queryEntry,
1098
+ retainQuery: (entry) => {
1099
+ const queryEntry = entry;
1100
+ if (queryEntry.retained++ === 0 && !queryEntry.connected) {
1101
+ connectEntry(queryEntry);
1102
+ }
1103
+ let released = false;
1104
+ return () => {
1105
+ if (released)
1106
+ return;
1107
+ released = true;
1108
+ if (--queryEntry.retained === 0 && !queryEntry.connected) {
1109
+ disconnectEntry(queryEntry);
1110
+ }
1111
+ };
1112
+ },
1113
+ paginatedQueryEntry,
1114
+ enqueueMutation,
1115
+ actionInternal,
1116
+ addOnTransitionHandler: (handler) => {
1117
+ const { transitionHandlers } = peek(runtime);
1118
+ transitionHandlers.add(handler);
1119
+ return () => transitionHandlers.delete(handler);
1120
+ },
1121
+ start,
1122
+ sessionId: () => peek(runtime).sessionId,
1123
+ nextRequestId: () => peek(runtime).nextRequestId,
1124
+ hasAuth: () => authToken() !== undefined,
1125
+ maxObservedTimestamp: () => maxObservedTimestamp(),
1126
+ authState: () => auth.state().state,
1127
+ optimisticUpdates: () => optimisticUpdates().length,
1128
+ });
1129
+ return client;
1130
+ }
1131
+ const INTERNALS = new WeakMap();
1132
+ /** @internal */
1133
+ export function internals(client) {
1134
+ const found = INTERNALS.get(client);
1135
+ if (!found)
1136
+ throw new Error("Not a client created by reatomConvexClient");
1137
+ return found;
1138
+ }