@reatomized/convex 0.3.1 → 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 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.48.0.
15
+ app's Reatom context. It speaks the sync protocol of convex 1.49.0.
16
16
 
17
17
  ## Usage
18
18
 
@@ -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 and later have a global `WebSocket`. On Node 20, pass one, such as the
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
 
@@ -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
+ }
@@ -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.48.0";
1
+ export declare const version = "1.49.0";
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export const version = "1.48.0";
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.1",
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
- "typecheck": "tsc --noEmit",
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.48.0"
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.48.0",
70
+ "convex": "1.49.0",
59
71
  "eslint": "^9.39.0",
60
72
  "globals": "^17.4.0",
61
73
  "happy-dom": "^20.10.0",