1import type { MutationClient } from "./client.ts";
2
3/**
4 * Plain JSON value. The default serializable leaf set, still exported for
5 * consumers that want the exact JSON shape.
6 */
7export type Json =
8 | string
9 | number
10 | boolean
11 | null
12 | Json[]
13 | { [key: string]: Json };
14
15/**
16 * Registry of serializable leaf types. Empty by default, which reduces
17 * {@link SerializableValue} to plain JSON. Downstream consumers whose transport
18 * handles a superset of JSON augment this to register those types:
19 *
20 * ```ts
21 * declare module "@clo/react-mutation" {
22 * interface SerializableLeaves { file: File | Blob }
23 * }
24 * ```
25 */
26export interface SerializableLeaves {}
27
28type Leaf = string | number | boolean | null | undefined | SerializableLeaves[keyof SerializableLeaves];
29
30/**
31 * Validates that `T` is serializable: every leaf is a registered {@link Leaf}
32 * and nested objects/arrays recurse. Enforced at `define` for `auth: true`
33 * arguments; unlike a concrete value type it accepts `interface` arguments,
34 * because the object case is a mapped type over `keyof T` and so needs no index
35 * signature the way `{ [k: string]: ... }` would. Any non-serializable member
36 * (a function/method, an unregistered class) collapses to `never`.
37 */
38export type Serializable<T> = [T] extends [Leaf] ? T
39 : T extends (...args: never[]) => unknown ? never
40 : T extends readonly unknown[] ? { [I in keyof T]: Serializable<T[I]> }
41 : T extends object ? { [K in keyof T]: Serializable<T[K]> }
42 : never;
43
44/**
45 * A concrete serializable value, driven by the {@link SerializableLeaves}
46 * registry. Stored in a {@link MutationAction} for replay after sign-in.
47 */
48export type SerializableValue =
49 | Leaf
50 | SerializableValue[]
51 | { [key: string]: SerializableValue };
52
53/**
54 * A serializable record of a mutation call. Produced when an authenticated
55 * mutation is attempted without a user; pass it to `MutationClient.run`
56 * after sign-in to re-run the call.
57 */
58export interface MutationAction {
59 id: string;
60 description: string;
61 args: SerializableValue[];
62}
63
64export interface Mutation<Args extends unknown[], Result> {
65 /** Unique identifier passed to `define`. */
66 readonly id: string;
67 /** Calling the mutation. Errors are turned into UI toasts. */
68 run(...args: Args): void;
69 /** Calls the mutation with custom handlers that can suppress global handlers. */
70 runWithOptions(...args: [...args: Args, options: RunOptions<Result>]): void;
71 /**
72 * Using this in any situation is likely incorrect, even internally.
73 * Use {@linkcode runWithOptions} to handle success and error.
74 *
75 * By using this, you must handle the success and error conditions of the
76 * promise, or else the user will never see the result on screen. If the
77 * mutation has snapshots, be aware that cancelled mutations are implemented
78 * with promises that never resolve.
79 */
80 runAsHeadlessPromise(...array: [...Args, RunOptions<Result>]): Promise<Result>;
81
82 /** Returns the concurrency key used for a given set of arguments */
83 key(args: Args): string;
84 /** Subscribe to status changes using the key from `key()` */
85 subscribe(
86 key: string,
87 cb: (update: MutationEvent<Result>) => void,
88 ): () => void;
89 describe(...args: Args): string;
90 describeResult: ((args: Args, result: Result) => string | undefined) | null;
91 /**
92 * `true` when an `auth: true` mutation has no user available. Scoped
93 * mutations are excluded; they disable instead of routing to the sign-in flow.
94 */
95 isUnauthenticated(): boolean;
96 isAllowed(): boolean;
97 subscribeAllowed(cb: () => void): () => void;
98 client: MutationClient<object, object, object>;
99}
100
101export interface RunOptions<Result> {
102 /** Called to show the success UI. Passing this suppresses the global success handler. */
103 onSuccessUi?: (result: Result) => void;
104 /**
105 * Called with result data, but unlike `onSuccessUi`, this indicates the
106 * caller is not concerned with the UI flow of the success. Passing this
107 * does NOT suppress the global success handler.
108 */
109 onSuccessData?: (result: Result) => void;
110 /** Called on error, suppresses the global error handler */
111 onError?: (error: unknown) => void;
112 /** Called on settled (doesn't suppress global handlers) */
113 onSettled?: (
114 status:
115 | { status: "success"; result: Result }
116 | { status: "error"; error: unknown },
117 ) => void;
118 /** Called when optimistic state is being restored/rolled back */
119 onRestore?: () => void;
120 /**
121 * Called instead of running an authenticated mutation when no user is
122 * available. The action is JSON serializable and can be passed to
123 * `MutationClient.run` after sign-in.
124 */
125 onUnauthenticated?: (action: MutationAction, mutation: Mutation<SerializableValue[], unknown>) => void;
126}
127
128export interface MutationEvent<Result> {
129 status: "idle" | "waiting" | "mutating" | "refetching" | "skipped";
130 result: Result | null;
131 error: unknown;
132 debounced: boolean;
133}