@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.
@@ -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
@@ -7,5 +7,7 @@ export {
7
7
  createServiceClient,
8
8
  createSupabaseClient,
9
9
  type DrizzleClient,
10
+ type ResolveDatabaseUrlOptions,
11
+ resolveDatabaseUrl,
10
12
  type SupabaseToken,
11
13
  } from "./clients.ts"
@@ -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
+ })