@zeno-lib/db 0.4.0 → 0.5.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
@@ -29,6 +93,32 @@ type ActionHandler<TContext extends ActionContext<unknown>, TOutput, TResult> =
29
93
  * `handler(db, input, context)`.
30
94
  */
31
95
  type DefineAction<TContext extends ActionContext<unknown>> = <TInput, TOutput, TResult>(schema: ActionSchema<TInput, TOutput>, handler: ActionHandler<TContext, TOutput, TResult>) => (input: TInput) => Promise<TResult>;
96
+ /**
97
+ * The schema `defineFormAction` needs: the Standard Schema `validate`, which
98
+ * reports failures as issues with paths instead of throwing. Zod 4 schemas
99
+ * satisfy it as they are.
100
+ */
101
+ interface FormActionSchema<TInput, TOutput> {
102
+ readonly "~standard": {
103
+ readonly types?: {
104
+ readonly input: TInput;
105
+ readonly output: TOutput;
106
+ } | undefined;
107
+ readonly validate: (value: unknown) => FormActionSchemaResult<TOutput> | Promise<FormActionSchemaResult<TOutput>>;
108
+ };
109
+ }
110
+ type FormActionSchemaResult<TOutput> = {
111
+ readonly value: TOutput;
112
+ readonly issues?: undefined;
113
+ } | {
114
+ readonly issues: readonly ActionIssue[];
115
+ };
116
+ /**
117
+ * `defineFormAction(schema, handler)` is `defineAction` for a form: the action
118
+ * resolves to an `ActionResult` instead of throwing on invalid input, so field
119
+ * errors survive Next.js's production redaction of thrown messages.
120
+ */
121
+ 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
122
  //#endregion
33
123
  //#region src/next.d.ts
34
124
  /**
@@ -67,6 +157,11 @@ type RequestDb<TRelations extends AnyRelations = EmptyRelations> = {
67
157
  getRequestDb: () => Promise<DrizzleClient<TRelations>>;
68
158
  /** Wraps a handler into a `"use server"` export; see `DefineAction`. */
69
159
  defineAction: DefineAction<RequestContext<TRelations>>;
160
+ /**
161
+ * `defineAction` for forms: resolves to an `ActionResult` instead of
162
+ * throwing on invalid input; see `DefineFormAction`.
163
+ */
164
+ defineFormAction: DefineFormAction<RequestContext<TRelations>>;
70
165
  };
71
166
  /**
72
167
  * Request-scoped, RLS-bound Drizzle access for Next.js. Verifies the session
@@ -84,4 +179,4 @@ type RequestDb<TRelations extends AnyRelations = EmptyRelations> = {
84
179
  */
85
180
  declare function createRequestDb<TRelations extends AnyRelations = EmptyRelations>(options: CreateRequestDbOptions<TRelations>): RequestDb<TRelations>;
86
181
  //#endregion
87
- export { type ActionContext, type ActionHandler, type ActionSchema, ClaimsSource, CreateRequestDbOptions, type DefineAction, RequestContext, RequestDb, UnauthenticatedError, createRequestDb };
182
+ export { type ActionContext, type ActionError, type ActionHandler, type ActionIssue, type ActionResult, type ActionSchema, 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
@@ -16,6 +84,35 @@ function createDefineAction(getContext) {
16
84
  return await handler(context.db, parsed, context);
17
85
  };
18
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
+ }
114
+ };
115
+ }
19
116
  //#endregion
20
117
  //#region src/next.ts
21
118
  /**
@@ -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.5.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -82,8 +82,9 @@
82
82
  "vite": "8.0.16",
83
83
  "vitest": "4.1.11",
84
84
  "zod": "^4.4.3",
85
- "@zeno-lib/test": "0.1.0",
86
- "@zeno-lib/typescript": "^1.1.0"
85
+ "@zeno-lib/schema": "0.2.2",
86
+ "@zeno-lib/typescript": "^1.1.0",
87
+ "@zeno-lib/test": "0.1.0"
87
88
  },
88
89
  "scripts": {
89
90
  "build": "tsdown",
@@ -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.
@@ -61,3 +68,70 @@ export function createDefineAction<TContext extends ActionContext<unknown>>(
61
68
  return await handler(context.db, parsed, context)
62
69
  }
63
70
  }
71
+
72
+ /**
73
+ * The schema `defineFormAction` needs: the Standard Schema `validate`, which
74
+ * reports failures as issues with paths instead of throwing. Zod 4 schemas
75
+ * satisfy it as they are.
76
+ */
77
+ export interface FormActionSchema<TInput, TOutput> {
78
+ readonly "~standard": {
79
+ readonly types?:
80
+ | { readonly input: TInput; readonly output: TOutput }
81
+ | undefined
82
+ readonly validate: (
83
+ value: unknown
84
+ ) =>
85
+ | FormActionSchemaResult<TOutput>
86
+ | Promise<FormActionSchemaResult<TOutput>>
87
+ }
88
+ }
89
+
90
+ type FormActionSchemaResult<TOutput> =
91
+ | { readonly value: TOutput; readonly issues?: undefined }
92
+ | { readonly issues: readonly ActionIssue[] }
93
+
94
+ /**
95
+ * `defineFormAction(schema, handler)` is `defineAction` for a form: the action
96
+ * resolves to an `ActionResult` instead of throwing on invalid input, so field
97
+ * errors survive Next.js's production redaction of thrown messages.
98
+ */
99
+ export type DefineFormAction<TContext extends ActionContext<unknown>> = <
100
+ TInput,
101
+ TOutput,
102
+ TResult,
103
+ >(
104
+ schema: FormActionSchema<TInput, TOutput>,
105
+ handler: ActionHandler<TContext, TOutput, TResult>
106
+ ) => (input: TInput) => Promise<ActionResult<Awaited<TResult>>>
107
+
108
+ /**
109
+ * Binds `defineFormAction` to a request-context resolver. Same order as
110
+ * `createDefineAction` (validate, then resolve the context, then run the
111
+ * handler), but two failures come back as `{ ok: false, error }`: schema
112
+ * issues, and a `FieldValidationError` thrown by the handler. Everything else
113
+ * (an `UnauthenticatedError`, a database error) still throws.
114
+ */
115
+ export function createDefineFormAction<TContext extends ActionContext<unknown>>(
116
+ getContext: () => Promise<TContext>
117
+ ): DefineFormAction<TContext> {
118
+ return (schema, handler) => async (input) => {
119
+ const parsed = await schema["~standard"].validate(input)
120
+ if (parsed.issues) {
121
+ return { error: toActionError(parsed.issues), ok: false }
122
+ }
123
+ const context = await getContext()
124
+
125
+ try {
126
+ return {
127
+ data: await handler(context.db, parsed.value, context),
128
+ ok: true,
129
+ }
130
+ } catch (error) {
131
+ if (error instanceof FieldValidationError) {
132
+ return { error: error.toActionError(), ok: false }
133
+ }
134
+ throw error
135
+ }
136
+ }
137
+ }
@@ -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,31 @@ test("a synchronous handler still yields an async action", () => {
30
30
 
31
31
  expectTypeOf(action).returns.toEqualTypeOf<Promise<boolean>>()
32
32
  })
33
+
34
+ test("defineFormAction takes the schema's input and resolves to an ActionResult", () => {
35
+ const schema = z.object({ id: z.string().transform(Number) })
36
+ const action = defineFormAction(schema, (db, input) => {
37
+ expectTypeOf(db).toEqualTypeOf<DrizzleClient>()
38
+ expectTypeOf(input).toEqualTypeOf<{ id: number }>()
39
+ return Promise.resolve({ saved: input.id })
40
+ })
41
+
42
+ expectTypeOf(action).parameter(0).toEqualTypeOf<{ id: string }>()
43
+ expectTypeOf(action).returns.toEqualTypeOf<
44
+ Promise<ActionResult<{ saved: number }>>
45
+ >()
46
+ })
47
+
48
+ test("an ActionResult narrows on ok", async () => {
49
+ const action = defineFormAction(z.number(), (_db, input) => input > 0)
50
+ const result = await action(1)
51
+
52
+ if (result.ok) {
53
+ expectTypeOf(result.data).toEqualTypeOf<boolean>()
54
+ } else {
55
+ expectTypeOf(result.error).toEqualTypeOf<ActionError>()
56
+ expectTypeOf(result.error.fieldErrors).toEqualTypeOf<
57
+ Record<string, string[]>
58
+ >()
59
+ }
60
+ })
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
@@ -112,6 +113,108 @@ describe("defineAction", () => {
112
113
  })
113
114
  })
114
115
 
116
+ describe("defineFormAction", () => {
117
+ const schema = z.object({
118
+ owners: z.array(
119
+ z.object({ percentage: z.number().max(100, "At most 100") })
120
+ ),
121
+ })
122
+ const context = { claims: { sub: "user-1" }, db: { name: "db" } }
123
+
124
+ it("resolves to { ok: true, data } with the handler's result", async () => {
125
+ const handler = vi.fn(
126
+ (_db: { name: string }, input: z.output<typeof schema>) =>
127
+ Promise.resolve(input.owners.length)
128
+ )
129
+ const action = createDefineFormAction(() => Promise.resolve(context))(
130
+ schema,
131
+ handler
132
+ )
133
+
134
+ await expect(action({ owners: [{ percentage: 50 }] })).resolves.toEqual({
135
+ data: 1,
136
+ ok: true,
137
+ })
138
+ expect(handler).toHaveBeenCalledWith(
139
+ context.db,
140
+ { owners: [{ percentage: 50 }] },
141
+ context
142
+ )
143
+ })
144
+
145
+ it("returns schema issues keyed by field name, before resolving the context", async () => {
146
+ const getContext = vi.fn(() => Promise.resolve(context))
147
+ const handler = vi.fn()
148
+ const action = createDefineFormAction(getContext)(schema, handler)
149
+
150
+ await expect(
151
+ action({ owners: [{ percentage: 10 }, { percentage: 150 }] })
152
+ ).resolves.toEqual({
153
+ error: {
154
+ fieldErrors: { "owners[1].percentage": ["At most 100"] },
155
+ formErrors: [],
156
+ },
157
+ ok: false,
158
+ })
159
+ expect(getContext).not.toHaveBeenCalled()
160
+ expect(handler).not.toHaveBeenCalled()
161
+ })
162
+
163
+ it("returns a FieldValidationError thrown by the handler in the same shape", async () => {
164
+ const action = createDefineFormAction(() => Promise.resolve(context))(
165
+ schema,
166
+ () => {
167
+ throw new FieldValidationError(
168
+ { "owners[0].percentage": "Already allocated" },
169
+ { formErrors: "Record is locked" }
170
+ )
171
+ }
172
+ )
173
+
174
+ await expect(action({ owners: [{ percentage: 1 }] })).resolves.toEqual({
175
+ error: {
176
+ fieldErrors: { "owners[0].percentage": ["Already allocated"] },
177
+ formErrors: ["Record is locked"],
178
+ },
179
+ ok: false,
180
+ })
181
+ })
182
+
183
+ it("keeps a failure that is not a validation error throwing", async () => {
184
+ const boom = new Error("connection reset")
185
+ const action = createDefineFormAction(() => Promise.resolve(context))(
186
+ schema,
187
+ () => Promise.reject(boom)
188
+ )
189
+
190
+ await expect(action({ owners: [] })).rejects.toBe(boom)
191
+ })
192
+
193
+ it("keeps a context failure throwing", async () => {
194
+ const handler = vi.fn()
195
+ const action = createDefineFormAction(() =>
196
+ Promise.reject(new UnauthenticatedError())
197
+ )(schema, handler)
198
+
199
+ await expect(action({ owners: [] })).rejects.toBeInstanceOf(
200
+ UnauthenticatedError
201
+ )
202
+ expect(handler).not.toHaveBeenCalled()
203
+ })
204
+
205
+ it("returns a result that survives a JSON round trip unchanged", async () => {
206
+ const action = createDefineFormAction(() => Promise.resolve(context))(
207
+ schema,
208
+ () => ({ id: 1 })
209
+ )
210
+ const failure = await action({ owners: [{ percentage: 101 }] })
211
+ const success = await action({ owners: [] })
212
+
213
+ expect(JSON.parse(JSON.stringify(failure))).toEqual(failure)
214
+ expect(JSON.parse(JSON.stringify(success))).toEqual(success)
215
+ })
216
+ })
217
+
115
218
  describe("createRequestDb", () => {
116
219
  it("builds the RLS client from the whole verified claims object", async () => {
117
220
  const { getRequestContext, getRequestDb } = createRequestDb({
@@ -175,4 +278,19 @@ describe("createRequestDb", () => {
175
278
  sub: claims.sub,
176
279
  })
177
280
  })
281
+
282
+ it("binds defineFormAction to the request context", async () => {
283
+ const { defineFormAction } = createRequestDb({
284
+ supabase: supabaseReturning(getClaimsResult(claims, null)),
285
+ })
286
+ const action = defineFormAction(z.string(), (_db, input, context) => ({
287
+ input,
288
+ sub: context.claims.sub,
289
+ }))
290
+
291
+ await expect(action("hello")).resolves.toEqual({
292
+ data: { input: "hello", sub: claims.sub },
293
+ ok: true,
294
+ })
295
+ })
178
296
  })
package/src/next.ts CHANGED
@@ -6,13 +6,29 @@ 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,
15
29
  DefineAction,
30
+ DefineFormAction,
31
+ FormActionSchema,
16
32
  } from "./define-action.ts"
17
33
 
18
34
  /**
@@ -58,6 +74,11 @@ export type RequestDb<TRelations extends AnyRelations = EmptyRelations> = {
58
74
  getRequestDb: () => Promise<DrizzleClient<TRelations>>
59
75
  /** Wraps a handler into a `"use server"` export; see `DefineAction`. */
60
76
  defineAction: DefineAction<RequestContext<TRelations>>
77
+ /**
78
+ * `defineAction` for forms: resolves to an `ActionResult` instead of
79
+ * throwing on invalid input; see `DefineFormAction`.
80
+ */
81
+ defineFormAction: DefineFormAction<RequestContext<TRelations>>
61
82
  }
62
83
 
63
84
  /**
@@ -106,6 +127,7 @@ export function createRequestDb<
106
127
 
107
128
  return {
108
129
  defineAction: createDefineAction(getRequestContext),
130
+ defineFormAction: createDefineFormAction(getRequestContext),
109
131
  getRequestContext,
110
132
  getRequestDb: async () => (await getRequestContext()).db,
111
133
  }
@@ -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
+ })