@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.
- package/dist/auth-schema.d.mts +543 -0
- package/dist/auth-schema.mjs +50 -0
- package/dist/clients-BlUEH7QJ.d.mts +58 -0
- package/dist/clients-CdPr3mCZ.mjs +197 -0
- package/dist/config.d.mts +20 -0
- package/dist/config.mjs +64 -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 -0
- package/dist/index.mjs +2 -0
- package/dist/next.d.mts +87 -0
- package/dist/next.mjs +69 -0
- package/dist/query.d.mts +103 -0
- package/dist/query.mjs +115 -0
- package/dist/schema.d.mts +158 -0
- package/dist/schema.mjs +219 -0
- package/dist/triggers.d.mts +65 -0
- package/dist/triggers.mjs +93 -0
- package/package.json +29 -5
- 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 +63 -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 +32 -0
- package/src/next.test.ts +178 -0
- package/src/next.ts +112 -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.test-d.ts +24 -0
- package/src/schema.ts +51 -21
|
@@ -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
|
+
"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.
|
|
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.
|
|
61
|
-
"
|
|
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": {
|
package/src/casing.test.ts
CHANGED
|
@@ -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
|
|
755
|
-
"
|
|
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(
|
|
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 "
|
|
789
|
+
'(select "can_select_projects"("projects"."id"))'
|
|
767
790
|
)
|
|
768
791
|
expect(dialect.sqlToQuery(policies[1]?.withCheck as SQL).sql).toBe(
|
|
769
|
-
'(select "
|
|
792
|
+
'(select "can_insert_projects"())'
|
|
770
793
|
)
|
|
771
794
|
expect(dialect.sqlToQuery(policies[2]?.using as SQL).sql).toBe(
|
|
772
|
-
'(select "
|
|
795
|
+
'(select "can_update_projects"())'
|
|
773
796
|
)
|
|
774
797
|
expect(dialect.sqlToQuery(policies[3]?.using as SQL).sql).toBe(
|
|
775
|
-
'(select "
|
|
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
|
|
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(
|
|
806
|
+
const policies = getTableConfig(projects).policies
|
|
784
807
|
|
|
785
808
|
expect(new PgDialect().sqlToQuery(policies[1]?.withCheck as SQL).sql).toBe(
|
|
786
|
-
'(select "
|
|
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
|
|
792
|
-
"
|
|
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(
|
|
823
|
+
const policies = getTableConfig(memberships).policies
|
|
801
824
|
|
|
802
825
|
expect(new PgDialect().sqlToQuery(policies[0]?.using as SQL).sql).toBe(
|
|
803
|
-
'(select "
|
|
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
|
|
809
|
-
const
|
|
810
|
-
functionPolicies(t, { schema: "
|
|
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(
|
|
835
|
+
const policies = getTableConfig(invoices).policies
|
|
813
836
|
|
|
814
837
|
expect(new PgDialect().sqlToQuery(policies[0]?.using as SQL).sql).toBe(
|
|
815
|
-
'(select "
|
|
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
|
|
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
|
+
})
|