@zeno-lib/db 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/schema.mjs CHANGED
@@ -1,17 +1,22 @@
1
1
  import { authUsers } from "./auth-schema.mjs";
2
+ import { n as functionPolicyName, t as FUNCTION_POLICY_OPERATIONS } from "./function-names-BDc-AuFm.mjs";
2
3
  import { Column, getColumnTable, getTableName, is, sql } from "drizzle-orm";
3
4
  import { bigint, integer, isPgEnum as isEnum, isPgMaterializedView as isMaterializedView, isPgSchema as isSchema, isPgSequence as isSequence, isPgView as isView, pgEnum as enum$1, pgMaterializedView as materializedView, pgPolicy, pgPolicy as policy, pgRole as role, pgSequence as sequence, pgTableCreator as tableCreator, pgView as view, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
4
5
  import { snakeCase } from "drizzle-orm/pg-core/casing";
5
6
  import { anonRole, authUid, authUid as authUid$1, authenticatedRole, authenticatedRole as authenticatedRole$1, postgresRole, realtimeMessages, realtimeTopic, serviceRole, supabaseAuthAdminRole } from "drizzle-orm/supabase";
6
7
  //#region src/schema.ts
7
- const timestamps = ({ precision, withTimezone = true } = {}) => {
8
- const config = {
8
+ const timestamps = ({ mode, precision, withTimezone = true } = {}) => {
9
+ const column = (name) => mode === "string" ? timestamp(name, {
10
+ mode: "string",
9
11
  precision,
10
12
  withTimezone
11
- };
13
+ }).notNull().defaultNow() : timestamp(name, {
14
+ precision,
15
+ withTimezone
16
+ }).notNull().defaultNow();
12
17
  return {
13
- createdAt: timestamp("created_at", config).notNull().defaultNow(),
14
- updatedAt: timestamp("updated_at", config).notNull().defaultNow()
18
+ createdAt: column("created_at"),
19
+ updatedAt: column("updated_at")
15
20
  };
16
21
  };
17
22
  const NULLABLE_AUTHOR_ACTIONS = {
@@ -118,12 +123,6 @@ const POLICY_BUILDERS = {
118
123
  select: selectPolicy,
119
124
  update: updatePolicy
120
125
  };
121
- const FUNCTION_POLICY_OPERATIONS = [
122
- "select",
123
- "insert",
124
- "update",
125
- "delete"
126
- ];
127
126
  const isWholeSetArgument = (argument) => is(argument, Column) || Array.isArray(argument);
128
127
  const argumentsFor = (argument, operation) => {
129
128
  if (!argument) return [];
@@ -163,7 +162,7 @@ const functionPolicies = (columns, { argument, name, prefix = "can", schema: fun
163
162
  if (!firstColumn) throw new Error("functionPolicies needs a table with at least one column");
164
163
  const tableName = getTableName(getColumnTable(firstColumn));
165
164
  return FUNCTION_POLICY_OPERATIONS.map((operation) => {
166
- const functionName = `${prefix}_${operation}_${tableName}`;
165
+ const functionName = functionPolicyName(prefix, operation, tableName);
167
166
  const callee = functionSchema ? sql`${sql.identifier(functionSchema)}.${sql.identifier(functionName)}` : sql`${sql.identifier(functionName)}`;
168
167
  const condition = sql`(select ${callee}(${sql.join(argumentsFor(argument, operation), sql`, `)}))`;
169
168
  const clause = { [POLICY_CLAUSE[operation]]: condition };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeno-lib/db",
3
- "version": "0.3.2",
3
+ "version": "0.5.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,22 +59,32 @@
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",
62
- "@zeno-lib/typescript": "^1.1.0"
83
+ "vitest": "4.1.11",
84
+ "zod": "^4.4.3",
85
+ "@zeno-lib/schema": "0.2.2",
86
+ "@zeno-lib/typescript": "^1.1.0",
87
+ "@zeno-lib/test": "0.1.0"
63
88
  },
64
89
  "scripts": {
65
90
  "build": "tsdown",
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { z } from "zod"
3
+ import {
4
+ FieldValidationError,
5
+ toActionError,
6
+ toFieldName,
7
+ } from "./action-result.ts"
8
+
9
+ describe("toFieldName", () => {
10
+ it("joins keys with dots and brackets array indices", () => {
11
+ expect(toFieldName(["owners", 0, "percentage"])).toBe(
12
+ "owners[0].percentage"
13
+ )
14
+ expect(toFieldName(["address", "city"])).toBe("address.city")
15
+ expect(toFieldName(["matrix", 1, 2])).toBe("matrix[1][2]")
16
+ expect(toFieldName([0, "name"])).toBe("[0].name")
17
+ })
18
+
19
+ it("reads Standard Schema path segment objects", () => {
20
+ expect(toFieldName([{ key: "owners" }, { key: 3 }])).toBe("owners[3]")
21
+ })
22
+
23
+ it("returns an empty name for a missing or empty path", () => {
24
+ expect(toFieldName(undefined)).toBe("")
25
+ expect(toFieldName([])).toBe("")
26
+ })
27
+ })
28
+
29
+ describe("toActionError", () => {
30
+ it("groups a Zod failure by field name and keeps path-less issues form-level", () => {
31
+ const schema = z
32
+ .object({
33
+ address: z.object({ city: z.string().min(1, "City required") }),
34
+ owners: z.array(
35
+ z.object({ percentage: z.number().max(100, "At most 100") })
36
+ ),
37
+ })
38
+ .refine(() => false, "Rejected as a whole")
39
+ const result = schema.safeParse({
40
+ address: { city: "" },
41
+ owners: [{ percentage: 10 }, { percentage: 120 }],
42
+ })
43
+ if (result.success) {
44
+ throw new Error("expected a failure")
45
+ }
46
+
47
+ expect(toActionError(result.error.issues)).toEqual({
48
+ fieldErrors: {
49
+ "address.city": ["City required"],
50
+ "owners[1].percentage": ["At most 100"],
51
+ },
52
+ formErrors: ["Rejected as a whole"],
53
+ })
54
+ })
55
+
56
+ it("collects several messages for one field in order", () => {
57
+ expect(
58
+ toActionError([
59
+ { message: "Too short", path: ["password"] },
60
+ { message: "Needs a digit", path: ["password"] },
61
+ { message: "Try again later" },
62
+ ])
63
+ ).toEqual({
64
+ fieldErrors: { password: ["Too short", "Needs a digit"] },
65
+ formErrors: ["Try again later"],
66
+ })
67
+ })
68
+ })
69
+
70
+ describe("FieldValidationError", () => {
71
+ it("normalises single messages to arrays", () => {
72
+ const error = new FieldValidationError(
73
+ { email: "Taken", "owners[0].name": ["A", "B"] },
74
+ { formErrors: "Locked" }
75
+ )
76
+
77
+ expect(error).toBeInstanceOf(Error)
78
+ expect(error.name).toBe("FieldValidationError")
79
+ expect(error.toActionError()).toEqual({
80
+ fieldErrors: { email: ["Taken"], "owners[0].name": ["A", "B"] },
81
+ formErrors: ["Locked"],
82
+ })
83
+ })
84
+
85
+ it("defaults to no form errors", () => {
86
+ expect(new FieldValidationError({ email: "Taken" }).formErrors).toEqual([])
87
+ })
88
+ })
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The serializable validation failure a form action returns instead of
3
+ * throwing. Next.js redacts a thrown error's message in production, so a
4
+ * `ZodError` thrown by a server action reaches the browser as an opaque
5
+ * failure; a returned value survives the round trip intact.
6
+ *
7
+ * `fieldErrors` is keyed by TanStack Form field name (`address.city`,
8
+ * `owners[0].percentage`); `formErrors` holds the issues with no path.
9
+ * `@zeno-lib/forms` declares the same shape on its side and matches it
10
+ * structurally, so neither package depends on the other.
11
+ */
12
+ export interface ActionError {
13
+ readonly fieldErrors: Record<string, string[]>
14
+ readonly formErrors: string[]
15
+ }
16
+
17
+ /** What a form action resolves to: the handler's result, or the failure. */
18
+ export type ActionResult<TData> =
19
+ | { readonly ok: true; readonly data: TData }
20
+ | { readonly ok: false; readonly error: ActionError }
21
+
22
+ /** A Standard Schema issue, reduced to what the mapping reads. */
23
+ export interface ActionIssue {
24
+ readonly message: string
25
+ readonly path?:
26
+ | ReadonlyArray<PropertyKey | { readonly key: PropertyKey }>
27
+ | undefined
28
+ }
29
+
30
+ type FieldMessages = string | readonly string[]
31
+
32
+ function toMessages(messages: FieldMessages): string[] {
33
+ return typeof messages === "string" ? [messages] : [...messages]
34
+ }
35
+
36
+ /**
37
+ * Thrown from a `defineFormAction` handler to reject the input for a reason
38
+ * only the server can see (a uniqueness conflict, a stale reference). The
39
+ * action catches it and returns `{ ok: false, error }` in the same shape a
40
+ * schema failure produces. Keys are TanStack Form field names.
41
+ *
42
+ * Thrown inside `db.transaction(...)`, it rolls the transaction back like any
43
+ * other error before the action converts it. From a plain `defineAction`
44
+ * handler it is an ordinary error and stays thrown.
45
+ */
46
+ export class FieldValidationError extends Error {
47
+ readonly fieldErrors: Record<string, string[]>
48
+ readonly formErrors: string[]
49
+
50
+ constructor(
51
+ fieldErrors: Readonly<Record<string, FieldMessages>>,
52
+ options: { formErrors?: FieldMessages; message?: string } = {}
53
+ ) {
54
+ super(options.message ?? "Validation failed")
55
+ this.name = "FieldValidationError"
56
+ this.fieldErrors = Object.fromEntries(
57
+ Object.entries(fieldErrors).map(([name, messages]) => [
58
+ name,
59
+ toMessages(messages),
60
+ ])
61
+ )
62
+ this.formErrors =
63
+ options.formErrors === undefined ? [] : toMessages(options.formErrors)
64
+ }
65
+
66
+ /** The error as the returned failure shape. */
67
+ toActionError(): ActionError {
68
+ return { fieldErrors: this.fieldErrors, formErrors: this.formErrors }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Formats an issue path the way TanStack Form names fields: object keys joined
74
+ * with `.`, array indices in brackets (`["owners", 0, "percentage"]` becomes
75
+ * `owners[0].percentage`). An empty path yields `""`.
76
+ */
77
+ export function toFieldName(path: ActionIssue["path"]): string {
78
+ let name = ""
79
+ for (const segment of path ?? []) {
80
+ const key = typeof segment === "object" ? segment.key : segment
81
+ if (typeof key === "number") {
82
+ name += `[${key}]`
83
+ } else {
84
+ const part = typeof key === "symbol" ? (key.description ?? "") : key
85
+ name += name === "" ? part : `.${part}`
86
+ }
87
+ }
88
+ return name
89
+ }
90
+
91
+ /**
92
+ * Groups Standard Schema issues into an `ActionError`: each issue lands under
93
+ * its field name, in order, and an issue without a path goes to `formErrors`.
94
+ */
95
+ export function toActionError(issues: readonly ActionIssue[]): ActionError {
96
+ const fieldErrors: Record<string, string[]> = {}
97
+ const formErrors: string[] = []
98
+ for (const issue of issues) {
99
+ const name = toFieldName(issue.path)
100
+ if (name === "") {
101
+ formErrors.push(issue.message)
102
+ } else {
103
+ const messages = fieldErrors[name] ?? []
104
+ messages.push(issue.message)
105
+ fieldErrors[name] = messages
106
+ }
107
+ }
108
+ return { fieldErrors, formErrors }
109
+ }
@@ -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
+ })