1import { BlockingMutation, type MutationOptions } from "./mutation.ts";
2import type { Mutation, MutationAction, Serializable, SerializableValue } from "./types.ts";
3
4/**
5 * Every element of a fixed argument tuple passes the {@link Serializable} check.
6 *
7 * The recursion deliberately walks head/tail and treats the non-tuple tail as
8 * valid: any construct that inspects the whole tuple instead (`keyof`, `length`,
9 * a mapped type, or `[...infer A]` reconstruction) forces resolution of the
10 * `NoInfer`-wrapped argument type and misfires on the `async function` mutate
11 * form, which is common. The cost is that a purely variadic mutate signature
12 * (`mutate: (...xs: NonSerializable[])`) reaches the `true` base case unchecked;
13 * fixed and leading-fixed parameters are still validated.
14 */
15type AllSerializable<Args extends unknown[]> = Args extends [infer Head, ...infer Tail]
16 ? [Head] extends [Serializable<Head>] ? AllSerializable<Tail> : false
17 : true;
18
19/**
20 * Compile-time guard for the serializable-argument rule, expressed as `define`'s
21 * trailing rest parameter: an empty tuple (nothing to pass) when the rule holds,
22 * a one-element tuple otherwise, so a non-serializable `auth: true` call fails on
23 * argument count at the call site. It rides a separate parameter rather than an
24 * intersection on the options object, which is the sole inference source for
25 * `Args` (via `mutate`) and collapses under intersection; and rather than a
26 * type-parameter bound, which silently goes permissive against inferred
27 * arguments. Scoped and unauthenticated mutations are unconstrained.
28 */
29type SerializableArgsGuard<Args extends unknown[], Auth> = Auth extends true ? AllSerializable<Args> extends true ? []
30 : [error: "auth:true mutation arguments must be serializable"]
31 : [];
32
33export interface MutationClientConfig {
34 context: {};
35 optimisticHelpers: {};
36 userContext: {};
37 authScopes: string;
38}
39
40export type MutationClientFromConfig<Config extends MutationClientConfig> = MutationClient<
41 Config["context"],
42 Config["optimisticHelpers"],
43 Config["userContext"],
44 Config["authScopes"]
45>;
46
47const defaultDeepEquals = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
48
49// Declared locally to avoid depending on node types.
50declare const process: { env: { NODE_ENV?: string } };
51
52export interface MutationClientOptions<
53 Context extends object,
54 OptimisticHelpers extends object,
55 UserContext extends object = {},
56 AuthScopes extends string = never,
57> {
58 context: Context;
59 getOptimisticHelpers: (
60 events: OptimisticEvents,
61 ) => OptimisticHelpers;
62 reportError: (message: string, error: unknown) => void;
63 reportSuccess?: (message: string) => void;
64 /**
65 * Compare two values for deep equality. Used by DebouncedMutation to determine
66 * if the optimistic state has changed from the initial snapshot.
67 * @default JSON.stringify based comparison
68 */
69 deepEquals?: (a: unknown, b: unknown) => boolean;
70 /**
71 * When false, all mutation run functions will throw an error.
72 * Useful for preventing mutations during SSR.
73 * @default true
74 */
75 enabled?: boolean;
76 /** To support authenticated mutations, define a function that returns additional context. */
77 userContext?: Reactive<UserContext | null>;
78 /**
79 * Subsets of authentication for different permission levels, used at the
80 * define site with `auth: "<scope>"`. A scoped mutation is allowed only when
81 * a user is available and its scope reads `true`; unlike `auth: true`, it is
82 * never routed to `handleUnauthenticated`.
83 */
84 authScopes?: Record<AuthScopes, Reactive<boolean>>;
85 /**
86 * Called instead of running an authenticated mutation when no user is
87 * available. The action is JSON serializable and can be stored, then passed
88 * to {@linkcode MutationClient.run} once the user signs in.
89 */
90 handleUnauthenticated?: (
91 this: { context: Context },
92 action: MutationAction,
93 mutation: Mutation<SerializableValue[], unknown>,
94 ) => void;
95}
96
97export interface Reactive<T> {
98 get: () => T;
99 sub: (onChange: () => void) => () => void;
100}
101
102export interface OptimisticEvents {
103 onRestore: (cb: () => void) => void;
104 onRefetch: (cb: () => Promise<void>) => void;
105}
106
107export class MutationClient<
108 Context extends object,
109 OptimisticHelpers extends object,
110 UserContext extends object = {},
111 AuthScopes extends string = never,
112> {
113 context: Context;
114 userContext?: Reactive<UserContext | null>;
115 authScopes?: { [scope: string]: Reactive<boolean> };
116 handleUnauthenticated?: (action: MutationAction, mutation: Mutation<SerializableValue[], unknown>) => void;
117 getOptimisticHelpers: (event: OptimisticEvents) => OptimisticHelpers;
118 reportError: (message: string, error: unknown) => void;
119 reportSuccess?: (message: string) => void;
120 deepEquals: (a: unknown, b: unknown) => boolean;
121 enabled: boolean;
122 #mutations: Map<string, Mutation<SerializableValue[], unknown>> = new Map();
123
124 constructor(options: MutationClientOptions<Context, OptimisticHelpers, UserContext, AuthScopes>) {
125 this.context = options.context;
126 this.userContext = options.userContext;
127 this.authScopes = options.authScopes;
128 this.handleUnauthenticated = options.handleUnauthenticated;
129 this.getOptimisticHelpers = options.getOptimisticHelpers;
130 this.reportError = options.reportError;
131 this.reportSuccess = options.reportSuccess;
132 this.deepEquals = options.deepEquals ?? defaultDeepEquals;
133 this.enabled = options.enabled ?? true;
134 }
135
136 /**
137 * Define a standard mutation.
138 *
139 * Only an `auth: true` mutation attempted without a user builds a serializable
140 * {@link MutationAction} for replay after sign-in, so only its arguments must
141 * be serializable, enforced by {@link SerializableArgsGuard}. That check is a
142 * {@link Serializable} mapped type rather than a concrete value type so
143 * `interface` arguments type-check, and the leaf set stays extensible via
144 * {@link SerializableLeaves}. Scoped (`auth: "<scope>"`) and unauthenticated
145 * mutations never produce an action and leave their arguments unconstrained.
146 */
147 define<
148 const Args extends unknown[],
149 Result,
150 const Auth extends boolean | AuthScopes = false,
151 >(
152 options: MutationOptions<
153 Args,
154 Result,
155 Auth,
156 { context: Context; optimisticHelpers: OptimisticHelpers; userContext: UserContext; authScopes: AuthScopes }
157 >,
158 ..._serializable: SerializableArgsGuard<NoInfer<Args>, NoInfer<Auth>>
159 ): Mutation<Args, Result> {
160 if (typeof options.auth === "string" && !this.authScopes?.[options.auth]) {
161 throw new Error(`Unknown auth scope "${options.auth}".`);
162 }
163 if (this.#mutations.has(options.id)) {
164 // Hot reload re-evaluates defining modules against the same client, so
165 // a duplicate is assumed to be a replacement unless this is positively
166 // a production build. The `typeof` guard supports unbundled browsers;
167 // bundled browser builds inline NODE_ENV but leave `typeof process`
168 // alone, which downgrades them to the replace-and-warn path.
169 if (typeof process !== "undefined" && process.env.NODE_ENV === "production") {
170 throw new Error(`Mutation id "${options.id}" is already registered.`);
171 }
172 console.error(`Mutation id "${options.id}" registered twice; assuming hot reload and replacing it.`);
173 }
174 const mutation = new BlockingMutation<
175 Args,
176 Result,
177 Auth,
178 { context: Context; optimisticHelpers: OptimisticHelpers; userContext: UserContext; authScopes: AuthScopes }
179 >(
180 this,
181 options,
182 );
183 this.#mutations.set(options.id, mutation as unknown as Mutation<SerializableValue[], unknown>);
184 return mutation;
185 }
186
187 /**
188 * Re-run a mutation from a stored {@linkcode MutationAction}, such as one
189 * captured by `handleUnauthenticated` before the user signed in. Results are
190 * reported through the global handlers.
191 */
192 run(action: Pick<MutationAction, "id" | "args">): void {
193 const mutation = this.#mutations.get(action.id);
194 if (!mutation) throw new Error(`Mutation id "${action.id}" is not registered.`);
195 mutation.run(...action.args);
196 }
197}