@zeno-lib/db 0.3.2 → 0.4.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.
@@ -1,4 +1,4 @@
1
- import { getTableColumns, type SQL, sql } from "drizzle-orm"
1
+ import { getTableColumns, is, type SQL, sql } from "drizzle-orm"
2
2
  import {
3
3
  bigint,
4
4
  getTableConfig,
@@ -8,6 +8,7 @@ import {
8
8
  isPgSequence,
9
9
  isPgView,
10
10
  PgDialect,
11
+ PgTimestampString,
11
12
  pgEnum,
12
13
  pgMaterializedView,
13
14
  pgPolicy,
@@ -507,6 +508,28 @@ describe("default casing", () => {
507
508
  )
508
509
  })
509
510
 
511
+ it("reads timestamps back as strings with mode: string, emitting the same type", () => {
512
+ const dated = getTableColumns(table("dated", { ...timestamps() }))
513
+ const stringly = getTableColumns(
514
+ table("stringly", { ...timestamps({ mode: "string", precision: 6 }) })
515
+ )
516
+ const audited = getTableColumns(
517
+ table("audited", { ...auditColumns({ mode: "string" }) })
518
+ )
519
+
520
+ expect(is(dated.createdAt, PgTimestampString)).toBe(false)
521
+ expect(is(stringly.createdAt, PgTimestampString)).toBe(true)
522
+ expect(is(stringly.updatedAt, PgTimestampString)).toBe(true)
523
+ expect(is(audited.createdAt, PgTimestampString)).toBe(true)
524
+ // drizzle spaces the precision differently per mode ("timestamp (6)" vs
525
+ // "timestamp(6)"); Postgres reads both as the same type.
526
+ expect(stringly.createdAt.getSQLType().replace(" (", "(")).toBe(
527
+ "timestamp(6) with time zone"
528
+ )
529
+ expect(stringly.updatedAt.notNull).toBe(true)
530
+ expect(stringly.updatedAt.default).toBeDefined()
531
+ })
532
+
510
533
  it("exports grouped audit column mixins", () => {
511
534
  expect(Object.keys(timestamps())).toEqual(["createdAt", "updatedAt"])
512
535
  expect(Object.keys(authorship())).toEqual(["createdBy", "updatedBy"])
@@ -751,45 +774,45 @@ describe("default casing", () => {
751
774
  })
752
775
 
753
776
  it("varies the argument per operation, insert usually taking none", () => {
754
- const deals = table(
755
- "deals",
777
+ const projects = table(
778
+ "projects",
756
779
  { id: primaryId("assigned"), ownerId: uuid() },
757
780
  (t) => functionPolicies(t, { argument: { delete: t.id, select: t.id } })
758
781
  )
759
- const policies = getTableConfig(deals).policies
782
+ const policies = getTableConfig(projects).policies
760
783
  const dialect = new PgDialect()
761
784
 
762
785
  // select and delete get the row; insert and update fall to no arguments,
763
786
  // insert because a missing key means "called with none" and update because
764
787
  // it was left out of the record too.
765
788
  expect(dialect.sqlToQuery(policies[0]?.using as SQL).sql).toBe(
766
- '(select "can_select_deals"("deals"."id"))'
789
+ '(select "can_select_projects"("projects"."id"))'
767
790
  )
768
791
  expect(dialect.sqlToQuery(policies[1]?.withCheck as SQL).sql).toBe(
769
- '(select "can_insert_deals"())'
792
+ '(select "can_insert_projects"())'
770
793
  )
771
794
  expect(dialect.sqlToQuery(policies[2]?.using as SQL).sql).toBe(
772
- '(select "can_update_deals"())'
795
+ '(select "can_update_projects"())'
773
796
  )
774
797
  expect(dialect.sqlToQuery(policies[3]?.using as SQL).sql).toBe(
775
- '(select "can_delete_deals"("deals"."id"))'
798
+ '(select "can_delete_projects"("projects"."id"))'
776
799
  )
777
800
  })
778
801
 
779
802
  it("reads an explicit null as a call with no arguments", () => {
780
- const deals = table("deals", { id: primaryId("assigned") }, (t) =>
803
+ const projects = table("projects", { id: primaryId("assigned") }, (t) =>
781
804
  functionPolicies(t, { argument: { insert: null, select: t.id } })
782
805
  )
783
- const policies = getTableConfig(deals).policies
806
+ const policies = getTableConfig(projects).policies
784
807
 
785
808
  expect(new PgDialect().sqlToQuery(policies[1]?.withCheck as SQL).sql).toBe(
786
- '(select "can_insert_deals"())'
809
+ '(select "can_insert_projects"())'
787
810
  )
788
811
  })
789
812
 
790
813
  it("passes several columns to a function that takes several", () => {
791
- const investors = table(
792
- "investors",
814
+ const memberships = table(
815
+ "memberships",
793
816
  {
794
817
  id: primaryId("uuid"),
795
818
  organisationId: bigint({ mode: "number" }),
@@ -797,22 +820,22 @@ describe("default casing", () => {
797
820
  },
798
821
  (t) => functionPolicies(t, { argument: [t.profileId, t.organisationId] })
799
822
  )
800
- const policies = getTableConfig(investors).policies
823
+ const policies = getTableConfig(memberships).policies
801
824
 
802
825
  expect(new PgDialect().sqlToQuery(policies[0]?.using as SQL).sql).toBe(
803
- '(select "can_select_investors"("investors"."profile_id", "investors"."organisation_id"))'
826
+ '(select "can_select_memberships"("memberships"."profile_id", "memberships"."organisation_id"))'
804
827
  )
805
828
  })
806
829
 
807
830
  it("qualifies the function with a schema when one is given", () => {
808
- const bexio = schema("bexio")
809
- const bills = bexio.table("bexio_bills", { id: primaryId("uuid") }, (t) =>
810
- functionPolicies(t, { schema: "bexio" })
831
+ const billing = schema("billing")
832
+ const invoices = billing.table("invoices", { id: primaryId("uuid") }, (t) =>
833
+ functionPolicies(t, { schema: "billing" })
811
834
  )
812
- const policies = getTableConfig(bills).policies
835
+ const policies = getTableConfig(invoices).policies
813
836
 
814
837
  expect(new PgDialect().sqlToQuery(policies[0]?.using as SQL).sql).toBe(
815
- '(select "bexio"."can_select_bexio_bills"())'
838
+ '(select "billing"."can_select_invoices"())'
816
839
  )
817
840
  })
818
841
  })
package/src/clients.ts CHANGED
@@ -21,6 +21,64 @@ export type CreateClientConfig<
21
21
  > = DrizzlePgConfig<TRelations> & {
22
22
  /** Overrides `process.env.SUPABASE_DATABASE_URL`. */
23
23
  connectionString?: string
24
+ /**
25
+ * Require the Supabase transaction pooler: port 6543 on a non-local host.
26
+ * Pass `true` in production (e.g. `process.env.VERCEL_ENV === "production"`)
27
+ * to fail at client creation rather than exhaust connections under load.
28
+ * Default `false`, which checks only that a URL is present.
29
+ */
30
+ requirePooler?: boolean
31
+ }
32
+
33
+ export type ResolveDatabaseUrlOptions = Pick<
34
+ CreateClientConfig,
35
+ "connectionString" | "requirePooler"
36
+ >
37
+
38
+ const POOLER_PORT = "6543"
39
+ const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "0.0.0.0"])
40
+
41
+ /**
42
+ * The connection string every factory uses: `connectionString`, else
43
+ * `SUPABASE_DATABASE_URL`. Throws when neither is set, and with
44
+ * `requirePooler` also when the URL is not the transaction pooler. Read per
45
+ * call, never at import, so a build without server secrets does not fail.
46
+ * Errors never echo the URL, which carries the password.
47
+ */
48
+ export function resolveDatabaseUrl({
49
+ connectionString,
50
+ requirePooler = false,
51
+ }: ResolveDatabaseUrlOptions = {}): string {
52
+ const url = connectionString ?? process.env.SUPABASE_DATABASE_URL ?? ""
53
+ if (!url) {
54
+ throw new Error("Missing SUPABASE_DATABASE_URL environment variable")
55
+ }
56
+ if (!requirePooler) {
57
+ return url
58
+ }
59
+
60
+ let parsed: URL
61
+ try {
62
+ parsed = new URL(url)
63
+ } catch {
64
+ throw new Error("SUPABASE_DATABASE_URL is not a valid URL")
65
+ }
66
+ if (parsed.protocol !== "postgresql:" && parsed.protocol !== "postgres:") {
67
+ throw new Error(
68
+ `SUPABASE_DATABASE_URL must be a postgresql:// URL, got ${parsed.protocol}//`
69
+ )
70
+ }
71
+ if (LOCAL_HOSTS.has(parsed.hostname)) {
72
+ throw new Error(
73
+ "SUPABASE_DATABASE_URL points at a local database, but the transaction pooler is required"
74
+ )
75
+ }
76
+ if (parsed.port !== POOLER_PORT) {
77
+ throw new Error(
78
+ `SUPABASE_DATABASE_URL must use the transaction pooler (port ${POOLER_PORT}), got port ${parsed.port || "(default)"}. Session mode holds one connection per instance for its whole lifetime.`
79
+ )
80
+ }
81
+ return url
24
82
  }
25
83
 
26
84
  // A directly-queryable Drizzle client plus a reference-counted `close()`.
@@ -108,11 +166,8 @@ function buildDrizzle<TRelations extends AnyRelations>(
108
166
  kind: PoolKind,
109
167
  config?: CreateClientConfig<TRelations>
110
168
  ): { close: CloseFn; db: PostgresJsDatabase<TRelations> } {
111
- const { connectionString, ...drizzleConfig } = config ?? {}
112
- const url = connectionString ?? process.env.SUPABASE_DATABASE_URL ?? ""
113
- if (!url) {
114
- throw new Error("Missing SUPABASE_DATABASE_URL environment variable")
115
- }
169
+ const { connectionString, requirePooler, ...drizzleConfig } = config ?? {}
170
+ const url = resolveDatabaseUrl({ connectionString, requirePooler })
116
171
  const entry = acquirePool(url, kind)
117
172
  const db = drizzle<TRelations>({
118
173
  client: entry.client,
@@ -0,0 +1,88 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2
+ import { createAdminClient, resolveDatabaseUrl } from "./clients.ts"
3
+
4
+ // Pure string checks: nothing here builds a pool or opens a socket, except the
5
+ // last test, which only reaches the check (it throws before `postgres()`).
6
+ const POOLER =
7
+ "postgresql://postgres.ref:secret@aws-0-eu-central-1.pooler.supabase.com:6543/postgres"
8
+ const SESSION =
9
+ "postgresql://postgres.ref:secret@aws-0-eu-central-1.pooler.supabase.com:5432/postgres"
10
+ const LOCAL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"
11
+
12
+ beforeEach(() => {
13
+ vi.stubEnv("SUPABASE_DATABASE_URL", undefined)
14
+ })
15
+
16
+ afterEach(() => {
17
+ vi.unstubAllEnvs()
18
+ })
19
+
20
+ describe("resolveDatabaseUrl", () => {
21
+ it("prefers the override, then the environment", () => {
22
+ vi.stubEnv("SUPABASE_DATABASE_URL", LOCAL)
23
+ expect(resolveDatabaseUrl()).toBe(LOCAL)
24
+ expect(resolveDatabaseUrl({ connectionString: POOLER })).toBe(POOLER)
25
+ })
26
+
27
+ it("throws when neither is set", () => {
28
+ expect(() => resolveDatabaseUrl()).toThrow(
29
+ "Missing SUPABASE_DATABASE_URL environment variable"
30
+ )
31
+ })
32
+
33
+ it("accepts any URL without requirePooler, as before", () => {
34
+ expect(resolveDatabaseUrl({ connectionString: LOCAL })).toBe(LOCAL)
35
+ expect(resolveDatabaseUrl({ connectionString: SESSION })).toBe(SESSION)
36
+ })
37
+
38
+ it("accepts the transaction pooler with requirePooler", () => {
39
+ expect(
40
+ resolveDatabaseUrl({ connectionString: POOLER, requirePooler: true })
41
+ ).toBe(POOLER)
42
+ })
43
+
44
+ it("rejects another port with requirePooler, without echoing the URL", () => {
45
+ const check = () =>
46
+ resolveDatabaseUrl({ connectionString: SESSION, requirePooler: true })
47
+
48
+ expect(check).toThrow("port 6543), got port 5432")
49
+ expect(check).not.toThrow("secret")
50
+ })
51
+
52
+ it("rejects a missing port with requirePooler", () => {
53
+ expect(() =>
54
+ resolveDatabaseUrl({
55
+ connectionString: "postgresql://u:p@db.example.com/postgres",
56
+ requirePooler: true,
57
+ })
58
+ ).toThrow("got port (default)")
59
+ })
60
+
61
+ it.each([
62
+ LOCAL,
63
+ "postgresql://u:p@localhost:6543/postgres",
64
+ "postgresql://u:p@[::1]:6543/postgres",
65
+ ])("rejects a local host with requirePooler: %s", (connectionString) => {
66
+ expect(() =>
67
+ resolveDatabaseUrl({ connectionString, requirePooler: true })
68
+ ).toThrow("points at a local database")
69
+ })
70
+
71
+ it("rejects a non-postgres URL with requirePooler", () => {
72
+ expect(() =>
73
+ resolveDatabaseUrl({
74
+ connectionString: "https://db.example.com:6543",
75
+ requirePooler: true,
76
+ })
77
+ ).toThrow("must be a postgresql:// URL")
78
+ expect(() =>
79
+ resolveDatabaseUrl({ connectionString: "not a url", requirePooler: true })
80
+ ).toThrow("is not a valid URL")
81
+ })
82
+
83
+ it("is what the factories run", () => {
84
+ expect(() =>
85
+ createAdminClient({ connectionString: LOCAL, requirePooler: true })
86
+ ).toThrow("points at a local database")
87
+ })
88
+ })
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The slice of a schema `defineAction` needs: a throwing `parse`, plus the
3
+ * Standard Schema `types` marker it reads the caller-facing input type from.
4
+ * Zod 4 schemas satisfy it as they are; so does anything else that implements
5
+ * Standard Schema and exposes `parse`.
6
+ */
7
+ export interface ActionSchema<TInput, TOutput> {
8
+ readonly "~standard": {
9
+ readonly types?:
10
+ | { readonly input: TInput; readonly output: TOutput }
11
+ | undefined
12
+ }
13
+ parse(input: unknown): TOutput
14
+ }
15
+
16
+ /** What a request context must carry for `defineAction` to hand it over. */
17
+ export interface ActionContext<TDb> {
18
+ readonly db: TDb
19
+ }
20
+
21
+ /** An action's body: the RLS-bound `db`, the parsed input, and the context. */
22
+ export type ActionHandler<
23
+ TContext extends ActionContext<unknown>,
24
+ TOutput,
25
+ TResult,
26
+ > = (
27
+ db: TContext["db"],
28
+ input: TOutput,
29
+ context: TContext
30
+ ) => TResult | Promise<TResult>
31
+
32
+ /**
33
+ * `defineAction(schema, handler)` returns the server action itself: an async
34
+ * function that parses its argument, resolves the request context, then calls
35
+ * `handler(db, input, context)`.
36
+ */
37
+ export type DefineAction<TContext extends ActionContext<unknown>> = <
38
+ TInput,
39
+ TOutput,
40
+ TResult,
41
+ >(
42
+ schema: ActionSchema<TInput, TOutput>,
43
+ handler: ActionHandler<TContext, TOutput, TResult>
44
+ ) => (input: TInput) => Promise<TResult>
45
+
46
+ /**
47
+ * Binds `defineAction` to a request-context resolver. Deliberately not a
48
+ * `"use server"` module: it only returns a function, and the app file that
49
+ * exports the result is the one that carries the directive.
50
+ *
51
+ * Input is parsed before the context is resolved, so a malformed call fails
52
+ * without a session round trip, and the handler never sees an unparsed value.
53
+ */
54
+ export function createDefineAction<TContext extends ActionContext<unknown>>(
55
+ getContext: () => Promise<TContext>
56
+ ): DefineAction<TContext> {
57
+ return (schema, handler) => async (input) => {
58
+ const parsed = schema.parse(input)
59
+ const context = await getContext()
60
+
61
+ return await handler(context.db, parsed, context)
62
+ }
63
+ }
@@ -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,32 @@
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 { createRequestDb } from "./next.ts"
6
+
7
+ const { defineAction } = 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
+ })