@reatomized/convex 0.3.0 → 0.4.0
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 +64 -7
- package/dist/browser/reatom/auth.d.ts +1 -1
- package/dist/browser/reatom/auth.js +38 -35
- package/dist/browser/reatom/better_auth.js +2 -2
- package/dist/browser/reatom/client.js +13 -10
- package/dist/browser/reatom/connection_hooks.d.ts +4 -8
- package/dist/browser/reatom/connection_hooks.js +8 -13
- package/dist/browser/reatom/convex_auth.js +8 -16
- package/dist/browser/reatom/fake_auth.d.ts +84 -0
- package/dist/browser/reatom/fake_auth.js +138 -0
- package/dist/browser/reatom/fake_client.d.ts +107 -0
- package/dist/browser/reatom/fake_client.js +307 -0
- package/dist/browser/reatom/socket.js +3 -1
- package/dist/browser/reatom/testing.d.ts +4 -0
- package/dist/browser/reatom/testing.js +3 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +17 -5
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ npm install @reatomized/convex convex @reatom/core
|
|
|
12
12
|
|
|
13
13
|
`convex` and `@reatom/core` are peer dependencies. The client takes function
|
|
14
14
|
references and `ConvexError` from your `convex`, and its atoms live in your
|
|
15
|
-
app's Reatom context. It speaks the sync protocol of convex 1.
|
|
15
|
+
app's Reatom context. It speaks the sync protocol of convex 1.49.0.
|
|
16
16
|
|
|
17
17
|
## Usage
|
|
18
18
|
|
|
@@ -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,
|
|
@@ -115,6 +115,63 @@ For another library, pass any object with `isLoading` and `session` atoms and a
|
|
|
115
115
|
user or organization id. Pass an atom of providers to switch libraries at
|
|
116
116
|
runtime.
|
|
117
117
|
|
|
118
|
+
## Testing
|
|
119
|
+
|
|
120
|
+
`@reatomized/convex/testing` has fakes for UI tests and stories, with no
|
|
121
|
+
deployment and no socket. `reatomConvexClientFake()` is a `ReatomConvexClient`
|
|
122
|
+
whose functions are handlers, registered per function reference with types from
|
|
123
|
+
it:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { loading, reatomConvexClientFake } from "@reatomized/convex/testing";
|
|
127
|
+
import { atom } from "@reatom/core";
|
|
128
|
+
import { ConvexError } from "convex/values";
|
|
129
|
+
import { api } from "../convex/_generated/api";
|
|
130
|
+
|
|
131
|
+
const convex = reatomConvexClientFake();
|
|
132
|
+
const notes = atom<string[]>([], "notes");
|
|
133
|
+
const ready = atom(true, "ready");
|
|
134
|
+
|
|
135
|
+
convex.handleQuery(api.notes.list, () => (ready() ? notes() : loading));
|
|
136
|
+
convex.handleMutation(api.notes.add, ({ text }, { identity }) => {
|
|
137
|
+
if (!identity) throw new ConvexError("Not authenticated");
|
|
138
|
+
notes.set((all) => [...all, text]);
|
|
139
|
+
return null;
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
- Query handlers run inside computeds. A handler that reads atoms runs again
|
|
144
|
+
when they change, and a mutation handler that writes them updates every query
|
|
145
|
+
that reads them.
|
|
146
|
+
- Returning `loading` keeps a query loading. A handler that throws fails the
|
|
147
|
+
query with that error in `error`, so a `ConvexError` keeps its `data`. Query
|
|
148
|
+
handlers are synchronous; mutation and action handlers may return promises.
|
|
149
|
+
- Results go through `convexToJson`, as on the wire: `undefined` arrives as
|
|
150
|
+
`null`, and a value Convex can't send fails the call.
|
|
151
|
+
- A function without a handler throws `MissingHandlerError` naming it: on read
|
|
152
|
+
for a query, at the call for a mutation or action. App code may catch that
|
|
153
|
+
throw, so the fake also logs it with `console.error` and lists it in
|
|
154
|
+
`unhandled`.
|
|
155
|
+
- The second argument of a handler is `{ identity }`, like
|
|
156
|
+
`ctx.auth.getUserIdentity()`. `setAuth` takes it from the token's claims;
|
|
157
|
+
`identity` is also settable.
|
|
158
|
+
- `paginatedQuery` pages through a query handler's results;
|
|
159
|
+
`paginate(items, paginationOpts)` cuts a page from an array.
|
|
160
|
+
- `connected` and `isRefreshingAuth` set what the client reports.
|
|
161
|
+
- Handlers and state are per Reatom context, like the real client's. Register
|
|
162
|
+
handlers inside the frame that uses them.
|
|
163
|
+
|
|
164
|
+
For auth, `reatomAuthProviderFake({ identity })` is an auth library for the real
|
|
165
|
+
`reatomAuthState(convex, provider)`: its token carries `identity`, so the
|
|
166
|
+
handlers see who signed in, and setting `provider.identity` signs in or out. Use
|
|
167
|
+
it to run the app's own auth state. `reatomAuthStateFake(status)` returns the
|
|
168
|
+
shape `reatomAuthState` returns with a settable `status`, for code that takes an
|
|
169
|
+
auth state and should show `loading` or `rejected` without a client.
|
|
170
|
+
|
|
171
|
+
For Storybook,
|
|
172
|
+
[`@reatomized/convex-storybook`](https://github.com/reatomized/convex/tree/reatom/packages/convex-storybook#readme)
|
|
173
|
+
builds these fakes per story from typed `parameters.convex`.
|
|
174
|
+
|
|
118
175
|
## Node
|
|
119
176
|
|
|
120
177
|
With `@reatom/core@1001.3.0` loaded, Node never exits on its own, so a script
|
|
@@ -126,8 +183,7 @@ installs from a URL only with `--allow-remote`):
|
|
|
126
183
|
npm install --allow-remote=root @reatom/core@https://pkg.pr.new/@reatom/core@5f42184
|
|
127
184
|
```
|
|
128
185
|
|
|
129
|
-
Node 22
|
|
130
|
-
`ws` package's, as `webSocketConstructor`.
|
|
186
|
+
Convex 1.49.0 requires Node 22 or later, which has a global `WebSocket`.
|
|
131
187
|
|
|
132
188
|
## Differences from convex/browser 1.48.0
|
|
133
189
|
|
|
@@ -153,8 +209,9 @@ Node 22 and later have a global `WebSocket`. On Node 20, pass one, such as the
|
|
|
153
209
|
convex/browser's `BaseConvexClient`. The Reatom functions it uses add 4.6 KiB,
|
|
154
210
|
shared with the rest of the app.
|
|
155
211
|
|
|
156
|
-
With 1000 components each running its own query in Chromium
|
|
157
|
-
ms against
|
|
212
|
+
With 1000 components each running its own query in Chromium and @reatom/jsx
|
|
213
|
+
rendering, mounting takes 6.28 ms against 54.2 ms for convex/react, unmounting
|
|
214
|
+
0.75 ms against 7.93 ms.
|
|
158
215
|
[CONTRIBUTING.md](https://github.com/reatomized/convex/blob/reatom/CONTRIBUTING.md#performance)
|
|
159
216
|
has the full table and how it was measured.
|
|
160
217
|
|
|
@@ -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)
|
|
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
|
-
|
|
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}.
|
|
125
|
-
const identityVersion = atom(0, `${name}.
|
|
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}.
|
|
128
|
-
const maxObservedTimestamp = atom(undefined, `${name}.
|
|
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
|
|
1
|
+
import { type AtomLike, type Ext } from "@reatom/core";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
3
|
-
*
|
|
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) => (
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth fakes: identities carried in unsigned JWTs, an auth library session for
|
|
3
|
+
* `reatomAuthState`, and an auth state with a settable status.
|
|
4
|
+
*/
|
|
5
|
+
import { type Atom, type Computed } from "@reatom/core";
|
|
6
|
+
import type { UserIdentity } from "convex/server";
|
|
7
|
+
import type { AuthTokenFetcher } from "./auth.js";
|
|
8
|
+
import type { AuthProvider, AuthStatus, ReatomAuthState } from "./auth_state.js";
|
|
9
|
+
/**
|
|
10
|
+
* A `UserIdentity` for `subject`, with `issuer` `https://convex.fake` unless
|
|
11
|
+
* `claims` names another.
|
|
12
|
+
*
|
|
13
|
+
* @public
|
|
14
|
+
*/
|
|
15
|
+
export declare function fakeIdentity(subject: string, claims?: Omit<Partial<UserIdentity>, "subject" | "tokenIdentifier">): UserIdentity;
|
|
16
|
+
/**
|
|
17
|
+
* An unsigned JWT carrying `identity` as OIDC claims, valid for an hour.
|
|
18
|
+
* `reatomConvexClientFake` reads the identity back from it.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
export declare function fakeToken(identity: UserIdentity): string;
|
|
23
|
+
/**
|
|
24
|
+
* The identity Convex derives from a JWT's claims. A token that isn't a JWT
|
|
25
|
+
* becomes an identity whose subject is the token.
|
|
26
|
+
*/
|
|
27
|
+
export declare function identityFromToken(token: string): UserIdentity;
|
|
28
|
+
/**
|
|
29
|
+
* An auth library session for `reatomAuthState`, set from a test.
|
|
30
|
+
*
|
|
31
|
+
* @public
|
|
32
|
+
*/
|
|
33
|
+
export interface AuthProviderFake extends AuthProvider {
|
|
34
|
+
/** The signed-in user, `null` when signed out. */
|
|
35
|
+
readonly identity: Atom<UserIdentity | null>;
|
|
36
|
+
readonly isLoading: Atom<boolean>;
|
|
37
|
+
/** The identity's `tokenIdentifier`. */
|
|
38
|
+
readonly session: Computed<string | null>;
|
|
39
|
+
}
|
|
40
|
+
/** @public */
|
|
41
|
+
export type AuthProviderFakeOptions = {
|
|
42
|
+
identity?: UserIdentity | null | undefined;
|
|
43
|
+
isLoading?: boolean | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Replaces the token fetch, which returns `fakeToken(identity)`. A fetch
|
|
46
|
+
* that returns `null` makes `reatomAuthState` report `rejected`.
|
|
47
|
+
*/
|
|
48
|
+
fetchToken?: AuthTokenFetcher | undefined;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* A fake auth library for `reatomAuthState(convex, provider)`. Its token
|
|
52
|
+
* carries `identity`, so `reatomConvexClientFake` hands that identity to
|
|
53
|
+
* function handlers once `setAuth` accepts the token.
|
|
54
|
+
*
|
|
55
|
+
* ```ts
|
|
56
|
+
* const provider = reatomAuthProviderFake({ identity: fakeIdentity("alex") });
|
|
57
|
+
* const auth = reatomAuthState(convex, provider);
|
|
58
|
+
* provider.identity.set(null); // signs out
|
|
59
|
+
* ```
|
|
60
|
+
*
|
|
61
|
+
* @public
|
|
62
|
+
*/
|
|
63
|
+
export declare function reatomAuthProviderFake(options?: AuthProviderFakeOptions, name?: string): AuthProviderFake;
|
|
64
|
+
/**
|
|
65
|
+
* The shape `reatomAuthState` returns, with a settable `status`.
|
|
66
|
+
*
|
|
67
|
+
* @public
|
|
68
|
+
*/
|
|
69
|
+
export interface ReatomAuthStateFake extends ReatomAuthState {
|
|
70
|
+
readonly status: Atom<AuthStatus>;
|
|
71
|
+
readonly isRefreshing: Atom<boolean>;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* An auth state that reports `status` without a client or auth library.
|
|
75
|
+
* `isLoading` and `isAuthenticated` follow `status`.
|
|
76
|
+
*
|
|
77
|
+
* ```ts
|
|
78
|
+
* const auth = reatomAuthStateFake("loading");
|
|
79
|
+
* auth.status.set("rejected");
|
|
80
|
+
* ```
|
|
81
|
+
*
|
|
82
|
+
* @public
|
|
83
|
+
*/
|
|
84
|
+
export declare function reatomAuthStateFake(status?: AuthStatus, name?: string): ReatomAuthStateFake;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth fakes: identities carried in unsigned JWTs, an auth library session for
|
|
3
|
+
* `reatomAuthState`, and an auth state with a settable status.
|
|
4
|
+
*/
|
|
5
|
+
import { atom, computed } from "@reatom/core";
|
|
6
|
+
import { jwtDecode } from "../../vendor/jwt-decode/index.js";
|
|
7
|
+
const FAKE_ISSUER = "https://convex.fake";
|
|
8
|
+
/** `UserIdentity` fields and the OIDC claims Convex reads them from. */
|
|
9
|
+
const CLAIMS = {
|
|
10
|
+
givenName: "given_name",
|
|
11
|
+
familyName: "family_name",
|
|
12
|
+
preferredUsername: "preferred_username",
|
|
13
|
+
profileUrl: "profile",
|
|
14
|
+
pictureUrl: "picture",
|
|
15
|
+
emailVerified: "email_verified",
|
|
16
|
+
birthday: "birthdate",
|
|
17
|
+
timezone: "zoneinfo",
|
|
18
|
+
language: "locale",
|
|
19
|
+
phoneNumber: "phone_number",
|
|
20
|
+
phoneNumberVerified: "phone_number_verified",
|
|
21
|
+
updatedAt: "updated_at",
|
|
22
|
+
};
|
|
23
|
+
const FIELDS = Object.fromEntries(Object.entries(CLAIMS).map(([field, claim]) => [claim, field]));
|
|
24
|
+
const RESERVED = new Set(["sub", "iss", "aud", "exp", "iat", "nbf", "jti"]);
|
|
25
|
+
/**
|
|
26
|
+
* A `UserIdentity` for `subject`, with `issuer` `https://convex.fake` unless
|
|
27
|
+
* `claims` names another.
|
|
28
|
+
*
|
|
29
|
+
* @public
|
|
30
|
+
*/
|
|
31
|
+
export function fakeIdentity(subject, claims = {}) {
|
|
32
|
+
const issuer = typeof claims.issuer === "string" ? claims.issuer : FAKE_ISSUER;
|
|
33
|
+
return {
|
|
34
|
+
...claims,
|
|
35
|
+
subject,
|
|
36
|
+
issuer,
|
|
37
|
+
tokenIdentifier: `${issuer}|${subject}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const base64url = (json) => {
|
|
41
|
+
let binary = "";
|
|
42
|
+
for (const byte of new TextEncoder().encode(json)) {
|
|
43
|
+
binary += String.fromCharCode(byte);
|
|
44
|
+
}
|
|
45
|
+
return btoa(binary)
|
|
46
|
+
.replace(/\+/g, "-")
|
|
47
|
+
.replace(/\//g, "_")
|
|
48
|
+
.replace(/=+$/, "");
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* An unsigned JWT carrying `identity` as OIDC claims, valid for an hour.
|
|
52
|
+
* `reatomConvexClientFake` reads the identity back from it.
|
|
53
|
+
*
|
|
54
|
+
* @public
|
|
55
|
+
*/
|
|
56
|
+
export function fakeToken(identity) {
|
|
57
|
+
const { tokenIdentifier: _, subject, issuer, ...fields } = identity;
|
|
58
|
+
const claims = { sub: subject, iss: issuer };
|
|
59
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
60
|
+
claims[CLAIMS[field] ?? field] = value;
|
|
61
|
+
}
|
|
62
|
+
const iat = Math.floor(Date.now() / 1000);
|
|
63
|
+
claims.iat = iat;
|
|
64
|
+
claims.exp = iat + 3600;
|
|
65
|
+
return [
|
|
66
|
+
base64url(JSON.stringify({ alg: "none", typ: "JWT" })),
|
|
67
|
+
base64url(JSON.stringify(claims)),
|
|
68
|
+
"",
|
|
69
|
+
].join(".");
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The identity Convex derives from a JWT's claims. A token that isn't a JWT
|
|
73
|
+
* becomes an identity whose subject is the token.
|
|
74
|
+
*/
|
|
75
|
+
export function identityFromToken(token) {
|
|
76
|
+
let claims;
|
|
77
|
+
try {
|
|
78
|
+
claims = jwtDecode(token);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return fakeIdentity(token);
|
|
82
|
+
}
|
|
83
|
+
const fields = {};
|
|
84
|
+
for (const [claim, value] of Object.entries(claims)) {
|
|
85
|
+
if (!RESERVED.has(claim))
|
|
86
|
+
fields[FIELDS[claim] ?? claim] = value;
|
|
87
|
+
}
|
|
88
|
+
return fakeIdentity(typeof claims.sub === "string" ? claims.sub : token, {
|
|
89
|
+
...fields,
|
|
90
|
+
issuer: typeof claims.iss === "string" ? claims.iss : FAKE_ISSUER,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* A fake auth library for `reatomAuthState(convex, provider)`. Its token
|
|
95
|
+
* carries `identity`, so `reatomConvexClientFake` hands that identity to
|
|
96
|
+
* function handlers once `setAuth` accepts the token.
|
|
97
|
+
*
|
|
98
|
+
* ```ts
|
|
99
|
+
* const provider = reatomAuthProviderFake({ identity: fakeIdentity("alex") });
|
|
100
|
+
* const auth = reatomAuthState(convex, provider);
|
|
101
|
+
* provider.identity.set(null); // signs out
|
|
102
|
+
* ```
|
|
103
|
+
*
|
|
104
|
+
* @public
|
|
105
|
+
*/
|
|
106
|
+
export function reatomAuthProviderFake(options = {}, name = "authProviderFake") {
|
|
107
|
+
const identity = atom(options.identity ?? null, `${name}.identity`);
|
|
108
|
+
return {
|
|
109
|
+
identity,
|
|
110
|
+
isLoading: atom(options.isLoading ?? false, `${name}.isLoading`),
|
|
111
|
+
session: computed(() => identity()?.tokenIdentifier ?? null, `${name}.session`),
|
|
112
|
+
fetchToken: options.fetchToken ??
|
|
113
|
+
(() => {
|
|
114
|
+
const current = identity();
|
|
115
|
+
return Promise.resolve(current ? fakeToken(current) : null);
|
|
116
|
+
}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* An auth state that reports `status` without a client or auth library.
|
|
121
|
+
* `isLoading` and `isAuthenticated` follow `status`.
|
|
122
|
+
*
|
|
123
|
+
* ```ts
|
|
124
|
+
* const auth = reatomAuthStateFake("loading");
|
|
125
|
+
* auth.status.set("rejected");
|
|
126
|
+
* ```
|
|
127
|
+
*
|
|
128
|
+
* @public
|
|
129
|
+
*/
|
|
130
|
+
export function reatomAuthStateFake(status = "authenticated", name = "authStateFake") {
|
|
131
|
+
const current = atom(status, `${name}.status`);
|
|
132
|
+
return {
|
|
133
|
+
status: current,
|
|
134
|
+
isLoading: computed(() => current() === "loading", `${name}.isLoading`),
|
|
135
|
+
isAuthenticated: computed(() => current() === "authenticated", `${name}.isAuthenticated`),
|
|
136
|
+
isRefreshing: atom(false, `${name}.isRefreshing`),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A client whose functions are handlers a test registers, for UI tests and
|
|
3
|
+
* stories without a deployment. Query handlers run inside computeds, so a
|
|
4
|
+
* handler that reads atoms re-runs when they change, and a mutation handler
|
|
5
|
+
* that writes them updates every query that reads them.
|
|
6
|
+
*
|
|
7
|
+
* Handlers and state are per Reatom context, like the real client's.
|
|
8
|
+
*/
|
|
9
|
+
import { type Atom } from "@reatom/core";
|
|
10
|
+
import { FunctionArgs, FunctionReference, FunctionReturnType, PaginationOptions, PaginationResult, UserIdentity } from "convex/server";
|
|
11
|
+
import type { ReatomConvexClient } from "./client.js";
|
|
12
|
+
import type { SocketState } from "./socket.js";
|
|
13
|
+
/**
|
|
14
|
+
* Return it from a query handler to keep the query loading.
|
|
15
|
+
*
|
|
16
|
+
* @public
|
|
17
|
+
*/
|
|
18
|
+
export declare const loading: unique symbol;
|
|
19
|
+
/**
|
|
20
|
+
* What a handler knows besides its arguments, like a function's `ctx.auth`.
|
|
21
|
+
*
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
export type FunctionContext = {
|
|
25
|
+
/**
|
|
26
|
+
* The identity of the token the client authenticated with, or the one set on
|
|
27
|
+
* `identity`; `null` without one.
|
|
28
|
+
*/
|
|
29
|
+
readonly identity: UserIdentity | null;
|
|
30
|
+
};
|
|
31
|
+
/** @public */
|
|
32
|
+
export type QueryHandler<Query extends FunctionReference<"query">> = (args: FunctionArgs<Query>, ctx: FunctionContext) => FunctionReturnType<Query> | typeof loading;
|
|
33
|
+
/** @public */
|
|
34
|
+
export type MutationHandler<Mutation extends FunctionReference<"mutation">> = (args: FunctionArgs<Mutation>, ctx: FunctionContext) => FunctionReturnType<Mutation> | Promise<FunctionReturnType<Mutation>>;
|
|
35
|
+
/** @public */
|
|
36
|
+
export type ActionHandler<Action extends FunctionReference<"action">> = (args: FunctionArgs<Action>, ctx: FunctionContext) => FunctionReturnType<Action> | Promise<FunctionReturnType<Action>>;
|
|
37
|
+
type FunctionType = "query" | "mutation" | "action";
|
|
38
|
+
/**
|
|
39
|
+
* A function called on a fake client without a handler. Reading such a query
|
|
40
|
+
* throws it; calling such a mutation or action throws it before returning a
|
|
41
|
+
* promise. The fake also logs it with `console.error` and lists it in
|
|
42
|
+
* `unhandled`, since app code may catch the throw.
|
|
43
|
+
*
|
|
44
|
+
* @public
|
|
45
|
+
*/
|
|
46
|
+
export declare class MissingHandlerError extends Error {
|
|
47
|
+
readonly type: FunctionType;
|
|
48
|
+
readonly udfPath: string;
|
|
49
|
+
constructor(type: FunctionType, udfPath: string);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A page of `items` for a paginated query handler. Cursors are offsets into
|
|
53
|
+
* `items`, so an item inserted before a loaded page shifts the pages after it.
|
|
54
|
+
*
|
|
55
|
+
* @public
|
|
56
|
+
*/
|
|
57
|
+
export declare function paginate<T>(items: readonly T[], { cursor, numItems, endCursor }: PaginationOptions): PaginationResult<T>;
|
|
58
|
+
/**
|
|
59
|
+
* A {@link ReatomConvexClient} that runs registered handlers instead of
|
|
60
|
+
* talking to a deployment.
|
|
61
|
+
*
|
|
62
|
+
* @public
|
|
63
|
+
*/
|
|
64
|
+
export interface ReatomConvexClientFake extends ReatomConvexClient {
|
|
65
|
+
/** Set by `setAuth` and `clearAuth`; settable to skip the token flow. */
|
|
66
|
+
readonly isAuthenticated: Atom<boolean | null>;
|
|
67
|
+
readonly isRefreshingAuth: Atom<boolean>;
|
|
68
|
+
/**
|
|
69
|
+
* The identity handlers see. `setAuth` sets it from the token's claims and
|
|
70
|
+
* `clearAuth` resets it.
|
|
71
|
+
*/
|
|
72
|
+
readonly identity: Atom<UserIdentity | null>;
|
|
73
|
+
readonly socketState: Atom<SocketState>;
|
|
74
|
+
/**
|
|
75
|
+
* Whether `connectionState` reports an open socket, `true` by default.
|
|
76
|
+
* Requests still run while it is `false`.
|
|
77
|
+
*/
|
|
78
|
+
readonly connected: Atom<boolean>;
|
|
79
|
+
/** Functions called without a handler in the current frame, once each. */
|
|
80
|
+
readonly unhandled: readonly MissingHandlerError[];
|
|
81
|
+
/**
|
|
82
|
+
* Answer `query` with `handler`, replacing an earlier one. Queries using it
|
|
83
|
+
* run it again. Call it inside a Reatom frame.
|
|
84
|
+
*/
|
|
85
|
+
handleQuery<Query extends FunctionReference<"query">>(query: Query, handler: QueryHandler<Query>): void;
|
|
86
|
+
handleMutation<Mutation extends FunctionReference<"mutation">>(mutation: Mutation, handler: MutationHandler<Mutation>): void;
|
|
87
|
+
handleAction<Action extends FunctionReference<"action">>(action: Action, handler: ActionHandler<Action>): void;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Create a fake client. Register handlers inside the frame that uses it:
|
|
91
|
+
*
|
|
92
|
+
* ```ts
|
|
93
|
+
* const convex = reatomConvexClientFake();
|
|
94
|
+
* const notes = atom<string[]>([], "notes");
|
|
95
|
+
* convex.handleQuery(api.notes.list, () => notes());
|
|
96
|
+
* convex.handleMutation(api.notes.add, ({ text }) => {
|
|
97
|
+
* notes.set((all) => [...all, text]);
|
|
98
|
+
* return null;
|
|
99
|
+
* });
|
|
100
|
+
* ```
|
|
101
|
+
*
|
|
102
|
+
* @param name - A prefix for the client's atom names.
|
|
103
|
+
*
|
|
104
|
+
* @public
|
|
105
|
+
*/
|
|
106
|
+
export declare function reatomConvexClientFake(name?: string): ReatomConvexClientFake;
|
|
107
|
+
export {};
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A client whose functions are handlers a test registers, for UI tests and
|
|
3
|
+
* stories without a deployment. Query handlers run inside computeds, so a
|
|
4
|
+
* handler that reads atoms re-runs when they change, and a mutation handler
|
|
5
|
+
* that writes them updates every query that reads them.
|
|
6
|
+
*
|
|
7
|
+
* Handlers and state are per Reatom context, like the real client's.
|
|
8
|
+
*/
|
|
9
|
+
import { action, atom, computed, context, peek, wrap, } from "@reatom/core";
|
|
10
|
+
import { getFunctionName, } from "convex/server";
|
|
11
|
+
import { ConvexError, convexToJson, jsonToConvex } from "convex/values";
|
|
12
|
+
import { parseArgs } from "../../common/index.js";
|
|
13
|
+
import { jwtDecode } from "../../vendor/jwt-decode/index.js";
|
|
14
|
+
import { canonicalizeUdfPath, serializePaginatedPathAndArgs, } from "../sync/udf_path_utils.js";
|
|
15
|
+
import { identityFromToken } from "./fake_auth.js";
|
|
16
|
+
import { reatomPaginatedQuery } from "./paginated.js";
|
|
17
|
+
/**
|
|
18
|
+
* Return it from a query handler to keep the query loading.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
export const loading = Symbol("loading");
|
|
23
|
+
const capitalized = { query: "Query", mutation: "Mutation", action: "Action" };
|
|
24
|
+
/**
|
|
25
|
+
* A function called on a fake client without a handler. Reading such a query
|
|
26
|
+
* throws it; calling such a mutation or action throws it before returning a
|
|
27
|
+
* promise. The fake also logs it with `console.error` and lists it in
|
|
28
|
+
* `unhandled`, since app code may catch the throw.
|
|
29
|
+
*
|
|
30
|
+
* @public
|
|
31
|
+
*/
|
|
32
|
+
export class MissingHandlerError extends Error {
|
|
33
|
+
type;
|
|
34
|
+
udfPath;
|
|
35
|
+
constructor(type, udfPath) {
|
|
36
|
+
const [module, name] = udfPath.split(":");
|
|
37
|
+
const reference = ["api", ...module.split("/"), name].join(".");
|
|
38
|
+
super(`No fake handler for ${type} ${udfPath}. Register one with handle${capitalized[type]}(${reference}, ...).`);
|
|
39
|
+
this.name = "MissingHandlerError";
|
|
40
|
+
this.type = type;
|
|
41
|
+
this.udfPath = udfPath;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A page of `items` for a paginated query handler. Cursors are offsets into
|
|
46
|
+
* `items`, so an item inserted before a loaded page shifts the pages after it.
|
|
47
|
+
*
|
|
48
|
+
* @public
|
|
49
|
+
*/
|
|
50
|
+
export function paginate(items, { cursor, numItems, endCursor }) {
|
|
51
|
+
const start = cursor === null ? 0 : Number(cursor);
|
|
52
|
+
const end = Math.min(endCursor === undefined || endCursor === null
|
|
53
|
+
? start + numItems
|
|
54
|
+
: Number(endCursor), items.length);
|
|
55
|
+
return {
|
|
56
|
+
page: items.slice(start, end),
|
|
57
|
+
isDone: end >= items.length,
|
|
58
|
+
continueCursor: String(end),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const asError = (error) => error instanceof Error ? error : new Error(String(error));
|
|
62
|
+
const functionName = (ref) => canonicalizeUdfPath(typeof ref === "string" ? ref : getFunctionName(ref));
|
|
63
|
+
/**
|
|
64
|
+
* Create a fake client. Register handlers inside the frame that uses it:
|
|
65
|
+
*
|
|
66
|
+
* ```ts
|
|
67
|
+
* const convex = reatomConvexClientFake();
|
|
68
|
+
* const notes = atom<string[]>([], "notes");
|
|
69
|
+
* convex.handleQuery(api.notes.list, () => notes());
|
|
70
|
+
* convex.handleMutation(api.notes.add, ({ text }) => {
|
|
71
|
+
* notes.set((all) => [...all, text]);
|
|
72
|
+
* return null;
|
|
73
|
+
* });
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* @param name - A prefix for the client's atom names.
|
|
77
|
+
*
|
|
78
|
+
* @public
|
|
79
|
+
*/
|
|
80
|
+
export function reatomConvexClientFake(name = "convex") {
|
|
81
|
+
const handlers = atom(() => ({
|
|
82
|
+
query: new Map(),
|
|
83
|
+
mutation: new Map(),
|
|
84
|
+
action: new Map(),
|
|
85
|
+
}), `${name}._handlers`);
|
|
86
|
+
const identity = atom(null, `${name}.identity`);
|
|
87
|
+
const isAuthenticated = atom(false, `${name}.isAuthenticated`);
|
|
88
|
+
const isRefreshingAuth = atom(false, `${name}.isRefreshingAuth`);
|
|
89
|
+
const authToken = atom(undefined, `${name}.authToken`);
|
|
90
|
+
/** Bumped by each `setAuth` and `clearAuth`, so a stale token is dropped. */
|
|
91
|
+
const authVersion = atom(0, `${name}._authVersion`);
|
|
92
|
+
const connected = atom(true, `${name}.connected`);
|
|
93
|
+
const inflight = atom([], `${name}._inflight`);
|
|
94
|
+
const unhandled = atom(() => [], `${name}._unhandled`);
|
|
95
|
+
const miss = (type, udfPath) => {
|
|
96
|
+
const error = new MissingHandlerError(type, udfPath);
|
|
97
|
+
const missing = peek(unhandled);
|
|
98
|
+
if (!missing.some((seen) => seen.message === error.message)) {
|
|
99
|
+
missing.push(error);
|
|
100
|
+
// A second channel: app code may catch the throw and show it as a
|
|
101
|
+
// server failure.
|
|
102
|
+
// eslint-disable-next-line no-console
|
|
103
|
+
console.error(error.message);
|
|
104
|
+
}
|
|
105
|
+
return error;
|
|
106
|
+
};
|
|
107
|
+
const register = (type, ref, handler) => {
|
|
108
|
+
const udfPath = functionName(ref);
|
|
109
|
+
handlers.set((current) => ({
|
|
110
|
+
...current,
|
|
111
|
+
[type]: new Map(current[type]).set(udfPath, handler),
|
|
112
|
+
}));
|
|
113
|
+
};
|
|
114
|
+
const functionContext = () => ({ identity: identity() });
|
|
115
|
+
/** A handler's result as the client would receive it from the server. */
|
|
116
|
+
const received = (type, udfPath, returned) => {
|
|
117
|
+
try {
|
|
118
|
+
return jsonToConvex(convexToJson((returned === undefined ? null : returned)));
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
throw new Error(`The fake ${type} handler for ${udfPath} returned a value Convex can't send: ${asError(error).message}`);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
let nextTraceId = 0;
|
|
125
|
+
const entries = new Map();
|
|
126
|
+
const queryEntry = (udfPath, args) => {
|
|
127
|
+
const token = JSON.stringify({
|
|
128
|
+
udfPath,
|
|
129
|
+
args: convexToJson(args),
|
|
130
|
+
});
|
|
131
|
+
const existing = entries.get(token);
|
|
132
|
+
if (existing !== undefined)
|
|
133
|
+
return existing;
|
|
134
|
+
const trace = `${name}.query#${udfPath}#${nextTraceId++}`;
|
|
135
|
+
const outcome = computed(() => {
|
|
136
|
+
const handler = handlers().query.get(udfPath);
|
|
137
|
+
if (handler === undefined)
|
|
138
|
+
throw miss("query", udfPath);
|
|
139
|
+
let returned;
|
|
140
|
+
try {
|
|
141
|
+
returned = handler(args, functionContext());
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
return { status: "error", error: asError(error) };
|
|
145
|
+
}
|
|
146
|
+
if (returned === loading)
|
|
147
|
+
return { status: "loading" };
|
|
148
|
+
if (returned instanceof Promise) {
|
|
149
|
+
throw new Error(`The fake query handler for ${udfPath} returned a Promise. Query handlers are synchronous: return \`loading\` until an atom the handler reads changes.`);
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
status: "success",
|
|
153
|
+
value: received("query", udfPath, returned),
|
|
154
|
+
};
|
|
155
|
+
}, `${trace}.outcome`);
|
|
156
|
+
const entry = {
|
|
157
|
+
token,
|
|
158
|
+
udfPath,
|
|
159
|
+
args,
|
|
160
|
+
result: computed(() => {
|
|
161
|
+
const current = outcome();
|
|
162
|
+
if (current.status === "loading")
|
|
163
|
+
return undefined;
|
|
164
|
+
if (current.status === "success") {
|
|
165
|
+
return { success: true, value: current.value, logLines: [] };
|
|
166
|
+
}
|
|
167
|
+
const { error } = current;
|
|
168
|
+
return {
|
|
169
|
+
success: false,
|
|
170
|
+
errorMessage: error.message,
|
|
171
|
+
...(error instanceof ConvexError
|
|
172
|
+
? { errorData: error.data }
|
|
173
|
+
: {}),
|
|
174
|
+
logLines: [],
|
|
175
|
+
};
|
|
176
|
+
}, trace),
|
|
177
|
+
data: computed(() => {
|
|
178
|
+
const current = outcome();
|
|
179
|
+
return current.status === "success" ? current.value : undefined;
|
|
180
|
+
}, `${trace}.data`),
|
|
181
|
+
error: computed(() => {
|
|
182
|
+
const current = outcome();
|
|
183
|
+
return current.status === "error" ? current.error : undefined;
|
|
184
|
+
}, `${trace}.error`),
|
|
185
|
+
status: computed(() => outcome().status, `${trace}.status`),
|
|
186
|
+
};
|
|
187
|
+
entries.set(token, entry);
|
|
188
|
+
return entry;
|
|
189
|
+
};
|
|
190
|
+
const paginated = new Map();
|
|
191
|
+
const paginatedQueryEntry = (udfPath, args, options) => {
|
|
192
|
+
const token = serializePaginatedPathAndArgs(udfPath, args, options);
|
|
193
|
+
const existing = paginated.get(token);
|
|
194
|
+
if (existing !== undefined)
|
|
195
|
+
return existing;
|
|
196
|
+
const created = reatomPaginatedQuery((pageUdfPath, pageArgs, paginationOpts) => queryEntry(pageUdfPath, { ...pageArgs, paginationOpts }), udfPath, args, options, `${name}.paginatedQuery#${udfPath}#${nextTraceId++}`);
|
|
197
|
+
paginated.set(token, created);
|
|
198
|
+
return created;
|
|
199
|
+
};
|
|
200
|
+
const request = (type) => action(async (udfPath, handler, args) => {
|
|
201
|
+
const call = { type, at: new Date() };
|
|
202
|
+
inflight.set((all) => [...all, call]);
|
|
203
|
+
try {
|
|
204
|
+
// The server gets its own copy of the arguments.
|
|
205
|
+
const serverArgs = jsonToConvex(convexToJson(args));
|
|
206
|
+
const returned = handler(serverArgs, functionContext());
|
|
207
|
+
return received(type, udfPath, await wrap(Promise.resolve(returned)));
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
inflight.set((all) => all.filter((current) => current !== call));
|
|
211
|
+
}
|
|
212
|
+
}, `${name}.${type}`);
|
|
213
|
+
const requests = { mutation: request("mutation"), action: request("action") };
|
|
214
|
+
const call = (type, ref, args) => {
|
|
215
|
+
const udfPath = functionName(ref);
|
|
216
|
+
const handler = peek(handlers)[type].get(udfPath);
|
|
217
|
+
if (handler === undefined)
|
|
218
|
+
throw miss(type, udfPath);
|
|
219
|
+
return requests[type](udfPath, handler, parseArgs(args));
|
|
220
|
+
};
|
|
221
|
+
const setAuth = action((fetchToken, onChange, _onRefreshChange) => {
|
|
222
|
+
const version = authVersion.set((current) => current + 1);
|
|
223
|
+
isAuthenticated.set(null);
|
|
224
|
+
isRefreshingAuth.set(false);
|
|
225
|
+
// The fetch outlives the caller's frame, like the real client's flow.
|
|
226
|
+
const accept = async () => {
|
|
227
|
+
let token;
|
|
228
|
+
try {
|
|
229
|
+
token = await wrap(fetchToken({ forceRefreshToken: false }));
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
token = null;
|
|
233
|
+
}
|
|
234
|
+
if (authVersion() !== version)
|
|
235
|
+
return;
|
|
236
|
+
if (token === null || token === undefined) {
|
|
237
|
+
authToken.set(undefined);
|
|
238
|
+
identity.set(null);
|
|
239
|
+
isAuthenticated.set(false);
|
|
240
|
+
onChange?.(false);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
authToken.set(token);
|
|
244
|
+
identity.set(identityFromToken(token));
|
|
245
|
+
isAuthenticated.set(true);
|
|
246
|
+
onChange?.(true);
|
|
247
|
+
};
|
|
248
|
+
void wrap(accept, context())();
|
|
249
|
+
}, `${name}.setAuth`);
|
|
250
|
+
const clearAuth = action(() => {
|
|
251
|
+
authVersion.set((current) => current + 1);
|
|
252
|
+
authToken.set(undefined);
|
|
253
|
+
identity.set(null);
|
|
254
|
+
isAuthenticated.set(false);
|
|
255
|
+
isRefreshingAuth.set(false);
|
|
256
|
+
}, `${name}.clearAuth`);
|
|
257
|
+
const fake = {
|
|
258
|
+
name,
|
|
259
|
+
url: `fake:${name}`,
|
|
260
|
+
connectionState: computed(() => {
|
|
261
|
+
const calls = inflight();
|
|
262
|
+
return {
|
|
263
|
+
hasInflightRequests: calls.length > 0,
|
|
264
|
+
isWebSocketConnected: connected(),
|
|
265
|
+
timeOfOldestInflightRequest: calls[0]?.at ?? null,
|
|
266
|
+
hasEverConnected: true,
|
|
267
|
+
connectionCount: 1,
|
|
268
|
+
connectionRetries: 0,
|
|
269
|
+
inflightMutations: calls.filter(({ type }) => type === "mutation")
|
|
270
|
+
.length,
|
|
271
|
+
inflightActions: calls.filter(({ type }) => type === "action").length,
|
|
272
|
+
};
|
|
273
|
+
}, `${name}.connectionState`),
|
|
274
|
+
isAuthenticated,
|
|
275
|
+
isRefreshingAuth,
|
|
276
|
+
authClaims: computed(() => {
|
|
277
|
+
const token = authToken();
|
|
278
|
+
if (token === undefined)
|
|
279
|
+
return undefined;
|
|
280
|
+
let decoded;
|
|
281
|
+
try {
|
|
282
|
+
decoded = jwtDecode(token);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
decoded = {};
|
|
286
|
+
}
|
|
287
|
+
return { token, decoded };
|
|
288
|
+
}, `${name}.authClaims`),
|
|
289
|
+
socketState: atom({ state: "ready", paused: "no" }, `${name}.socketState`),
|
|
290
|
+
identity,
|
|
291
|
+
connected,
|
|
292
|
+
get unhandled() {
|
|
293
|
+
return peek(unhandled);
|
|
294
|
+
},
|
|
295
|
+
query: (query, args) => queryEntry(functionName(query), parseArgs(args)),
|
|
296
|
+
paginatedQuery: (query, args, options) => paginatedQueryEntry(functionName(query), parseArgs(args), { initialNumItems: options.initialNumItems, id: options.id ?? -1 }),
|
|
297
|
+
mutation: (mutation, args) => call("mutation", mutation, args),
|
|
298
|
+
action: (action, args) => call("action", action, args),
|
|
299
|
+
setAuth,
|
|
300
|
+
clearAuth,
|
|
301
|
+
close: action(() => Promise.resolve(), `${name}.close`),
|
|
302
|
+
handleQuery: (query, handler) => register("query", query, handler),
|
|
303
|
+
handleMutation: (mutation, handler) => register("mutation", mutation, handler),
|
|
304
|
+
handleAction: (action, handler) => register("action", action, handler),
|
|
305
|
+
};
|
|
306
|
+
return fake;
|
|
307
|
+
}
|
|
@@ -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}.
|
|
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;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { loading, MissingHandlerError, paginate, reatomConvexClientFake, } from "./fake_client.js";
|
|
2
|
+
export type { ActionHandler, FunctionContext, MutationHandler, QueryHandler, ReatomConvexClientFake, } from "./fake_client.js";
|
|
3
|
+
export { fakeIdentity, fakeToken, reatomAuthProviderFake, reatomAuthStateFake, } from "./fake_auth.js";
|
|
4
|
+
export type { AuthProviderFake, AuthProviderFakeOptions, ReatomAuthStateFake, } from "./fake_auth.js";
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// The @reatomized/convex/testing entry point: fakes for UI tests and stories.
|
|
2
|
+
export { loading, MissingHandlerError, paginate, reatomConvexClientFake, } from "./fake_client.js";
|
|
3
|
+
export { fakeIdentity, fakeToken, reatomAuthProviderFake, reatomAuthStateFake, } from "./fake_auth.js";
|
package/dist/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const version = "1.
|
|
1
|
+
export declare const version = "1.49.0";
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = "1.
|
|
1
|
+
export const version = "1.49.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reatomized/convex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "A Convex sync client with its state in Reatom atoms",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"convex",
|
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
"types": "./dist/browser/reatom/better_auth.d.ts",
|
|
34
34
|
"default": "./dist/browser/reatom/better_auth.js"
|
|
35
35
|
},
|
|
36
|
+
"./testing": {
|
|
37
|
+
"types": "./dist/browser/reatom/testing.d.ts",
|
|
38
|
+
"default": "./dist/browser/reatom/testing.js"
|
|
39
|
+
},
|
|
36
40
|
"./package.json": "./package.json"
|
|
37
41
|
},
|
|
38
42
|
"files": [
|
|
@@ -40,22 +44,30 @@
|
|
|
40
44
|
"README.md",
|
|
41
45
|
"LICENSE"
|
|
42
46
|
],
|
|
47
|
+
"workspaces": [
|
|
48
|
+
".",
|
|
49
|
+
"packages/*"
|
|
50
|
+
],
|
|
43
51
|
"scripts": {
|
|
44
|
-
"build": "node scripts/build.mjs",
|
|
52
|
+
"build": "node scripts/build.mjs && npm run build --workspace @reatomized/convex-storybook",
|
|
45
53
|
"prepack": "node scripts/build.mjs",
|
|
46
|
-
"
|
|
54
|
+
"changeset": "changeset",
|
|
55
|
+
"version-packages": "changeset version && npm install --package-lock-only --ignore-scripts --allow-remote=root",
|
|
56
|
+
"release": "changeset publish",
|
|
57
|
+
"typecheck": "tsc --noEmit && npm run typecheck --workspace @reatomized/convex-storybook",
|
|
47
58
|
"test": "vitest run",
|
|
48
59
|
"lint": "eslint ."
|
|
49
60
|
},
|
|
50
61
|
"peerDependencies": {
|
|
51
62
|
"@reatom/core": "^1001.3.0",
|
|
52
|
-
"convex": "^1.
|
|
63
|
+
"convex": "^1.49.0"
|
|
53
64
|
},
|
|
54
65
|
"devDependencies": {
|
|
66
|
+
"@changesets/cli": "^3.0.3",
|
|
55
67
|
"@eslint/js": "^9.39.0",
|
|
56
68
|
"@reatom/core": "https://pkg.pr.new/@reatom/core@5f42184",
|
|
57
69
|
"@types/node": "^24.0.0",
|
|
58
|
-
"convex": "1.
|
|
70
|
+
"convex": "1.49.0",
|
|
59
71
|
"eslint": "^9.39.0",
|
|
60
72
|
"globals": "^17.4.0",
|
|
61
73
|
"happy-dom": "^20.10.0",
|