@reatomized/convex 0.3.0 → 0.3.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.
package/README.md CHANGED
@@ -24,11 +24,11 @@ import type { Id } from "../convex/_generated/dataModel";
24
24
 
25
25
  const convex = reatomConvexClient(import.meta.env.VITE_CONVEX_URL);
26
26
 
27
- const noteId = atom<Id<"notes"> | null>(null);
27
+ const noteId = atom<Id<"notes"> | null>(null, "noteId");
28
28
  const note = computed(() => {
29
29
  const id = noteId();
30
30
  return id ? convex.query(api.notes.get, { id }).data() : undefined;
31
- });
31
+ }, "note");
32
32
  ```
33
33
 
34
34
  - `convex.query(ref, args)` returns the entry for that function and arguments,
@@ -153,8 +153,9 @@ Node 22 and later have a global `WebSocket`. On Node 20, pass one, such as the
153
153
  convex/browser's `BaseConvexClient`. The Reatom functions it uses add 4.6 KiB,
154
154
  shared with the rest of the app.
155
155
 
156
- With 1000 components each running its own query in Chromium, mounting takes 9.24
157
- ms against 65.5 ms for convex/react, unmounting 1.99 ms against 9.87 ms.
156
+ With 1000 components each running its own query in Chromium and @reatom/jsx
157
+ rendering, mounting takes 6.28 ms against 54.2 ms for convex/react, unmounting
158
+ 0.75 ms against 7.93 ms.
158
159
  [CONTRIBUTING.md](https://github.com/reatomized/convex/blob/reatom/CONTRIBUTING.md#performance)
159
160
  has the full table and how it was measured.
160
161
 
@@ -67,7 +67,7 @@ export declare function reatomAuth(sync: AuthSync, options: {
67
67
  }, name: string): {
68
68
  /** The auth state machine. */
69
69
  state: import("@reatom/core").Atom<AuthState, [newState: AuthState]>;
70
- setConfig(fetchToken: AuthTokenFetcher, onChange: (isAuthenticated: boolean) => void, onRefreshChange?: ((isRefreshing: boolean) => void) | undefined): Promise<void>;
70
+ setConfig: import("@reatom/core").GAction<(fetchToken: AuthTokenFetcher, onChange: (isAuthenticated: boolean) => void, onRefreshChange?: ((isRefreshing: boolean) => void) | undefined) => Promise<void>>;
71
71
  onTransition(serverMessage: Transition): void;
72
72
  onAuthError(serverMessage: AuthError): void;
73
73
  stop(): void;
@@ -10,7 +10,7 @@
10
10
  * server's clock skew. When the server rejects a token, the socket stops, a
11
11
  * fresh token is fetched and sent, and the socket restarts.
12
12
  */
13
- import { atom, context, peek, wrap } from "@reatom/core";
13
+ import { action, atom, context, peek, wrap } from "@reatom/core";
14
14
  import { jwtDecode } from "../../vendor/jwt-decode/index.js";
15
15
  // setTimeout takes a 32-bit delay, about 24 days.
16
16
  const MAXIMUM_REFRESH_DELAY = 20 * 24 * 60 * 60 * 1000; // 20 days
@@ -124,6 +124,8 @@ export function reatomAuth(sync, options, name) {
124
124
  logger.warn(`Refetching auth token immediately, configured leeway ${refreshTokenLeewaySeconds}s is larger than the token's lifetime ${tokenValiditySeconds}s`);
125
125
  delay = 0;
126
126
  }
127
+ // Owned by the auth state machine, not a query subscription. Changing
128
+ // config or stopping auth cancels this in resetAuthState.
127
129
  const refetchTokenTimeoutId = setTimeout(wrap(() => {
128
130
  logVerbose("running scheduled token refetch");
129
131
  void refetchToken();
@@ -137,7 +139,7 @@ export function reatomAuth(sync, options, name) {
137
139
  };
138
140
  // Force-fetches a token and schedules the next refetch once the server
139
141
  // confirms it, so an active client never has to reauthenticate.
140
- const refetchToken = async () => {
142
+ const refetchToken = action(async () => {
141
143
  const current = state();
142
144
  if (current.state === "noAuth")
143
145
  return;
@@ -171,10 +173,10 @@ export function reatomAuth(sync, options, name) {
171
173
  // In case a scheduled refetch ran during a reauthentication.
172
174
  logVerbose("restarting WS after auth token fetch (if currently stopped)");
173
175
  sync.tryRestartSocket();
174
- };
176
+ }, `${name}._refetchToken`);
175
177
  // Like `refetchToken`, but with the socket stopped so requests don't retry
176
178
  // with the rejected token.
177
- const tryToReauthenticate = async (serverMessage) => {
179
+ const tryToReauthenticate = action(async (serverMessage) => {
178
180
  logVerbose(`attempting to reauthenticate: ${serverMessage.error}`);
179
181
  const current = state();
180
182
  const rt = peek(runtime);
@@ -230,40 +232,41 @@ export function reatomAuth(sync, options, name) {
230
232
  setAndReportAuthFailed(latest.config.onAuthChange);
231
233
  }
232
234
  sync.tryRestartSocket();
233
- };
235
+ }, `${name}._reauthenticate`);
236
+ const setConfig = action(async (fetchToken, onChange, onRefreshChange) => {
237
+ resetAuthState();
238
+ logVerbose("pausing WS for auth token fetch");
239
+ sync.pauseSocket();
240
+ const token = await wrap(fetchTokenAndGuardAgainstRace(fetchToken, {
241
+ forceRefreshToken: false,
242
+ }));
243
+ if (token.isFromOutdatedConfig)
244
+ return;
245
+ const config = {
246
+ fetchToken,
247
+ onAuthChange: onChange,
248
+ onRefreshChange,
249
+ };
250
+ if (token.value) {
251
+ setAuthState({
252
+ state: "waitingForServerConfirmationOfCachedToken",
253
+ config,
254
+ hasRetried: false,
255
+ });
256
+ sync.authenticate(token.value);
257
+ }
258
+ else {
259
+ setAuthState({ state: "initialRefetch", config });
260
+ // Try again with `forceRefreshToken: true`.
261
+ await wrap(refetchToken());
262
+ }
263
+ logVerbose("resuming WS after auth token fetch");
264
+ sync.resumeSocket();
265
+ }, `${name}.setConfig`);
234
266
  return {
235
267
  /** The auth state machine. */
236
268
  state,
237
- async setConfig(fetchToken, onChange, onRefreshChange) {
238
- resetAuthState();
239
- logVerbose("pausing WS for auth token fetch");
240
- sync.pauseSocket();
241
- const token = await wrap(fetchTokenAndGuardAgainstRace(fetchToken, {
242
- forceRefreshToken: false,
243
- }));
244
- if (token.isFromOutdatedConfig)
245
- return;
246
- const config = {
247
- fetchToken,
248
- onAuthChange: onChange,
249
- onRefreshChange,
250
- };
251
- if (token.value) {
252
- setAuthState({
253
- state: "waitingForServerConfirmationOfCachedToken",
254
- config,
255
- hasRetried: false,
256
- });
257
- sync.authenticate(token.value);
258
- }
259
- else {
260
- setAuthState({ state: "initialRefetch", config });
261
- // Try again with `forceRefreshToken: true`.
262
- await wrap(refetchToken());
263
- }
264
- logVerbose("resuming WS after auth token fetch");
265
- sync.resumeSocket();
266
- },
269
+ setConfig,
267
270
  onTransition(serverMessage) {
268
271
  // Stale: the client has moved on to a newer identity.
269
272
  if (!sync.isCurrentOrNewerAuthVersion(serverMessage.endVersion.identity)) {
@@ -4,7 +4,7 @@
4
4
  * `ConvexBetterAuthProvider`. Sign in and out through the better-auth client
5
5
  * itself; its session store tells this provider.
6
6
  */
7
- import { action, atom, computed, peek, withAsync, withConnectHook, wrap, } from "@reatom/core";
7
+ import { abortVar, action, atom, computed, peek, withAsync, withConnectHook, wrap, } from "@reatom/core";
8
8
  /**
9
9
  * With `crossDomainClient()`, an OAuth sign-in comes back with a one-time
10
10
  * token in the `ott` query param; the provider trades it for the session.
@@ -25,7 +25,7 @@ export function reatomBetterAuthProvider(authClient, options = {}, name = "bette
25
25
  // Boxed, so the atom holds the request rather than its result.
26
26
  const pending = atom(null, `${name}._pending`);
27
27
  const state = atom(() => authClient.useSession.get(), `${name}.sessionState`).extend(withConnectHook((target) => {
28
- void start();
28
+ void abortVar.spawn(start);
29
29
  return authClient.useSession.subscribe(wrap((value) => {
30
30
  const previous = peek(target).data?.session?.id;
31
31
  target.set(value);
@@ -121,11 +121,11 @@ export function reatomConvexClient(address, options = {}, name = "convex") {
121
121
  if (!hasWindow && options.unsavedChangesWarning === true) {
122
122
  throw new Error("unsavedChangesWarning requested, but window.addEventListener not found! Remove {unsavedChangesWarning: true} from Convex client options.");
123
123
  }
124
- const querySetVersion = atom(0, `${name}.querySetVersion`);
125
- const identityVersion = atom(0, `${name}.identityVersion`);
124
+ const querySetVersion = atom(0, `${name}._querySetVersion`);
125
+ const identityVersion = atom(0, `${name}._identityVersion`);
126
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`);
127
+ const remoteVersion = atom(INITIAL_VERSION, `${name}._remoteVersion`);
128
+ const maxObservedTimestamp = atom(undefined, `${name}._maxObservedTimestamp`);
129
129
  /** The token the server is told about. */
130
130
  const authToken = atom(undefined, `${name}.authToken`);
131
131
  /**
@@ -139,6 +139,9 @@ export function reatomConvexClient(address, options = {}, name = "convex") {
139
139
  const requests = atom(new Map(), `${name}.requests`);
140
140
  /** Optimistic updates of mutations the client has not seen reflected yet. */
141
141
  const optimisticUpdates = atom([], `${name}.optimisticUpdates`);
142
+ let nextTraceId = 0;
143
+ // Transport handles and query indexes are mutable per-context bookkeeping.
144
+ // Public results are atoms; these indexes deliberately are not rollback state.
142
145
  const runtime = atom(() => ({
143
146
  root: context(),
144
147
  started: false,
@@ -317,7 +320,7 @@ export function reatomConvexClient(address, options = {}, name = "convex") {
317
320
  rt.pending.clear();
318
321
  return modifications;
319
322
  };
320
- const flush = () => {
323
+ const flush = action(() => {
321
324
  const rt = peek(runtime);
322
325
  rt.flushScheduled = false;
323
326
  if (rt.pending.size === 0 || rt.paused || rt.closed)
@@ -331,7 +334,7 @@ export function reatomConvexClient(address, options = {}, name = "convex") {
331
334
  };
332
335
  querySetVersion.set(baseVersion + 1);
333
336
  socket.send(modification);
334
- };
337
+ }, `${name}._flush`);
335
338
  const scheduleFlush = (rt) => {
336
339
  if (rt.flushScheduled)
337
340
  return;
@@ -376,7 +379,7 @@ export function reatomConvexClient(address, options = {}, name = "convex") {
376
379
  };
377
380
  const createEntry = (rt, token, udfPath, args, queryOptions) => {
378
381
  const visible = rt.overlay.get(token)?.result;
379
- const result = atom(visible, `${name}.query#${udfPath}`);
382
+ const result = atom(visible, `${name}.query#${udfPath}#${nextTraceId++}`);
380
383
  let data;
381
384
  let error;
382
385
  let status;
@@ -827,7 +830,7 @@ export function reatomConvexClient(address, options = {}, name = "convex") {
827
830
  remoteVersion.set(message.endVersion);
828
831
  ingest(changed, removeCompleted(message.endVersion.ts), removed);
829
832
  };
830
- const onMessage = (serverMessage) => {
833
+ const onMessage = action((serverMessage) => {
831
834
  const rt = peek(runtime);
832
835
  // Once per client: metrics grow with reconnects.
833
836
  if (!rt.firstMessageReceived) {
@@ -874,7 +877,7 @@ export function reatomConvexClient(address, options = {}, name = "convex") {
874
877
  }
875
878
  }
876
879
  return hasSyncedPastLastReconnect();
877
- };
880
+ }, `${name}._onMessage`);
878
881
  const socket = reatomSocket(wsUri, { webSocketConstructor, logger, debug }, {
879
882
  onOpen,
880
883
  onResume,
@@ -1044,7 +1047,7 @@ export function reatomConvexClient(address, options = {}, name = "convex") {
1044
1047
  const existing = paginated.get(token);
1045
1048
  if (existing !== undefined)
1046
1049
  return existing;
1047
- const created = reatomPaginatedQuery((pageUdfPath, pageArgs, paginationOpts) => queryEntry(pageUdfPath, { ...pageArgs, paginationOpts }), canonical, args, paginatedOptions, `${name}.paginatedQuery#${canonical}`);
1050
+ const created = reatomPaginatedQuery((pageUdfPath, pageArgs, paginationOpts) => queryEntry(pageUdfPath, { ...pageArgs, paginationOpts }), canonical, args, paginatedOptions, `${name}.paginatedQuery#${canonical}#${nextTraceId++}`);
1048
1051
  paginated.set(created);
1049
1052
  return created;
1050
1053
  };
@@ -1,11 +1,7 @@
1
- import type { AtomLike, Ext } from "@reatom/core";
1
+ import { type AtomLike, type Ext } from "@reatom/core";
2
2
  /**
3
- * Calls `connect` when `target` gains its first subscriber and `disconnect`
4
- * when it loses the last one. `target` must have no other connect hook.
5
- *
6
- * Sets Reatom's internal `onConnect` slot to plain functions: Reatom's queue
7
- * calls them in the context's root frame. Unlike `withConnectHook` this makes
8
- * no actions, AbortController or AbortError per connection, which matters
9
- * with thousands of queries.
3
+ * Composes with other public Reatom connection hooks. Client work belongs to
4
+ * the context, not to a subscriber's abort scope; losing a subscription must
5
+ * not cancel a socket operation or a scheduled query-set flush.
10
6
  */
11
7
  export declare const withConnectionHooks: <Target extends AtomLike<any, any[], any>>(connect: () => void, disconnect: () => void) => Ext<Target, Target>;
@@ -1,15 +1,10 @@
1
+ import { abortVar, withConnectHook, } from "@reatom/core";
1
2
  /**
2
- * Calls `connect` when `target` gains its first subscriber and `disconnect`
3
- * when it loses the last one. `target` must have no other connect hook.
4
- *
5
- * Sets Reatom's internal `onConnect` slot to plain functions: Reatom's queue
6
- * calls them in the context's root frame. Unlike `withConnectHook` this makes
7
- * no actions, AbortController or AbortError per connection, which matters
8
- * with thousands of queries.
3
+ * Composes with other public Reatom connection hooks. Client work belongs to
4
+ * the context, not to a subscriber's abort scope; losing a subscription must
5
+ * not cancel a socket operation or a scheduled query-set flush.
9
6
  */
10
- export const withConnectionHooks = (connect, disconnect) => (target) => {
11
- target.__reatom.onConnect = Object.assign(() => connect(), {
12
- abort: () => disconnect(),
13
- });
14
- return target;
15
- };
7
+ export const withConnectionHooks = (connect, disconnect) => withConnectHook(() => {
8
+ abortVar.spawn(connect);
9
+ return disconnect;
10
+ });
@@ -8,7 +8,7 @@
8
8
  * verifier, signs in. `auth:signOut` ends the server session. Storage keys and
9
9
  * formats are `@convex-dev/auth/react`'s, so its sessions carry over.
10
10
  */
11
- import { action, atom, computed, peek, withAsync, withConnectHook, wrap, } from "@reatom/core";
11
+ import { abortVar, action, atom, computed, peek, onEvent, sleep, withAsync, withConnectHook, wrap, } from "@reatom/core";
12
12
  import { ConvexHttpClient } from "convex/browser";
13
13
  import { makeFunctionReference } from "convex/server";
14
14
  import { jwtDecode } from "../../vendor/jwt-decode/index.js";
@@ -48,16 +48,12 @@ export function reatomConvexAuthProvider(convex, options = {}, name = `${convex.
48
48
  ? storage().removeItem(key(base))
49
49
  : storage().setItem(key(base), value));
50
50
  const token = atom(null, `${name}.token`).extend(withConnectHook(() => {
51
- void start();
52
- const stopFollowing = followStorage();
53
- const stopWarning = warnOnUnload();
54
- return () => {
55
- stopFollowing?.();
56
- stopWarning?.();
57
- };
51
+ void abortVar.spawn(start);
52
+ followStorage();
53
+ warnOnUnload();
58
54
  }));
59
55
  const restoring = atom(true, `${name}._restoring`).extend(withConnectHook(() => {
60
- void start();
56
+ void abortVar.spawn(start);
61
57
  }));
62
58
  const isRefreshingToken = atom(false, `${name}.isRefreshingToken`);
63
59
  const started = atom(false, `${name}._started`);
@@ -95,7 +91,7 @@ export function reatomConvexAuthProvider(convex, options = {}, name = `${convex.
95
91
  if (!(error instanceof TypeError) || retry >= RETRY_BACKOFF.length)
96
92
  throw error;
97
93
  const delay = RETRY_BACKOFF[retry] + RETRY_JITTER * Math.random();
98
- await wrap(new Promise((resolve) => setTimeout(resolve, delay)));
94
+ await wrap(sleep(delay));
99
95
  }
100
96
  }
101
97
  };
@@ -188,27 +184,23 @@ export function reatomConvexAuthProvider(convex, options = {}, name = `${convex.
188
184
  const followStorage = () => {
189
185
  if (typeof window === "undefined")
190
186
  return undefined;
191
- const listener = wrap((event) => {
187
+ return onEvent(window, "storage", (event) => {
192
188
  if (event.storageArea !== storage() || event.key !== key(JWT_KEY))
193
189
  return;
194
190
  // Each key comes as its own event; the refresh token stays in storage.
195
191
  apply(event.newValue);
196
192
  });
197
- window.addEventListener("storage", listener);
198
- return () => window.removeEventListener("storage", listener);
199
193
  };
200
194
  // Leaving during a refresh token exchange can lose the new pair.
201
195
  const warnOnUnload = () => {
202
196
  if (typeof window === "undefined")
203
197
  return undefined;
204
- const listener = wrap((event) => {
198
+ return onEvent(window, "beforeunload", (event) => {
205
199
  if (!peek(isRefreshingToken))
206
200
  return;
207
201
  event.preventDefault();
208
202
  event.returnValue = true;
209
203
  });
210
- window.addEventListener("beforeunload", listener);
211
- return () => window.removeEventListener("beforeunload", listener);
212
204
  };
213
205
  return {
214
206
  isLoading,
@@ -72,7 +72,7 @@ export function reatomSocket(uri, options, callbacks, name) {
72
72
  const state = atom({ state: "disconnected" }, `${name}.state`);
73
73
  const connectionCount = atom(0, `${name}.connectionCount`);
74
74
  /** Consecutive failed connections, reset once the client syncs past a reconnect. */
75
- const retries = atom(0, `${name}.retries`);
75
+ const retries = atom(0, `${name}._retries`);
76
76
  const hasEverConnected = atom(false, `${name}.hasEverConnected`);
77
77
  const runtime = atom(() => ({
78
78
  root: context(),
@@ -150,6 +150,8 @@ export function reatomSocket(uri, options, callbacks, name) {
150
150
  }
151
151
  return transition;
152
152
  };
153
+ // Socket timers outlive individual subscribers. terminate clears both
154
+ // inactivity and reconnect timers; each reschedule cancels its predecessor.
153
155
  const resetServerInactivityTimeout = () => {
154
156
  if (state().state === "terminated")
155
157
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reatomized/convex",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "A Convex sync client with its state in Reatom atoms",
5
5
  "keywords": [
6
6
  "convex",