@zeno-lib/db 0.3.2 → 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/clients-BlUEH7QJ.d.mts +58 -0
- package/dist/clients-CdPr3mCZ.mjs +197 -0
- package/dist/errors.d.mts +70 -0
- package/dist/errors.mjs +78 -0
- package/dist/function-names-BDc-AuFm.mjs +10 -0
- package/dist/function-names-hv2mj_x1.d.mts +5 -0
- package/dist/index.d.mts +2 -42
- package/dist/index.mjs +2 -167
- package/dist/next.d.mts +182 -0
- package/dist/next.mjs +167 -0
- package/dist/query.d.mts +103 -0
- package/dist/query.mjs +115 -0
- package/dist/schema.d.mts +20 -11
- package/dist/schema.mjs +11 -12
- package/package.json +31 -6
- package/src/action-result.test.ts +88 -0
- package/src/action-result.ts +109 -0
- package/src/casing.test.ts +43 -20
- package/src/clients.ts +60 -5
- package/src/database-url.test.ts +88 -0
- package/src/define-action.ts +137 -0
- package/src/errors.test.ts +95 -0
- package/src/errors.ts +133 -0
- package/src/function-names.ts +21 -0
- package/src/index.ts +2 -0
- package/src/next.test-d.ts +60 -0
- package/src/next.test.ts +296 -0
- package/src/next.ts +134 -0
- package/src/query.test-d.ts +20 -0
- package/src/query.test.ts +261 -0
- package/src/query.ts +252 -0
- package/src/schema-zod.test.ts +37 -0
- package/src/schema.test-d.ts +24 -0
- package/src/schema.ts +51 -21
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ActionIssue,
|
|
3
|
+
type ActionResult,
|
|
4
|
+
FieldValidationError,
|
|
5
|
+
toActionError,
|
|
6
|
+
} from "./action-result.ts"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The slice of a schema `defineAction` needs: a throwing `parse`, plus the
|
|
10
|
+
* Standard Schema `types` marker it reads the caller-facing input type from.
|
|
11
|
+
* Zod 4 schemas satisfy it as they are; so does anything else that implements
|
|
12
|
+
* Standard Schema and exposes `parse`.
|
|
13
|
+
*/
|
|
14
|
+
export interface ActionSchema<TInput, TOutput> {
|
|
15
|
+
readonly "~standard": {
|
|
16
|
+
readonly types?:
|
|
17
|
+
| { readonly input: TInput; readonly output: TOutput }
|
|
18
|
+
| undefined
|
|
19
|
+
}
|
|
20
|
+
parse(input: unknown): TOutput
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What a request context must carry for `defineAction` to hand it over. */
|
|
24
|
+
export interface ActionContext<TDb> {
|
|
25
|
+
readonly db: TDb
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** An action's body: the RLS-bound `db`, the parsed input, and the context. */
|
|
29
|
+
export type ActionHandler<
|
|
30
|
+
TContext extends ActionContext<unknown>,
|
|
31
|
+
TOutput,
|
|
32
|
+
TResult,
|
|
33
|
+
> = (
|
|
34
|
+
db: TContext["db"],
|
|
35
|
+
input: TOutput,
|
|
36
|
+
context: TContext
|
|
37
|
+
) => TResult | Promise<TResult>
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `defineAction(schema, handler)` returns the server action itself: an async
|
|
41
|
+
* function that parses its argument, resolves the request context, then calls
|
|
42
|
+
* `handler(db, input, context)`.
|
|
43
|
+
*/
|
|
44
|
+
export type DefineAction<TContext extends ActionContext<unknown>> = <
|
|
45
|
+
TInput,
|
|
46
|
+
TOutput,
|
|
47
|
+
TResult,
|
|
48
|
+
>(
|
|
49
|
+
schema: ActionSchema<TInput, TOutput>,
|
|
50
|
+
handler: ActionHandler<TContext, TOutput, TResult>
|
|
51
|
+
) => (input: TInput) => Promise<TResult>
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Binds `defineAction` to a request-context resolver. Deliberately not a
|
|
55
|
+
* `"use server"` module: it only returns a function, and the app file that
|
|
56
|
+
* exports the result is the one that carries the directive.
|
|
57
|
+
*
|
|
58
|
+
* Input is parsed before the context is resolved, so a malformed call fails
|
|
59
|
+
* without a session round trip, and the handler never sees an unparsed value.
|
|
60
|
+
*/
|
|
61
|
+
export function createDefineAction<TContext extends ActionContext<unknown>>(
|
|
62
|
+
getContext: () => Promise<TContext>
|
|
63
|
+
): DefineAction<TContext> {
|
|
64
|
+
return (schema, handler) => async (input) => {
|
|
65
|
+
const parsed = schema.parse(input)
|
|
66
|
+
const context = await getContext()
|
|
67
|
+
|
|
68
|
+
return await handler(context.db, parsed, context)
|
|
69
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { DrizzleQueryError } from "drizzle-orm"
|
|
2
|
+
import postgres from "postgres"
|
|
3
|
+
import { describe, expect, it } from "vitest"
|
|
4
|
+
import { isConstraintViolation, SqlState, toPostgresError } from "./errors.ts"
|
|
5
|
+
|
|
6
|
+
// postgres.js builds its errors from a server message; this is that shape,
|
|
7
|
+
// constructed directly so no server is needed.
|
|
8
|
+
const postgresError = (fields: Record<string, string>) =>
|
|
9
|
+
Object.assign(new postgres.PostgresError(fields.message ?? "failed"), fields)
|
|
10
|
+
|
|
11
|
+
// What Drizzle throws: the driver error as `cause`, `code` undefined.
|
|
12
|
+
const wrapped = (cause: Error) =>
|
|
13
|
+
new DrizzleQueryError("insert into contacts …", [], cause)
|
|
14
|
+
|
|
15
|
+
const duplicate = postgresError({
|
|
16
|
+
code: SqlState.uniqueViolation,
|
|
17
|
+
constraint_name: "contacts_email_key",
|
|
18
|
+
message: "duplicate key value violates unique constraint",
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe("toPostgresError", () => {
|
|
22
|
+
it("unwraps Drizzle's DrizzleQueryError", () => {
|
|
23
|
+
const error = wrapped(duplicate)
|
|
24
|
+
|
|
25
|
+
expect((error as { code?: string }).code).toBeUndefined()
|
|
26
|
+
expect(toPostgresError(error)).toBe(duplicate)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("unwraps a second level, as a failed transaction nests", () => {
|
|
30
|
+
const outer = Object.assign(new Error("transaction failed"), {
|
|
31
|
+
cause: wrapped(duplicate),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
expect(toPostgresError(outer)).toBe(duplicate)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("returns the error itself when it is already one", () => {
|
|
38
|
+
expect(toPostgresError(duplicate)).toBe(duplicate)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("returns undefined for anything else", () => {
|
|
42
|
+
expect(toPostgresError(new Error("nope"))).toBeUndefined()
|
|
43
|
+
expect(toPostgresError("nope")).toBeUndefined()
|
|
44
|
+
expect(toPostgresError(undefined)).toBeUndefined()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("stops on a cyclic cause chain", () => {
|
|
48
|
+
const a = new Error("a")
|
|
49
|
+
const b = Object.assign(new Error("b"), { cause: a })
|
|
50
|
+
Object.assign(a, { cause: b })
|
|
51
|
+
|
|
52
|
+
expect(toPostgresError(a)).toBeUndefined()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe("isConstraintViolation", () => {
|
|
57
|
+
it("matches any class 23 code by default", () => {
|
|
58
|
+
expect(isConstraintViolation(wrapped(duplicate))).toBe(duplicate)
|
|
59
|
+
|
|
60
|
+
const notNull = postgresError({ code: SqlState.notNullViolation })
|
|
61
|
+
expect(isConstraintViolation(notNull)).toBe(notNull)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("does not match other SQLSTATE classes", () => {
|
|
65
|
+
const denied = postgresError({ code: SqlState.insufficientPrivilege })
|
|
66
|
+
|
|
67
|
+
expect(isConstraintViolation(denied)).toBeUndefined()
|
|
68
|
+
expect(isConstraintViolation(new Error("nope"))).toBeUndefined()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("narrows to named constraints", () => {
|
|
72
|
+
expect(
|
|
73
|
+
isConstraintViolation(duplicate, new Set(["contacts_email_key"]))
|
|
74
|
+
).toBe(duplicate)
|
|
75
|
+
expect(isConstraintViolation(duplicate, ["other_key"])).toBeUndefined()
|
|
76
|
+
expect(
|
|
77
|
+
isConstraintViolation(postgresError({ code: SqlState.uniqueViolation }), [
|
|
78
|
+
"contacts_email_key",
|
|
79
|
+
])
|
|
80
|
+
).toBeUndefined()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("narrows to one code", () => {
|
|
84
|
+
expect(
|
|
85
|
+
isConstraintViolation(duplicate, undefined, {
|
|
86
|
+
code: SqlState.uniqueViolation,
|
|
87
|
+
})
|
|
88
|
+
).toBe(duplicate)
|
|
89
|
+
expect(
|
|
90
|
+
isConstraintViolation(duplicate, ["contacts_email_key"], {
|
|
91
|
+
code: SqlState.foreignKeyViolation,
|
|
92
|
+
})
|
|
93
|
+
).toBeUndefined()
|
|
94
|
+
})
|
|
95
|
+
})
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Recognising Postgres errors through Drizzle. Runtime code, not a test helper:
|
|
2
|
+
// mapping a unique violation to a form error is ordinary request handling.
|
|
3
|
+
import type { PostgresError } from "postgres"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SQLSTATE codes worth naming. The full list is Postgres's "Error Codes"
|
|
7
|
+
* appendix; these are the ones an application branches on.
|
|
8
|
+
*/
|
|
9
|
+
export const SqlState = {
|
|
10
|
+
/** `check_violation`. */
|
|
11
|
+
checkViolation: "23514",
|
|
12
|
+
/** `exclusion_violation`. */
|
|
13
|
+
exclusionViolation: "23P01",
|
|
14
|
+
/** `foreign_key_violation`. */
|
|
15
|
+
foreignKeyViolation: "23503",
|
|
16
|
+
/** `insufficient_privilege`: an RLS policy or a missing grant refused it. */
|
|
17
|
+
insufficientPrivilege: "42501",
|
|
18
|
+
/**
|
|
19
|
+
* `invalid_column_reference`, which is what an `ON CONFLICT` target that no
|
|
20
|
+
* unique index can arbitrate raises. It is raised while the statement is
|
|
21
|
+
* planned, so it fires on the first insert, not on the first conflict.
|
|
22
|
+
*/
|
|
23
|
+
invalidColumnReference: "42P10",
|
|
24
|
+
/** `not_null_violation`. */
|
|
25
|
+
notNullViolation: "23502",
|
|
26
|
+
/** `undefined_function`: a function missing, or called with the wrong signature. */
|
|
27
|
+
undefinedFunction: "42883",
|
|
28
|
+
/** `unique_violation`. */
|
|
29
|
+
uniqueViolation: "23505",
|
|
30
|
+
} as const
|
|
31
|
+
|
|
32
|
+
export type SqlStateCode = (typeof SqlState)[keyof typeof SqlState]
|
|
33
|
+
|
|
34
|
+
/** SQLSTATE class 23, every `integrity_constraint_violation`. */
|
|
35
|
+
const INTEGRITY_CONSTRAINT_CLASS = "23"
|
|
36
|
+
|
|
37
|
+
// Drizzle wraps once, and a failure inside `db.transaction()` can nest one
|
|
38
|
+
// more level. The bound only stops a cyclic `cause` chain.
|
|
39
|
+
const MAX_CAUSE_DEPTH = 5
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The postgres.js error underneath whatever Drizzle threw, or `undefined`.
|
|
43
|
+
*
|
|
44
|
+
* Drizzle wraps **every** driver error in a `DrizzleQueryError` whose own
|
|
45
|
+
* `code` is `undefined`, so `error.code === "23505"` is always false:
|
|
46
|
+
*
|
|
47
|
+
* ```
|
|
48
|
+
* depth 0: DrizzleQueryError code=undefined "Failed query: insert into …"
|
|
49
|
+
* depth 1: PostgresError code=23505 "duplicate key value violates …"
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* Matched by `name` rather than `instanceof`, so a second copy of `postgres`
|
|
53
|
+
* in the tree cannot make it miss.
|
|
54
|
+
*/
|
|
55
|
+
export const toPostgresError = (error: unknown): PostgresError | undefined => {
|
|
56
|
+
let current: unknown = error
|
|
57
|
+
|
|
58
|
+
for (
|
|
59
|
+
let depth = 0;
|
|
60
|
+
current instanceof Error && depth < MAX_CAUSE_DEPTH;
|
|
61
|
+
depth += 1
|
|
62
|
+
) {
|
|
63
|
+
if (current.name === "PostgresError") {
|
|
64
|
+
return current as PostgresError
|
|
65
|
+
}
|
|
66
|
+
// `Error.cause` is ES2022, past this package's `lib`.
|
|
67
|
+
current = (current as Error & { cause?: unknown }).cause
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type ConstraintViolationOptions = {
|
|
74
|
+
/**
|
|
75
|
+
* The SQLSTATE to require, e.g. `SqlState.uniqueViolation`. Defaults to any
|
|
76
|
+
* code in class 23 (`integrity_constraint_violation`).
|
|
77
|
+
*/
|
|
78
|
+
code?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether `error` is an integrity-constraint violation, optionally one of the
|
|
83
|
+
* named constraints. Returns the `PostgresError` so a caller can read its
|
|
84
|
+
* `constraint_name` or `detail`, or `undefined` when it does not match.
|
|
85
|
+
*
|
|
86
|
+
* ```ts
|
|
87
|
+
* try {
|
|
88
|
+
* await db.insert(contacts).values(row)
|
|
89
|
+
* } catch (error) {
|
|
90
|
+
* if (isConstraintViolation(error, ["contacts_email_key"], { code: SqlState.uniqueViolation })) {
|
|
91
|
+
* return { error: "That email is already a contact" }
|
|
92
|
+
* }
|
|
93
|
+
* throw error
|
|
94
|
+
* }
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export const isConstraintViolation = (
|
|
98
|
+
error: unknown,
|
|
99
|
+
constraints?: Iterable<string>,
|
|
100
|
+
{ code }: ConstraintViolationOptions = {}
|
|
101
|
+
): PostgresError | undefined => {
|
|
102
|
+
const postgresError = toPostgresError(error)
|
|
103
|
+
|
|
104
|
+
if (!postgresError) {
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const matchesCode = code
|
|
109
|
+
? postgresError.code === code
|
|
110
|
+
: postgresError.code.startsWith(INTEGRITY_CONSTRAINT_CLASS)
|
|
111
|
+
|
|
112
|
+
if (!matchesCode) {
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (constraints === undefined) {
|
|
117
|
+
return postgresError
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const name = postgresError.constraint_name
|
|
121
|
+
|
|
122
|
+
if (name === undefined) {
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const constraint of constraints) {
|
|
127
|
+
if (constraint === name) {
|
|
128
|
+
return postgresError
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return
|
|
133
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The naming convention `functionPolicies` writes policies against and
|
|
2
|
+
// `selectFunctionPermissions` calls: `<prefix>_<operation>_<table>`. Shared so
|
|
3
|
+
// the two cannot drift apart.
|
|
4
|
+
|
|
5
|
+
export const FUNCTION_POLICY_OPERATIONS = [
|
|
6
|
+
"select",
|
|
7
|
+
"insert",
|
|
8
|
+
"update",
|
|
9
|
+
"delete",
|
|
10
|
+
] as const
|
|
11
|
+
|
|
12
|
+
export type FunctionPolicyOperation =
|
|
13
|
+
(typeof FUNCTION_POLICY_OPERATIONS)[number]
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_FUNCTION_PREFIX = "can"
|
|
16
|
+
|
|
17
|
+
export const functionPolicyName = (
|
|
18
|
+
prefix: string,
|
|
19
|
+
operation: FunctionPolicyOperation,
|
|
20
|
+
tableName: string
|
|
21
|
+
) => `${prefix}_${operation}_${tableName}`
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { JwtPayload } from "@supabase/supabase-js"
|
|
2
|
+
import { expectTypeOf, test } from "vitest"
|
|
3
|
+
import { z } from "zod"
|
|
4
|
+
import type { DrizzleClient } from "./clients.ts"
|
|
5
|
+
import { type ActionError, type ActionResult, createRequestDb } from "./next.ts"
|
|
6
|
+
|
|
7
|
+
const { defineAction, defineFormAction } = createRequestDb({
|
|
8
|
+
supabase: () => ({
|
|
9
|
+
auth: {
|
|
10
|
+
getClaims: () => Promise.resolve({ data: null, error: null }),
|
|
11
|
+
},
|
|
12
|
+
}),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test("the action takes the schema's input and resolves to the handler's result", () => {
|
|
16
|
+
const schema = z.object({ id: z.string().transform(Number) })
|
|
17
|
+
const action = defineAction(schema, (db, input, context) => {
|
|
18
|
+
expectTypeOf(db).toEqualTypeOf<DrizzleClient>()
|
|
19
|
+
expectTypeOf(input).toEqualTypeOf<{ id: number }>()
|
|
20
|
+
expectTypeOf(context.claims).toEqualTypeOf<JwtPayload>()
|
|
21
|
+
return Promise.resolve(input.id)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
expectTypeOf(action).parameter(0).toEqualTypeOf<{ id: string }>()
|
|
25
|
+
expectTypeOf(action).returns.toEqualTypeOf<Promise<number>>()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test("a synchronous handler still yields an async action", () => {
|
|
29
|
+
const action = defineAction(z.number(), (_db, input) => input > 0)
|
|
30
|
+
|
|
31
|
+
expectTypeOf(action).returns.toEqualTypeOf<Promise<boolean>>()
|
|
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
|
+
})
|