@zeno-lib/db 0.4.0 → 0.6.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/dist/next.d.mts CHANGED
@@ -1,6 +1,70 @@
1
1
  import { n as DrizzleClient, t as CreateClientConfig } from "./clients-BlUEH7QJ.mjs";
2
2
  import { AnyRelations, EmptyRelations } from "drizzle-orm";
3
3
  import { JwtPayload, SupabaseClient } from "@supabase/supabase-js";
4
+ //#region src/action-result.d.ts
5
+ /**
6
+ * The serializable validation failure a form action returns instead of
7
+ * throwing. Next.js redacts a thrown error's message in production, so a
8
+ * `ZodError` thrown by a server action reaches the browser as an opaque
9
+ * failure; a returned value survives the round trip intact.
10
+ *
11
+ * `fieldErrors` is keyed by TanStack Form field name (`address.city`,
12
+ * `owners[0].percentage`); `formErrors` holds the issues with no path.
13
+ * `@zeno-lib/forms` declares the same shape on its side and matches it
14
+ * structurally, so neither package depends on the other.
15
+ */
16
+ interface ActionError {
17
+ readonly fieldErrors: Record<string, string[]>;
18
+ readonly formErrors: string[];
19
+ }
20
+ /** What a form action resolves to: the handler's result, or the failure. */
21
+ type ActionResult<TData> = {
22
+ readonly ok: true;
23
+ readonly data: TData;
24
+ } | {
25
+ readonly ok: false;
26
+ readonly error: ActionError;
27
+ };
28
+ /** A Standard Schema issue, reduced to what the mapping reads. */
29
+ interface ActionIssue {
30
+ readonly message: string;
31
+ readonly path?: ReadonlyArray<PropertyKey | {
32
+ readonly key: PropertyKey;
33
+ }> | undefined;
34
+ }
35
+ type FieldMessages = string | readonly string[];
36
+ /**
37
+ * Thrown from a `defineFormAction` handler to reject the input for a reason
38
+ * only the server can see (a uniqueness conflict, a stale reference). The
39
+ * action catches it and returns `{ ok: false, error }` in the same shape a
40
+ * schema failure produces. Keys are TanStack Form field names.
41
+ *
42
+ * Thrown inside `db.transaction(...)`, it rolls the transaction back like any
43
+ * other error before the action converts it. From a plain `defineAction`
44
+ * handler it is an ordinary error and stays thrown.
45
+ */
46
+ declare class FieldValidationError extends Error {
47
+ readonly fieldErrors: Record<string, string[]>;
48
+ readonly formErrors: string[];
49
+ constructor(fieldErrors: Readonly<Record<string, FieldMessages>>, options?: {
50
+ formErrors?: FieldMessages;
51
+ message?: string;
52
+ });
53
+ /** The error as the returned failure shape. */
54
+ toActionError(): ActionError;
55
+ }
56
+ /**
57
+ * Formats an issue path the way TanStack Form names fields: object keys joined
58
+ * with `.`, array indices in brackets (`["owners", 0, "percentage"]` becomes
59
+ * `owners[0].percentage`). An empty path yields `""`.
60
+ */
61
+ declare function toFieldName(path: ActionIssue["path"]): string;
62
+ /**
63
+ * Groups Standard Schema issues into an `ActionError`: each issue lands under
64
+ * its field name, in order, and an issue without a path goes to `formErrors`.
65
+ */
66
+ declare function toActionError(issues: readonly ActionIssue[]): ActionError;
67
+ //#endregion
4
68
  //#region src/define-action.d.ts
5
69
  /**
6
70
  * The slice of a schema `defineAction` needs: a throwing `parse`, plus the
@@ -23,12 +87,46 @@ interface ActionContext<TDb> {
23
87
  }
24
88
  /** An action's body: the RLS-bound `db`, the parsed input, and the context. */
25
89
  type ActionHandler<TContext extends ActionContext<unknown>, TOutput, TResult> = (db: TContext["db"], input: TOutput, context: TContext) => TResult | Promise<TResult>;
90
+ /**
91
+ * What an action resolves to: the handler's result, with `undefined` (and
92
+ * `void`) replaced by `null`.
93
+ */
94
+ type ActionValue<TResult> = undefined extends TResult ? Exclude<Exclude<TResult, undefined>, void> | null : TResult;
26
95
  /**
27
96
  * `defineAction(schema, handler)` returns the server action itself: an async
28
97
  * function that parses its argument, resolves the request context, then calls
29
- * `handler(db, input, context)`.
98
+ * `handler(db, input, context)`. A handler that resolves to `undefined`, such
99
+ * as a Drizzle `findFirst` that matched no row, makes the action resolve to
100
+ * `null`, because TanStack Query rejects a query whose function resolves to
101
+ * `undefined`.
102
+ */
103
+ type DefineAction<TContext extends ActionContext<unknown>> = <TInput, TOutput, TResult>(schema: ActionSchema<TInput, TOutput>, handler: ActionHandler<TContext, TOutput, TResult>) => (input: TInput) => Promise<ActionValue<TResult>>;
104
+ /**
105
+ * The schema `defineFormAction` needs: the Standard Schema `validate`, which
106
+ * reports failures as issues with paths instead of throwing. Zod 4 schemas
107
+ * satisfy it as they are.
30
108
  */
31
- type DefineAction<TContext extends ActionContext<unknown>> = <TInput, TOutput, TResult>(schema: ActionSchema<TInput, TOutput>, handler: ActionHandler<TContext, TOutput, TResult>) => (input: TInput) => Promise<TResult>;
109
+ interface FormActionSchema<TInput, TOutput> {
110
+ readonly "~standard": {
111
+ readonly types?: {
112
+ readonly input: TInput;
113
+ readonly output: TOutput;
114
+ } | undefined;
115
+ readonly validate: (value: unknown) => FormActionSchemaResult<TOutput> | Promise<FormActionSchemaResult<TOutput>>;
116
+ };
117
+ }
118
+ type FormActionSchemaResult<TOutput> = {
119
+ readonly value: TOutput;
120
+ readonly issues?: undefined;
121
+ } | {
122
+ readonly issues: readonly ActionIssue[];
123
+ };
124
+ /**
125
+ * `defineFormAction(schema, handler)` is `defineAction` for a form: the action
126
+ * resolves to an `ActionResult` instead of throwing on invalid input, so field
127
+ * errors survive Next.js's production redaction of thrown messages.
128
+ */
129
+ type DefineFormAction<TContext extends ActionContext<unknown>> = <TInput, TOutput, TResult>(schema: FormActionSchema<TInput, TOutput>, handler: ActionHandler<TContext, TOutput, TResult>) => (input: TInput) => Promise<ActionResult<Awaited<TResult>>>;
32
130
  //#endregion
33
131
  //#region src/next.d.ts
34
132
  /**
@@ -67,6 +165,11 @@ type RequestDb<TRelations extends AnyRelations = EmptyRelations> = {
67
165
  getRequestDb: () => Promise<DrizzleClient<TRelations>>;
68
166
  /** Wraps a handler into a `"use server"` export; see `DefineAction`. */
69
167
  defineAction: DefineAction<RequestContext<TRelations>>;
168
+ /**
169
+ * `defineAction` for forms: resolves to an `ActionResult` instead of
170
+ * throwing on invalid input; see `DefineFormAction`.
171
+ */
172
+ defineFormAction: DefineFormAction<RequestContext<TRelations>>;
70
173
  };
71
174
  /**
72
175
  * Request-scoped, RLS-bound Drizzle access for Next.js. Verifies the session
@@ -84,4 +187,4 @@ type RequestDb<TRelations extends AnyRelations = EmptyRelations> = {
84
187
  */
85
188
  declare function createRequestDb<TRelations extends AnyRelations = EmptyRelations>(options: CreateRequestDbOptions<TRelations>): RequestDb<TRelations>;
86
189
  //#endregion
87
- export { type ActionContext, type ActionHandler, type ActionSchema, ClaimsSource, CreateRequestDbOptions, type DefineAction, RequestContext, RequestDb, UnauthenticatedError, createRequestDb };
190
+ export { type ActionContext, type ActionError, type ActionHandler, type ActionIssue, type ActionResult, type ActionSchema, type ActionValue, ClaimsSource, CreateRequestDbOptions, type DefineAction, type DefineFormAction, FieldValidationError, type FormActionSchema, RequestContext, RequestDb, UnauthenticatedError, createRequestDb, toActionError, toFieldName };
package/dist/next.mjs CHANGED
@@ -1,5 +1,73 @@
1
1
  import { a as createSupabaseClient } from "./clients-CdPr3mCZ.mjs";
2
2
  import { cache } from "react";
3
+ //#region src/action-result.ts
4
+ function toMessages(messages) {
5
+ return typeof messages === "string" ? [messages] : [...messages];
6
+ }
7
+ /**
8
+ * Thrown from a `defineFormAction` handler to reject the input for a reason
9
+ * only the server can see (a uniqueness conflict, a stale reference). The
10
+ * action catches it and returns `{ ok: false, error }` in the same shape a
11
+ * schema failure produces. Keys are TanStack Form field names.
12
+ *
13
+ * Thrown inside `db.transaction(...)`, it rolls the transaction back like any
14
+ * other error before the action converts it. From a plain `defineAction`
15
+ * handler it is an ordinary error and stays thrown.
16
+ */
17
+ var FieldValidationError = class extends Error {
18
+ constructor(fieldErrors, options = {}) {
19
+ super(options.message ?? "Validation failed");
20
+ this.name = "FieldValidationError";
21
+ this.fieldErrors = Object.fromEntries(Object.entries(fieldErrors).map(([name, messages]) => [name, toMessages(messages)]));
22
+ this.formErrors = options.formErrors === void 0 ? [] : toMessages(options.formErrors);
23
+ }
24
+ /** The error as the returned failure shape. */
25
+ toActionError() {
26
+ return {
27
+ fieldErrors: this.fieldErrors,
28
+ formErrors: this.formErrors
29
+ };
30
+ }
31
+ };
32
+ /**
33
+ * Formats an issue path the way TanStack Form names fields: object keys joined
34
+ * with `.`, array indices in brackets (`["owners", 0, "percentage"]` becomes
35
+ * `owners[0].percentage`). An empty path yields `""`.
36
+ */
37
+ function toFieldName(path) {
38
+ let name = "";
39
+ for (const segment of path ?? []) {
40
+ const key = typeof segment === "object" ? segment.key : segment;
41
+ if (typeof key === "number") name += `[${key}]`;
42
+ else {
43
+ const part = typeof key === "symbol" ? key.description ?? "" : key;
44
+ name += name === "" ? part : `.${part}`;
45
+ }
46
+ }
47
+ return name;
48
+ }
49
+ /**
50
+ * Groups Standard Schema issues into an `ActionError`: each issue lands under
51
+ * its field name, in order, and an issue without a path goes to `formErrors`.
52
+ */
53
+ function toActionError(issues) {
54
+ const fieldErrors = {};
55
+ const formErrors = [];
56
+ for (const issue of issues) {
57
+ const name = toFieldName(issue.path);
58
+ if (name === "") formErrors.push(issue.message);
59
+ else {
60
+ const messages = fieldErrors[name] ?? [];
61
+ messages.push(issue.message);
62
+ fieldErrors[name] = messages;
63
+ }
64
+ }
65
+ return {
66
+ fieldErrors,
67
+ formErrors
68
+ };
69
+ }
70
+ //#endregion
3
71
  //#region src/define-action.ts
4
72
  /**
5
73
  * Binds `defineAction` to a request-context resolver. Deliberately not a
@@ -13,7 +81,36 @@ function createDefineAction(getContext) {
13
81
  return (schema, handler) => async (input) => {
14
82
  const parsed = schema.parse(input);
15
83
  const context = await getContext();
16
- return await handler(context.db, parsed, context);
84
+ return await handler(context.db, parsed, context) ?? null;
85
+ };
86
+ }
87
+ /**
88
+ * Binds `defineFormAction` to a request-context resolver. Same order as
89
+ * `createDefineAction` (validate, then resolve the context, then run the
90
+ * handler), but two failures come back as `{ ok: false, error }`: schema
91
+ * issues, and a `FieldValidationError` thrown by the handler. Everything else
92
+ * (an `UnauthenticatedError`, a database error) still throws.
93
+ */
94
+ function createDefineFormAction(getContext) {
95
+ return (schema, handler) => async (input) => {
96
+ const parsed = await schema["~standard"].validate(input);
97
+ if (parsed.issues) return {
98
+ error: toActionError(parsed.issues),
99
+ ok: false
100
+ };
101
+ const context = await getContext();
102
+ try {
103
+ return {
104
+ data: await handler(context.db, parsed.value, context),
105
+ ok: true
106
+ };
107
+ } catch (error) {
108
+ if (error instanceof FieldValidationError) return {
109
+ error: error.toActionError(),
110
+ ok: false
111
+ };
112
+ throw error;
113
+ }
17
114
  };
18
115
  }
19
116
  //#endregion
@@ -61,9 +158,10 @@ function createRequestDb(options) {
61
158
  });
62
159
  return {
63
160
  defineAction: createDefineAction(getRequestContext),
161
+ defineFormAction: createDefineFormAction(getRequestContext),
64
162
  getRequestContext,
65
163
  getRequestDb: async () => (await getRequestContext()).db
66
164
  };
67
165
  }
68
166
  //#endregion
69
- export { UnauthenticatedError, createRequestDb };
167
+ export { FieldValidationError, UnauthenticatedError, createRequestDb, toActionError, toFieldName };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeno-lib/db",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -57,7 +57,7 @@
57
57
  },
58
58
  "peerDependencies": {
59
59
  "@supabase/supabase-js": ">=2",
60
- "drizzle-kit": "1.0.0-rc.3",
60
+ "drizzle-kit": "1.0.0-rc.5-5935859",
61
61
  "drizzle-orm": "1.0.0-rc.3",
62
62
  "postgres": ">=3.4",
63
63
  "react": ">=19"
@@ -71,8 +71,8 @@
71
71
  "@supabase/supabase-js": "2.116.0",
72
72
  "@types/node": "24.10.0",
73
73
  "@types/react": "19.2.14",
74
- "dotenv": "17.4.2",
75
- "drizzle-kit": "1.0.0-rc.3",
74
+ "dotenv": "18.0.4",
75
+ "drizzle-kit": "1.0.0-rc.5-5935859",
76
76
  "drizzle-orm": "1.0.0-rc.3",
77
77
  "postgres": "3.4.7",
78
78
  "react": "^19.2.5",
@@ -80,8 +80,9 @@
80
80
  "tsdown": "^0.22.14",
81
81
  "typescript": "7.0.2",
82
82
  "vite": "8.0.16",
83
- "vitest": "4.1.11",
83
+ "vitest": "5.0.2",
84
84
  "zod": "^4.4.3",
85
+ "@zeno-lib/schema": "0.2.2",
85
86
  "@zeno-lib/test": "0.1.0",
86
87
  "@zeno-lib/typescript": "^1.1.0"
87
88
  },
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { z } from "zod"
3
+ import {
4
+ FieldValidationError,
5
+ toActionError,
6
+ toFieldName,
7
+ } from "./action-result.ts"
8
+
9
+ describe("toFieldName", () => {
10
+ it("joins keys with dots and brackets array indices", () => {
11
+ expect(toFieldName(["owners", 0, "percentage"])).toBe(
12
+ "owners[0].percentage"
13
+ )
14
+ expect(toFieldName(["address", "city"])).toBe("address.city")
15
+ expect(toFieldName(["matrix", 1, 2])).toBe("matrix[1][2]")
16
+ expect(toFieldName([0, "name"])).toBe("[0].name")
17
+ })
18
+
19
+ it("reads Standard Schema path segment objects", () => {
20
+ expect(toFieldName([{ key: "owners" }, { key: 3 }])).toBe("owners[3]")
21
+ })
22
+
23
+ it("returns an empty name for a missing or empty path", () => {
24
+ expect(toFieldName(undefined)).toBe("")
25
+ expect(toFieldName([])).toBe("")
26
+ })
27
+ })
28
+
29
+ describe("toActionError", () => {
30
+ it("groups a Zod failure by field name and keeps path-less issues form-level", () => {
31
+ const schema = z
32
+ .object({
33
+ address: z.object({ city: z.string().min(1, "City required") }),
34
+ owners: z.array(
35
+ z.object({ percentage: z.number().max(100, "At most 100") })
36
+ ),
37
+ })
38
+ .refine(() => false, "Rejected as a whole")
39
+ const result = schema.safeParse({
40
+ address: { city: "" },
41
+ owners: [{ percentage: 10 }, { percentage: 120 }],
42
+ })
43
+ if (result.success) {
44
+ throw new Error("expected a failure")
45
+ }
46
+
47
+ expect(toActionError(result.error.issues)).toEqual({
48
+ fieldErrors: {
49
+ "address.city": ["City required"],
50
+ "owners[1].percentage": ["At most 100"],
51
+ },
52
+ formErrors: ["Rejected as a whole"],
53
+ })
54
+ })
55
+
56
+ it("collects several messages for one field in order", () => {
57
+ expect(
58
+ toActionError([
59
+ { message: "Too short", path: ["password"] },
60
+ { message: "Needs a digit", path: ["password"] },
61
+ { message: "Try again later" },
62
+ ])
63
+ ).toEqual({
64
+ fieldErrors: { password: ["Too short", "Needs a digit"] },
65
+ formErrors: ["Try again later"],
66
+ })
67
+ })
68
+ })
69
+
70
+ describe("FieldValidationError", () => {
71
+ it("normalises single messages to arrays", () => {
72
+ const error = new FieldValidationError(
73
+ { email: "Taken", "owners[0].name": ["A", "B"] },
74
+ { formErrors: "Locked" }
75
+ )
76
+
77
+ expect(error).toBeInstanceOf(Error)
78
+ expect(error.name).toBe("FieldValidationError")
79
+ expect(error.toActionError()).toEqual({
80
+ fieldErrors: { email: ["Taken"], "owners[0].name": ["A", "B"] },
81
+ formErrors: ["Locked"],
82
+ })
83
+ })
84
+
85
+ it("defaults to no form errors", () => {
86
+ expect(new FieldValidationError({ email: "Taken" }).formErrors).toEqual([])
87
+ })
88
+ })
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The serializable validation failure a form action returns instead of
3
+ * throwing. Next.js redacts a thrown error's message in production, so a
4
+ * `ZodError` thrown by a server action reaches the browser as an opaque
5
+ * failure; a returned value survives the round trip intact.
6
+ *
7
+ * `fieldErrors` is keyed by TanStack Form field name (`address.city`,
8
+ * `owners[0].percentage`); `formErrors` holds the issues with no path.
9
+ * `@zeno-lib/forms` declares the same shape on its side and matches it
10
+ * structurally, so neither package depends on the other.
11
+ */
12
+ export interface ActionError {
13
+ readonly fieldErrors: Record<string, string[]>
14
+ readonly formErrors: string[]
15
+ }
16
+
17
+ /** What a form action resolves to: the handler's result, or the failure. */
18
+ export type ActionResult<TData> =
19
+ | { readonly ok: true; readonly data: TData }
20
+ | { readonly ok: false; readonly error: ActionError }
21
+
22
+ /** A Standard Schema issue, reduced to what the mapping reads. */
23
+ export interface ActionIssue {
24
+ readonly message: string
25
+ readonly path?:
26
+ | ReadonlyArray<PropertyKey | { readonly key: PropertyKey }>
27
+ | undefined
28
+ }
29
+
30
+ type FieldMessages = string | readonly string[]
31
+
32
+ function toMessages(messages: FieldMessages): string[] {
33
+ return typeof messages === "string" ? [messages] : [...messages]
34
+ }
35
+
36
+ /**
37
+ * Thrown from a `defineFormAction` handler to reject the input for a reason
38
+ * only the server can see (a uniqueness conflict, a stale reference). The
39
+ * action catches it and returns `{ ok: false, error }` in the same shape a
40
+ * schema failure produces. Keys are TanStack Form field names.
41
+ *
42
+ * Thrown inside `db.transaction(...)`, it rolls the transaction back like any
43
+ * other error before the action converts it. From a plain `defineAction`
44
+ * handler it is an ordinary error and stays thrown.
45
+ */
46
+ export class FieldValidationError extends Error {
47
+ readonly fieldErrors: Record<string, string[]>
48
+ readonly formErrors: string[]
49
+
50
+ constructor(
51
+ fieldErrors: Readonly<Record<string, FieldMessages>>,
52
+ options: { formErrors?: FieldMessages; message?: string } = {}
53
+ ) {
54
+ super(options.message ?? "Validation failed")
55
+ this.name = "FieldValidationError"
56
+ this.fieldErrors = Object.fromEntries(
57
+ Object.entries(fieldErrors).map(([name, messages]) => [
58
+ name,
59
+ toMessages(messages),
60
+ ])
61
+ )
62
+ this.formErrors =
63
+ options.formErrors === undefined ? [] : toMessages(options.formErrors)
64
+ }
65
+
66
+ /** The error as the returned failure shape. */
67
+ toActionError(): ActionError {
68
+ return { fieldErrors: this.fieldErrors, formErrors: this.formErrors }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Formats an issue path the way TanStack Form names fields: object keys joined
74
+ * with `.`, array indices in brackets (`["owners", 0, "percentage"]` becomes
75
+ * `owners[0].percentage`). An empty path yields `""`.
76
+ */
77
+ export function toFieldName(path: ActionIssue["path"]): string {
78
+ let name = ""
79
+ for (const segment of path ?? []) {
80
+ const key = typeof segment === "object" ? segment.key : segment
81
+ if (typeof key === "number") {
82
+ name += `[${key}]`
83
+ } else {
84
+ const part = typeof key === "symbol" ? (key.description ?? "") : key
85
+ name += name === "" ? part : `.${part}`
86
+ }
87
+ }
88
+ return name
89
+ }
90
+
91
+ /**
92
+ * Groups Standard Schema issues into an `ActionError`: each issue lands under
93
+ * its field name, in order, and an issue without a path goes to `formErrors`.
94
+ */
95
+ export function toActionError(issues: readonly ActionIssue[]): ActionError {
96
+ const fieldErrors: Record<string, string[]> = {}
97
+ const formErrors: string[] = []
98
+ for (const issue of issues) {
99
+ const name = toFieldName(issue.path)
100
+ if (name === "") {
101
+ formErrors.push(issue.message)
102
+ } else {
103
+ const messages = fieldErrors[name] ?? []
104
+ messages.push(issue.message)
105
+ fieldErrors[name] = messages
106
+ }
107
+ }
108
+ return { fieldErrors, formErrors }
109
+ }
@@ -1,3 +1,10 @@
1
+ import {
2
+ type ActionIssue,
3
+ type ActionResult,
4
+ FieldValidationError,
5
+ toActionError,
6
+ } from "./action-result.ts"
7
+
1
8
  /**
2
9
  * The slice of a schema `defineAction` needs: a throwing `parse`, plus the
3
10
  * Standard Schema `types` marker it reads the caller-facing input type from.
@@ -29,10 +36,21 @@ export type ActionHandler<
29
36
  context: TContext
30
37
  ) => TResult | Promise<TResult>
31
38
 
39
+ /**
40
+ * What an action resolves to: the handler's result, with `undefined` (and
41
+ * `void`) replaced by `null`.
42
+ */
43
+ export type ActionValue<TResult> = undefined extends TResult
44
+ ? Exclude<Exclude<TResult, undefined>, void> | null
45
+ : TResult
46
+
32
47
  /**
33
48
  * `defineAction(schema, handler)` returns the server action itself: an async
34
49
  * function that parses its argument, resolves the request context, then calls
35
- * `handler(db, input, context)`.
50
+ * `handler(db, input, context)`. A handler that resolves to `undefined`, such
51
+ * as a Drizzle `findFirst` that matched no row, makes the action resolve to
52
+ * `null`, because TanStack Query rejects a query whose function resolves to
53
+ * `undefined`.
36
54
  */
37
55
  export type DefineAction<TContext extends ActionContext<unknown>> = <
38
56
  TInput,
@@ -41,7 +59,7 @@ export type DefineAction<TContext extends ActionContext<unknown>> = <
41
59
  >(
42
60
  schema: ActionSchema<TInput, TOutput>,
43
61
  handler: ActionHandler<TContext, TOutput, TResult>
44
- ) => (input: TInput) => Promise<TResult>
62
+ ) => (input: TInput) => Promise<ActionValue<TResult>>
45
63
 
46
64
  /**
47
65
  * Binds `defineAction` to a request-context resolver. Deliberately not a
@@ -54,10 +72,82 @@ export type DefineAction<TContext extends ActionContext<unknown>> = <
54
72
  export function createDefineAction<TContext extends ActionContext<unknown>>(
55
73
  getContext: () => Promise<TContext>
56
74
  ): DefineAction<TContext> {
75
+ return <TInput, TOutput, TResult>(
76
+ schema: ActionSchema<TInput, TOutput>,
77
+ handler: ActionHandler<TContext, TOutput, TResult>
78
+ ) =>
79
+ async (input: TInput) => {
80
+ const parsed = schema.parse(input)
81
+ const context = await getContext()
82
+ const result = await handler(context.db, parsed, context)
83
+
84
+ return (result ?? null) as ActionValue<TResult>
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The schema `defineFormAction` needs: the Standard Schema `validate`, which
90
+ * reports failures as issues with paths instead of throwing. Zod 4 schemas
91
+ * satisfy it as they are.
92
+ */
93
+ export interface FormActionSchema<TInput, TOutput> {
94
+ readonly "~standard": {
95
+ readonly types?:
96
+ | { readonly input: TInput; readonly output: TOutput }
97
+ | undefined
98
+ readonly validate: (
99
+ value: unknown
100
+ ) =>
101
+ | FormActionSchemaResult<TOutput>
102
+ | Promise<FormActionSchemaResult<TOutput>>
103
+ }
104
+ }
105
+
106
+ type FormActionSchemaResult<TOutput> =
107
+ | { readonly value: TOutput; readonly issues?: undefined }
108
+ | { readonly issues: readonly ActionIssue[] }
109
+
110
+ /**
111
+ * `defineFormAction(schema, handler)` is `defineAction` for a form: the action
112
+ * resolves to an `ActionResult` instead of throwing on invalid input, so field
113
+ * errors survive Next.js's production redaction of thrown messages.
114
+ */
115
+ export type DefineFormAction<TContext extends ActionContext<unknown>> = <
116
+ TInput,
117
+ TOutput,
118
+ TResult,
119
+ >(
120
+ schema: FormActionSchema<TInput, TOutput>,
121
+ handler: ActionHandler<TContext, TOutput, TResult>
122
+ ) => (input: TInput) => Promise<ActionResult<Awaited<TResult>>>
123
+
124
+ /**
125
+ * Binds `defineFormAction` to a request-context resolver. Same order as
126
+ * `createDefineAction` (validate, then resolve the context, then run the
127
+ * handler), but two failures come back as `{ ok: false, error }`: schema
128
+ * issues, and a `FieldValidationError` thrown by the handler. Everything else
129
+ * (an `UnauthenticatedError`, a database error) still throws.
130
+ */
131
+ export function createDefineFormAction<TContext extends ActionContext<unknown>>(
132
+ getContext: () => Promise<TContext>
133
+ ): DefineFormAction<TContext> {
57
134
  return (schema, handler) => async (input) => {
58
- const parsed = schema.parse(input)
135
+ const parsed = await schema["~standard"].validate(input)
136
+ if (parsed.issues) {
137
+ return { error: toActionError(parsed.issues), ok: false }
138
+ }
59
139
  const context = await getContext()
60
140
 
61
- return await handler(context.db, parsed, context)
141
+ try {
142
+ return {
143
+ data: await handler(context.db, parsed.value, context),
144
+ ok: true,
145
+ }
146
+ } catch (error) {
147
+ if (error instanceof FieldValidationError) {
148
+ return { error: error.toActionError(), ok: false }
149
+ }
150
+ throw error
151
+ }
62
152
  }
63
153
  }
@@ -2,9 +2,9 @@ import type { JwtPayload } from "@supabase/supabase-js"
2
2
  import { expectTypeOf, test } from "vitest"
3
3
  import { z } from "zod"
4
4
  import type { DrizzleClient } from "./clients.ts"
5
- import { createRequestDb } from "./next.ts"
5
+ import { type ActionError, type ActionResult, createRequestDb } from "./next.ts"
6
6
 
7
- const { defineAction } = createRequestDb({
7
+ const { defineAction, defineFormAction } = createRequestDb({
8
8
  supabase: () => ({
9
9
  auth: {
10
10
  getClaims: () => Promise.resolve({ data: null, error: null }),
@@ -30,3 +30,47 @@ test("a synchronous handler still yields an async action", () => {
30
30
 
31
31
  expectTypeOf(action).returns.toEqualTypeOf<Promise<boolean>>()
32
32
  })
33
+
34
+ test("a result that may be undefined resolves to null instead", () => {
35
+ const action = defineAction(z.number(), (_db, input) =>
36
+ Promise.resolve(input > 0 ? { id: input } : undefined)
37
+ )
38
+
39
+ expectTypeOf(action).returns.toEqualTypeOf<Promise<{ id: number } | null>>()
40
+ })
41
+
42
+ test("a handler with no result resolves to null", () => {
43
+ const action = defineAction(z.number(), async () => {
44
+ await Promise.resolve()
45
+ })
46
+
47
+ expectTypeOf(action).returns.toEqualTypeOf<Promise<null>>()
48
+ })
49
+
50
+ test("defineFormAction takes the schema's input and resolves to an ActionResult", () => {
51
+ const schema = z.object({ id: z.string().transform(Number) })
52
+ const action = defineFormAction(schema, (db, input) => {
53
+ expectTypeOf(db).toEqualTypeOf<DrizzleClient>()
54
+ expectTypeOf(input).toEqualTypeOf<{ id: number }>()
55
+ return Promise.resolve({ saved: input.id })
56
+ })
57
+
58
+ expectTypeOf(action).parameter(0).toEqualTypeOf<{ id: string }>()
59
+ expectTypeOf(action).returns.toEqualTypeOf<
60
+ Promise<ActionResult<{ saved: number }>>
61
+ >()
62
+ })
63
+
64
+ test("an ActionResult narrows on ok", async () => {
65
+ const action = defineFormAction(z.number(), (_db, input) => input > 0)
66
+ const result = await action(1)
67
+
68
+ if (result.ok) {
69
+ expectTypeOf(result.data).toEqualTypeOf<boolean>()
70
+ } else {
71
+ expectTypeOf(result.error).toEqualTypeOf<ActionError>()
72
+ expectTypeOf(result.error.fieldErrors).toEqualTypeOf<
73
+ Record<string, string[]>
74
+ >()
75
+ }
76
+ })
package/src/next.test.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { AuthError, type JwtPayload } from "@supabase/supabase-js"
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest"
3
3
  import { z } from "zod"
4
- import { createDefineAction } from "./define-action.ts"
4
+ import { FieldValidationError } from "./action-result.ts"
5
+ import { createDefineAction, createDefineFormAction } from "./define-action.ts"
5
6
 
6
7
  // No database: the RLS client factory is replaced by a stub that returns a
7
8
  // marker object, so nothing here builds a pool or opens a connection. What
@@ -78,6 +79,23 @@ describe("defineAction", () => {
78
79
  expect(handler).toHaveBeenCalledWith(context.db, { id: 7 }, context)
79
80
  })
80
81
 
82
+ it("resolves to null when the handler resolves to undefined", async () => {
83
+ const action = createDefineAction(() => Promise.resolve({ db: {} }))(
84
+ schema,
85
+ () => Promise.resolve(undefined)
86
+ )
87
+
88
+ await expect(action({ id: 1 })).resolves.toBeNull()
89
+ })
90
+
91
+ it("keeps a falsy result other than undefined", async () => {
92
+ const define = createDefineAction(() => Promise.resolve({ db: {} }))
93
+
94
+ await expect(define(schema, () => 0)({ id: 1 })).resolves.toBe(0)
95
+ await expect(define(schema, () => false)({ id: 1 })).resolves.toBe(false)
96
+ await expect(define(schema, () => "")({ id: 1 })).resolves.toBe("")
97
+ })
98
+
81
99
  it("hands the handler the claims for authorship", async () => {
82
100
  const context = { claims: { sub: "user-1" }, db: {} }
83
101
  const action = createDefineAction(() => Promise.resolve(context))(
@@ -112,6 +130,108 @@ describe("defineAction", () => {
112
130
  })
113
131
  })
114
132
 
133
+ describe("defineFormAction", () => {
134
+ const schema = z.object({
135
+ owners: z.array(
136
+ z.object({ percentage: z.number().max(100, "At most 100") })
137
+ ),
138
+ })
139
+ const context = { claims: { sub: "user-1" }, db: { name: "db" } }
140
+
141
+ it("resolves to { ok: true, data } with the handler's result", async () => {
142
+ const handler = vi.fn(
143
+ (_db: { name: string }, input: z.output<typeof schema>) =>
144
+ Promise.resolve(input.owners.length)
145
+ )
146
+ const action = createDefineFormAction(() => Promise.resolve(context))(
147
+ schema,
148
+ handler
149
+ )
150
+
151
+ await expect(action({ owners: [{ percentage: 50 }] })).resolves.toEqual({
152
+ data: 1,
153
+ ok: true,
154
+ })
155
+ expect(handler).toHaveBeenCalledWith(
156
+ context.db,
157
+ { owners: [{ percentage: 50 }] },
158
+ context
159
+ )
160
+ })
161
+
162
+ it("returns schema issues keyed by field name, before resolving the context", async () => {
163
+ const getContext = vi.fn(() => Promise.resolve(context))
164
+ const handler = vi.fn()
165
+ const action = createDefineFormAction(getContext)(schema, handler)
166
+
167
+ await expect(
168
+ action({ owners: [{ percentage: 10 }, { percentage: 150 }] })
169
+ ).resolves.toEqual({
170
+ error: {
171
+ fieldErrors: { "owners[1].percentage": ["At most 100"] },
172
+ formErrors: [],
173
+ },
174
+ ok: false,
175
+ })
176
+ expect(getContext).not.toHaveBeenCalled()
177
+ expect(handler).not.toHaveBeenCalled()
178
+ })
179
+
180
+ it("returns a FieldValidationError thrown by the handler in the same shape", async () => {
181
+ const action = createDefineFormAction(() => Promise.resolve(context))(
182
+ schema,
183
+ () => {
184
+ throw new FieldValidationError(
185
+ { "owners[0].percentage": "Already allocated" },
186
+ { formErrors: "Record is locked" }
187
+ )
188
+ }
189
+ )
190
+
191
+ await expect(action({ owners: [{ percentage: 1 }] })).resolves.toEqual({
192
+ error: {
193
+ fieldErrors: { "owners[0].percentage": ["Already allocated"] },
194
+ formErrors: ["Record is locked"],
195
+ },
196
+ ok: false,
197
+ })
198
+ })
199
+
200
+ it("keeps a failure that is not a validation error throwing", async () => {
201
+ const boom = new Error("connection reset")
202
+ const action = createDefineFormAction(() => Promise.resolve(context))(
203
+ schema,
204
+ () => Promise.reject(boom)
205
+ )
206
+
207
+ await expect(action({ owners: [] })).rejects.toBe(boom)
208
+ })
209
+
210
+ it("keeps a context failure throwing", async () => {
211
+ const handler = vi.fn()
212
+ const action = createDefineFormAction(() =>
213
+ Promise.reject(new UnauthenticatedError())
214
+ )(schema, handler)
215
+
216
+ await expect(action({ owners: [] })).rejects.toBeInstanceOf(
217
+ UnauthenticatedError
218
+ )
219
+ expect(handler).not.toHaveBeenCalled()
220
+ })
221
+
222
+ it("returns a result that survives a JSON round trip unchanged", async () => {
223
+ const action = createDefineFormAction(() => Promise.resolve(context))(
224
+ schema,
225
+ () => ({ id: 1 })
226
+ )
227
+ const failure = await action({ owners: [{ percentage: 101 }] })
228
+ const success = await action({ owners: [] })
229
+
230
+ expect(JSON.parse(JSON.stringify(failure))).toEqual(failure)
231
+ expect(JSON.parse(JSON.stringify(success))).toEqual(success)
232
+ })
233
+ })
234
+
115
235
  describe("createRequestDb", () => {
116
236
  it("builds the RLS client from the whole verified claims object", async () => {
117
237
  const { getRequestContext, getRequestDb } = createRequestDb({
@@ -175,4 +295,19 @@ describe("createRequestDb", () => {
175
295
  sub: claims.sub,
176
296
  })
177
297
  })
298
+
299
+ it("binds defineFormAction to the request context", async () => {
300
+ const { defineFormAction } = createRequestDb({
301
+ supabase: supabaseReturning(getClaimsResult(claims, null)),
302
+ })
303
+ const action = defineFormAction(z.string(), (_db, input, context) => ({
304
+ input,
305
+ sub: context.claims.sub,
306
+ }))
307
+
308
+ await expect(action("hello")).resolves.toEqual({
309
+ data: { input: "hello", sub: claims.sub },
310
+ ok: true,
311
+ })
312
+ })
178
313
  })
package/src/next.ts CHANGED
@@ -6,13 +6,30 @@ import {
6
6
  createSupabaseClient,
7
7
  type DrizzleClient,
8
8
  } from "./clients.ts"
9
- import { createDefineAction, type DefineAction } from "./define-action.ts"
9
+ import {
10
+ createDefineAction,
11
+ createDefineFormAction,
12
+ type DefineAction,
13
+ type DefineFormAction,
14
+ } from "./define-action.ts"
10
15
 
16
+ // biome-ignore lint/performance/noBarrelFile: `/next` is the one entry server actions import; the result types and error class belong on it.
17
+ export {
18
+ type ActionError,
19
+ type ActionIssue,
20
+ type ActionResult,
21
+ FieldValidationError,
22
+ toActionError,
23
+ toFieldName,
24
+ } from "./action-result.ts"
11
25
  export type {
12
26
  ActionContext,
13
27
  ActionHandler,
14
28
  ActionSchema,
29
+ ActionValue,
15
30
  DefineAction,
31
+ DefineFormAction,
32
+ FormActionSchema,
16
33
  } from "./define-action.ts"
17
34
 
18
35
  /**
@@ -58,6 +75,11 @@ export type RequestDb<TRelations extends AnyRelations = EmptyRelations> = {
58
75
  getRequestDb: () => Promise<DrizzleClient<TRelations>>
59
76
  /** Wraps a handler into a `"use server"` export; see `DefineAction`. */
60
77
  defineAction: DefineAction<RequestContext<TRelations>>
78
+ /**
79
+ * `defineAction` for forms: resolves to an `ActionResult` instead of
80
+ * throwing on invalid input; see `DefineFormAction`.
81
+ */
82
+ defineFormAction: DefineFormAction<RequestContext<TRelations>>
61
83
  }
62
84
 
63
85
  /**
@@ -106,6 +128,7 @@ export function createRequestDb<
106
128
 
107
129
  return {
108
130
  defineAction: createDefineAction(getRequestContext),
131
+ defineFormAction: createDefineFormAction(getRequestContext),
109
132
  getRequestContext,
110
133
  getRequestDb: async () => (await getRequestContext()).db,
111
134
  }
@@ -0,0 +1,37 @@
1
+ import { defineTableSchema } from "@zeno-lib/schema"
2
+ import { text } from "drizzle-orm/pg-core"
3
+ import { describe, expect, it } from "vitest"
4
+
5
+ import { auditColumns, sequentialPrimaryId, table } from "./schema.ts"
6
+
7
+ // `@zeno-lib/schema` cannot import this package, so it recognises the audit
8
+ // columns by the keys these helpers emit. This pins that contract.
9
+ describe("schema helpers under defineTableSchema", () => {
10
+ const notes = table("notes", {
11
+ ...auditColumns(),
12
+ body: text().notNull(),
13
+ id: sequentialPrimaryId(),
14
+ })
15
+ const schemas = defineTableSchema(notes)
16
+
17
+ it("keeps every audit column out of insert and update", () => {
18
+ const forged = {
19
+ body: "Hello",
20
+ createdAt: new Date(),
21
+ createdBy: "00000000-0000-4000-8000-000000000000",
22
+ updatedAt: new Date(),
23
+ updatedBy: "00000000-0000-4000-8000-000000000000",
24
+ }
25
+
26
+ expect(schemas.insert.parse(forged)).toEqual({ body: "Hello" })
27
+ expect(schemas.update.parse(forged)).toEqual({ body: "Hello" })
28
+ })
29
+
30
+ it("accepts only positive sequential ids", () => {
31
+ expect(schemas.insert.safeParse({ body: "x", id: 0 }).success).toBe(false)
32
+ expect(schemas.insert.parse({ body: "x", id: 1 })).toEqual({
33
+ body: "x",
34
+ id: 1,
35
+ })
36
+ })
37
+ })