@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/clients-BlUEH7QJ.d.mts +58 -0
- package/dist/clients-CdPr3mCZ.mjs +197 -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 -42
- package/dist/index.mjs +2 -167
- package/dist/next.d.mts +182 -0
- package/dist/next.mjs +167 -0
- package/dist/query.d.mts +103 -0
- package/dist/query.mjs +115 -0
- package/dist/schema.d.mts +20 -11
- package/dist/schema.mjs +11 -12
- package/package.json +31 -6
- package/src/action-result.test.ts +88 -0
- package/src/action-result.ts +109 -0
- 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 +137 -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 +60 -0
- package/src/next.test.ts +296 -0
- package/src/next.ts +134 -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-zod.test.ts +37 -0
- package/src/schema.test-d.ts +24 -0
- package/src/schema.ts +51 -21
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
|
|
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:
|
|
14
|
-
updatedAt:
|
|
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 =
|
|
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
|
+
"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.
|
|
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
|
-
"
|
|
62
|
-
"@zeno-lib/
|
|
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
|
+
}
|
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
|
+
})
|