@zeno-lib/db 0.3.1 → 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.
@@ -0,0 +1,93 @@
1
+ import { getTableName, is } from "drizzle-orm";
2
+ import { PgTable, getTableConfig } from "drizzle-orm/pg-core";
3
+ //#region src/triggers.ts
4
+ const DEFAULT_EXTENSION_SCHEMA = "extensions";
5
+ const DEFAULT_COLUMN = "updated_at";
6
+ const DEFAULT_AUTHOR_COLUMN = "updated_by";
7
+ const DEFAULT_TABLE_SCHEMA = "public";
8
+ const quote = (identifier) => `"${identifier.split("\"").join("\"\"")}"`;
9
+ function resolveTable(table, schema) {
10
+ const name = is(table, PgTable) ? getTableName(table) : table;
11
+ const tableSchema = schema ?? (is(table, PgTable) ? getTableConfig(table).schema ?? DEFAULT_TABLE_SCHEMA : DEFAULT_TABLE_SCHEMA);
12
+ return { qualified: `${quote(tableSchema)}.${quote(name)}` };
13
+ }
14
+ /**
15
+ * `CREATE TRIGGER` SQL that keeps `updated_at` current for **every** writer,
16
+ * including PostgREST, the dashboard and psql.
17
+ *
18
+ * `timestamps()` gives the column a `DEFAULT now()`, which covers the insert.
19
+ * SQL has no "on update" default, so without this trigger the column is only
20
+ * ever the insert time. Required, not optional.
21
+ *
22
+ * ```ts
23
+ * // pnpm db:generate --custom --name=posts_audit_triggers
24
+ * // then paste the output into the generated migration:
25
+ * console.log(updatedAtTrigger(posts))
26
+ * ```
27
+ */
28
+ function updatedAtTrigger(table, { column = DEFAULT_COLUMN, createExtension = true, extensionSchema = DEFAULT_EXTENSION_SCHEMA, name, schema } = {}) {
29
+ const { qualified } = resolveTable(table, schema);
30
+ const triggerName = name ?? `handle_${column}`;
31
+ const trigger = [
32
+ `drop trigger if exists ${quote(triggerName)} on ${qualified};`,
33
+ `create trigger ${quote(triggerName)}`,
34
+ ` before update on ${qualified}`,
35
+ " for each row",
36
+ ` execute function ${quote(extensionSchema)}.moddatetime (${quote(column)});`
37
+ ].join("\n");
38
+ return createExtension ? `${moddatetimeExtension(extensionSchema)}\n\n${trigger}` : trigger;
39
+ }
40
+ /** `create extension if not exists moddatetime`, safe to repeat per migration. */
41
+ function moddatetimeExtension(schema = DEFAULT_EXTENSION_SCHEMA) {
42
+ return `create extension if not exists moddatetime with schema ${quote(schema)};`;
43
+ }
44
+ /**
45
+ * `CREATE TRIGGER` SQL that stamps an author column with `auth.uid()` on every
46
+ * update, the `updated_at` story for `updated_by`.
47
+ *
48
+ * `updatedBy()` gives the column a `DEFAULT (select auth.uid())`, which covers
49
+ * the insert. This covers the update, for every writer rather than only the
50
+ * ones going through Drizzle.
51
+ *
52
+ * A write with no session (the admin or `service_role` clients) records `NULL`,
53
+ * because that is what `auth.uid()` returns there.
54
+ */
55
+ function updatedByTrigger(table, { column = DEFAULT_AUTHOR_COLUMN, createFunction = true, functionSchema = DEFAULT_TABLE_SCHEMA, name, schema } = {}) {
56
+ const { qualified } = resolveTable(table, schema);
57
+ const triggerName = name ?? `handle_${column}`;
58
+ const functionName = `set_${column}`;
59
+ const qualifiedFunction = `${quote(functionSchema)}.${quote(functionName)}`;
60
+ const routine = [
61
+ `create or replace function ${qualifiedFunction}()`,
62
+ " returns trigger",
63
+ " language plpgsql",
64
+ " security invoker",
65
+ ` set search_path = ''`,
66
+ "as $$",
67
+ "begin",
68
+ ` new.${quote(column)} = (select auth.uid());`,
69
+ " return new;",
70
+ "end;",
71
+ "$$;"
72
+ ].join("\n");
73
+ const trigger = [
74
+ `drop trigger if exists ${quote(triggerName)} on ${qualified};`,
75
+ `create trigger ${quote(triggerName)}`,
76
+ ` before update on ${qualified}`,
77
+ " for each row",
78
+ ` execute function ${qualifiedFunction}();`
79
+ ].join("\n");
80
+ return createFunction ? `${routine}\n\n${trigger}` : trigger;
81
+ }
82
+ /**
83
+ * Both update triggers `auditColumns()` needs, in one string.
84
+ *
85
+ * `auditColumns()` and `timestamps()` leave `updated_at` and `updated_by` at
86
+ * their insert values without these, so a table using either helper wants this
87
+ * in a `drizzle-kit generate --custom` migration.
88
+ */
89
+ function auditTriggers(table, options = {}) {
90
+ return `${updatedAtTrigger(table, options)}\n\n${updatedByTrigger(table, options)}`;
91
+ }
92
+ //#endregion
93
+ export { auditTriggers, moddatetimeExtension, updatedAtTrigger, updatedByTrigger };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeno-lib/db",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -18,6 +18,21 @@
18
18
  "import": "./dist/config.mjs",
19
19
  "default": "./dist/config.mjs"
20
20
  },
21
+ "./errors": {
22
+ "types": "./dist/errors.d.mts",
23
+ "import": "./dist/errors.mjs",
24
+ "default": "./dist/errors.mjs"
25
+ },
26
+ "./next": {
27
+ "types": "./dist/next.d.mts",
28
+ "import": "./dist/next.mjs",
29
+ "default": "./dist/next.mjs"
30
+ },
31
+ "./query": {
32
+ "types": "./dist/query.d.mts",
33
+ "import": "./dist/query.mjs",
34
+ "default": "./dist/query.mjs"
35
+ },
21
36
  "./schema": {
22
37
  "types": "./dist/schema.d.mts",
23
38
  "import": "./dist/schema.mjs",
@@ -44,21 +59,30 @@
44
59
  "@supabase/supabase-js": ">=2",
45
60
  "drizzle-kit": "1.0.0-rc.3",
46
61
  "drizzle-orm": "1.0.0-rc.3",
47
- "postgres": ">=3.4"
62
+ "postgres": ">=3.4",
63
+ "react": ">=19"
64
+ },
65
+ "peerDependenciesMeta": {
66
+ "react": {
67
+ "optional": true
68
+ }
48
69
  },
49
70
  "devDependencies": {
50
- "@supabase/supabase-js": "2.106.1",
71
+ "@supabase/supabase-js": "2.116.0",
51
72
  "@types/node": "24.10.0",
73
+ "@types/react": "19.2.14",
52
74
  "dotenv": "17.4.2",
53
75
  "drizzle-kit": "1.0.0-rc.3",
54
76
  "drizzle-orm": "1.0.0-rc.3",
55
77
  "postgres": "3.4.7",
78
+ "react": "^19.2.5",
56
79
  "supabase": "2.84.1",
57
80
  "tsdown": "^0.22.14",
58
81
  "typescript": "7.0.2",
59
82
  "vite": "8.0.16",
60
- "vitest": "4.1.0",
61
- "@zeno-lib/test": "0.0.3",
83
+ "vitest": "4.1.11",
84
+ "zod": "^4.4.3",
85
+ "@zeno-lib/test": "0.1.0",
62
86
  "@zeno-lib/typescript": "^1.1.0"
63
87
  },
64
88
  "scripts": {
@@ -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
+ })