@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 +106 -3
- package/dist/next.mjs +100 -2
- package/package.json +6 -5
- package/src/action-result.test.ts +88 -0
- package/src/action-result.ts +109 -0
- package/src/define-action.ts +94 -4
- package/src/next.test-d.ts +46 -2
- package/src/next.test.ts +136 -1
- package/src/next.ts +24 -1
- package/src/schema-zod.test.ts +37 -0
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
|
-
|
|
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.
|
|
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.
|
|
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": "
|
|
75
|
-
"drizzle-kit": "1.0.0-rc.
|
|
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": "
|
|
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
|
+
}
|
package/src/define-action.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
}
|
package/src/next.test-d.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
+
})
|