@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.
@@ -0,0 +1,261 @@
1
+ import { defineRelations, type SQL } from "drizzle-orm"
2
+ import { PgDialect, text } from "drizzle-orm/pg-core"
3
+ import { drizzle } from "drizzle-orm/postgres-js"
4
+ import { describe, expect, it } from "vitest"
5
+ import {
6
+ definedValues,
7
+ excludedSet,
8
+ functionPermissionTables,
9
+ maybeOne,
10
+ one,
11
+ type QueryExecutor,
12
+ selectFunctionPermissions,
13
+ } from "./query.ts"
14
+ import { primaryId, schema, table, view } from "./schema.ts"
15
+
16
+ // No connection anywhere: `drizzle.mock()` builds statements for `.toSQL()`,
17
+ // and `PgDialect` renders a bare `SQL` the way the driver would receive it.
18
+ const db = drizzle.mock()
19
+ const dialect = new PgDialect()
20
+ const render = (query: SQL) => dialect.sqlToQuery(query)
21
+
22
+ const contacts = table("contacts", {
23
+ displayName: text(),
24
+ email: text(),
25
+ id: primaryId("sequential"),
26
+ })
27
+
28
+ const dealsProperties = table("deals_properties", {
29
+ dealId: text().notNull(),
30
+ propertyId: text().notNull(),
31
+ })
32
+
33
+ describe("one", () => {
34
+ it("returns the only row", () => {
35
+ expect(one([{ id: 1 }])).toEqual({ id: 1 })
36
+ })
37
+
38
+ it("throws on none and on several", () => {
39
+ expect(() => one([])).toThrow("Expected exactly one row, received 0")
40
+ expect(() => one([1, 2])).toThrow("Expected exactly one row, received 2")
41
+ })
42
+ })
43
+
44
+ describe("maybeOne", () => {
45
+ it("returns the row, or undefined for none", () => {
46
+ expect(maybeOne([{ id: 1 }])).toEqual({ id: 1 })
47
+ expect(maybeOne([])).toBeUndefined()
48
+ })
49
+
50
+ it("throws on several", () => {
51
+ expect(() => maybeOne([1, 2])).toThrow(
52
+ "Expected at most one row, received 2"
53
+ )
54
+ })
55
+ })
56
+
57
+ describe("definedValues", () => {
58
+ it("drops undefined keys and keeps null", () => {
59
+ expect(definedValues({ a: 1, b: undefined, c: null })).toEqual({
60
+ a: 1,
61
+ c: null,
62
+ })
63
+ })
64
+ })
65
+
66
+ describe("excludedSet", () => {
67
+ const upsert = (values: Record<string, unknown>, set: Record<string, SQL>) =>
68
+ db
69
+ .insert(contacts)
70
+ .values({ id: 1, ...values })
71
+ .onConflictDoUpdate({ set, target: contacts.id })
72
+ .toSQL().sql
73
+
74
+ it("writes back every supplied column by its database name, never the target", () => {
75
+ const values = { displayName: "Ada", email: "ada@example.com", id: 1 }
76
+
77
+ expect(upsert(values, excludedSet(contacts, values))).toContain(
78
+ 'do update set "display_name" = excluded."display_name", "email" = excluded."email"'
79
+ )
80
+ })
81
+
82
+ it("skips a key carrying undefined", () => {
83
+ const values = { displayName: undefined, email: "ada@example.com" }
84
+ const set = excludedSet(contacts, values)
85
+
86
+ expect(Object.keys(set)).toEqual(["email"])
87
+ })
88
+
89
+ it("falls back to id = excluded.id when nothing else is supplied", () => {
90
+ const set = excludedSet(contacts, { displayName: undefined, id: 1 })
91
+
92
+ expect(upsert({}, set)).toContain('do update set "id" = excluded."id"')
93
+ })
94
+
95
+ it("takes another conflict target", () => {
96
+ const values = { displayName: "Ada", email: "ada@example.com" }
97
+ const set = excludedSet(contacts, values, { target: "email" })
98
+
99
+ expect(Object.keys(set)).toEqual(["displayName"])
100
+ expect(render(one(Object.values(set))).sql).toBe('excluded."display_name"')
101
+ })
102
+
103
+ it("falls back to the first target column that exists", () => {
104
+ const set = excludedSet(
105
+ dealsProperties,
106
+ { dealId: "d", propertyId: "p" },
107
+ { target: ["dealId", "propertyId"] }
108
+ )
109
+
110
+ expect(Object.keys(set)).toEqual(["dealId"])
111
+ expect(render(one(Object.values(set))).sql).toBe('excluded."deal_id"')
112
+ })
113
+
114
+ it("stays empty for a table without the default id target", () => {
115
+ expect(excludedSet(dealsProperties, {})).toEqual({})
116
+ })
117
+ })
118
+
119
+ const billing = schema("billing")
120
+ const invoices = billing.table("invoices", { id: primaryId("sequential") })
121
+ const privateNotes = schema("private").table("notes", {
122
+ id: primaryId("sequential"),
123
+ })
124
+ const contactNames = view("contact_names").as((qb) =>
125
+ qb.select({ displayName: contacts.displayName }).from(contacts)
126
+ )
127
+
128
+ describe("functionPermissionTables", () => {
129
+ const schemaObject = {
130
+ billing,
131
+ contactNames,
132
+ contacts,
133
+ invoices,
134
+ privateNotes,
135
+ relationsHelper: () => undefined,
136
+ }
137
+
138
+ it("includes only public tables by default", () => {
139
+ expect([...functionPermissionTables(schemaObject)]).toEqual([
140
+ ["contacts", "public"],
141
+ ])
142
+ })
143
+
144
+ it("maps each table to its own schema", () => {
145
+ expect(
146
+ new Map(
147
+ functionPermissionTables(schemaObject, {
148
+ schemas: ["public", "billing"],
149
+ })
150
+ )
151
+ ).toEqual(
152
+ new Map([
153
+ ["contacts", "public"],
154
+ ["invoices", "billing"],
155
+ ])
156
+ )
157
+ })
158
+
159
+ it("accepts the result of defineRelations", () => {
160
+ const relations = defineRelations({ contacts, invoices })
161
+
162
+ expect([
163
+ ...functionPermissionTables(relations, { schemas: ["billing"] }),
164
+ ]).toEqual([["invoices", "billing"]])
165
+ })
166
+
167
+ it("rejects a bare name that two included schemas share", () => {
168
+ const publicInvoices = table("invoices", { id: primaryId("sequential") })
169
+
170
+ expect(() =>
171
+ functionPermissionTables(
172
+ { invoices, publicInvoices },
173
+ { schemas: ["public", "billing"] }
174
+ )
175
+ ).toThrow('Table "invoices" exists in both')
176
+ })
177
+ })
178
+
179
+ describe("selectFunctionPermissions", () => {
180
+ const tables = functionPermissionTables(
181
+ { contacts, invoices },
182
+ { schemas: ["public", "billing"] }
183
+ )
184
+
185
+ // Records the statement and answers with a canned row: no database.
186
+ const fakeDb = (row: Record<string, boolean | null>) => {
187
+ const queries: SQL[] = []
188
+ const executor: QueryExecutor = {
189
+ execute: (query) => {
190
+ queries.push(query)
191
+ return Promise.resolve([row])
192
+ },
193
+ }
194
+ return { executor, queries }
195
+ }
196
+
197
+ it("calls all four functions in one statement, id to all but insert", async () => {
198
+ const { executor, queries } = fakeDb({
199
+ delete: false,
200
+ insert: true,
201
+ select: true,
202
+ update: null,
203
+ })
204
+
205
+ const permissions = await selectFunctionPermissions(executor, {
206
+ id: 7,
207
+ table: "invoices",
208
+ tables,
209
+ })
210
+
211
+ expect(permissions).toEqual({
212
+ delete: false,
213
+ insert: true,
214
+ select: true,
215
+ update: false,
216
+ })
217
+ const query = render(one(queries))
218
+ expect(query.sql).toBe(
219
+ 'select "billing"."can_select_invoices"("id" => $1) as "select", "billing"."can_insert_invoices"() as "insert", "billing"."can_update_invoices"("id" => $2) as "update", "billing"."can_delete_invoices"("id" => $3) as "delete"'
220
+ )
221
+ expect(query.params).toEqual([7, 7, 7])
222
+ })
223
+
224
+ it("calls every function bare without an id, honouring prefix and schema", async () => {
225
+ const { executor, queries } = fakeDb({
226
+ delete: true,
227
+ insert: true,
228
+ select: true,
229
+ update: true,
230
+ })
231
+
232
+ await selectFunctionPermissions(executor, {
233
+ functionSchema: "private",
234
+ prefix: "may",
235
+ table: "contacts",
236
+ tables,
237
+ })
238
+
239
+ expect(render(one(queries)).sql).toBe(
240
+ 'select "private"."may_select_contacts"() as "select", "private"."may_insert_contacts"() as "insert", "private"."may_update_contacts"() as "update", "private"."may_delete_contacts"() as "delete"'
241
+ )
242
+ })
243
+
244
+ it("rejects a table outside the allowlist before building anything", async () => {
245
+ const { executor, queries } = fakeDb({})
246
+
247
+ await expect(
248
+ selectFunctionPermissions(executor, {
249
+ table: 'contacts"; drop table contacts; --',
250
+ tables,
251
+ })
252
+ ).rejects.toThrow("No permission functions for table")
253
+ expect(queries).toHaveLength(0)
254
+ })
255
+
256
+ it("accepts a real Drizzle client", () => {
257
+ // Compile-time only: the structural type fits a postgres-js database.
258
+ const executor: QueryExecutor = db
259
+ expect(executor.execute).toBeTypeOf("function")
260
+ })
261
+ })
package/src/query.ts ADDED
@@ -0,0 +1,252 @@
1
+ // Query-side helpers: the PostgREST conveniences Drizzle has no equivalent for
2
+ // (`.single()`, `.maybeSingle()`, a default upsert `set`), plus the read side
3
+ // of `functionPolicies`.
4
+ import { getColumns, is, type SQL, sql } from "drizzle-orm"
5
+ import { getTableConfig, PgTable } from "drizzle-orm/pg-core"
6
+ import {
7
+ DEFAULT_FUNCTION_PREFIX,
8
+ FUNCTION_POLICY_OPERATIONS,
9
+ type FunctionPolicyOperation,
10
+ functionPolicyName,
11
+ } from "./function-names.ts"
12
+
13
+ /**
14
+ * PostgREST's `.single()`: exactly one row, or an error.
15
+ *
16
+ * `findFirst()` returns `undefined` for no rows and silently drops the rest
17
+ * when there are several, and `.returning()` hands back an array.
18
+ */
19
+ export const one = <T>(rows: readonly T[]): T => {
20
+ if (rows.length !== 1) {
21
+ throw new Error(`Expected exactly one row, received ${rows.length}`)
22
+ }
23
+
24
+ return rows[0] as T
25
+ }
26
+
27
+ /**
28
+ * PostgREST's `.maybeSingle()`: one row or none, erroring on more than one.
29
+ * Returns `undefined` rather than `null`, which is what `findFirst()` does.
30
+ */
31
+ export const maybeOne = <T>(rows: readonly T[]): T | undefined => {
32
+ if (rows.length > 1) {
33
+ throw new Error(`Expected at most one row, received ${rows.length}`)
34
+ }
35
+
36
+ return rows[0]
37
+ }
38
+
39
+ type ColumnKey<TTable extends PgTable> = Extract<
40
+ keyof TTable["_"]["columns"],
41
+ string
42
+ >
43
+
44
+ export type ExcludedSetOptions<TTable extends PgTable> = {
45
+ /**
46
+ * The conflict target's column keys, never written back from `excluded`.
47
+ * The first one that exists is also the empty-set fallback. Default `"id"`.
48
+ */
49
+ target?: ColumnKey<TTable> | readonly ColumnKey<TTable>[]
50
+ }
51
+
52
+ /**
53
+ * The `set` for `onConflictDoUpdate` that reproduces PostgREST's
54
+ * `.upsert(row, { onConflict })`: on conflict, every column the caller
55
+ * supplied is overwritten from the proposed row (`col = excluded.col`).
56
+ *
57
+ * - A key carrying `undefined` is skipped. Drizzle omits an undefined column
58
+ * from the INSERT, so `excluded.x` would be the column default, and on a
59
+ * conflict `x = excluded.x` would overwrite a stored value with it.
60
+ * - The conflict target (`id` unless `target` says otherwise) is excluded.
61
+ * - An empty result falls back to `<target> = excluded.<target>`. Drizzle
62
+ * throws `No values to set` while *building* the statement, so a payload
63
+ * holding nothing but the key would fail even on the plain insert path.
64
+ * Writing the key back to itself is a no-op that keeps `.returning()`
65
+ * yielding the row, which `onConflictDoNothing` does not.
66
+ *
67
+ * With no target column in the table (a junction keyed only by its foreign
68
+ * keys, with the default target) the fallback cannot apply and the result
69
+ * stays empty: such a table wants `onConflictDoNothing` anyway.
70
+ *
71
+ * Column names come from the schema, never from `values`.
72
+ */
73
+ export const excludedSet = <TTable extends PgTable>(
74
+ table: TTable,
75
+ values: Record<string, unknown>,
76
+ { target = "id" as ColumnKey<TTable> }: ExcludedSetOptions<TTable> = {}
77
+ ): Record<string, SQL> => {
78
+ const columns: Record<string, { name: string }> = getColumns(table)
79
+ const targets: readonly string[] =
80
+ typeof target === "string" ? [target] : target
81
+ const set: Record<string, SQL> = {}
82
+
83
+ for (const [key, column] of Object.entries(columns)) {
84
+ if (key in values && values[key] !== undefined && !targets.includes(key)) {
85
+ set[key] = sql`excluded.${sql.identifier(column.name)}`
86
+ }
87
+ }
88
+
89
+ if (Object.keys(set).length > 0) {
90
+ return set
91
+ }
92
+
93
+ const fallback = targets.find((key) => key in columns)
94
+ const fallbackColumn = fallback === undefined ? undefined : columns[fallback]
95
+
96
+ if (fallback !== undefined && fallbackColumn) {
97
+ set[fallback] = sql`excluded.${sql.identifier(fallbackColumn.name)}`
98
+ }
99
+
100
+ return set
101
+ }
102
+
103
+ /**
104
+ * The same values with every `undefined` key dropped, so an insert and its
105
+ * `excludedSet` agree on which columns were supplied.
106
+ */
107
+ export const definedValues = <T extends Record<string, unknown>>(
108
+ values: T
109
+ ): T =>
110
+ Object.fromEntries(
111
+ Object.entries(values).filter(([, value]) => value !== undefined)
112
+ ) as T
113
+
114
+ /** Table name to the schema its `<prefix>_<operation>_<table>` functions live in. */
115
+ export type FunctionPermissionTables = ReadonlyMap<string, string>
116
+
117
+ export type FunctionPermissionTablesOptions = {
118
+ /** Schemas whose tables are included. Default `["public"]`. */
119
+ schemas?: readonly string[]
120
+ }
121
+
122
+ const DEFAULT_SCHEMA = "public"
123
+
124
+ // A schema module exports tables directly; `defineRelations` wraps each in
125
+ // `{ table, name, relations }`. Both are accepted so either object works.
126
+ const tableOf = (value: unknown): PgTable | undefined => {
127
+ if (is(value, PgTable)) {
128
+ return value
129
+ }
130
+ if (typeof value === "object" && value !== null && "table" in value) {
131
+ const { table } = value
132
+ return is(table, PgTable) ? table : undefined
133
+ }
134
+ return
135
+ }
136
+
137
+ /**
138
+ * The allowlist `selectFunctionPermissions` resolves a table name against,
139
+ * built from a Drizzle schema object (`import * as schema`) or the result of
140
+ * `defineRelations`. Build it once at module scope. Views and non-table
141
+ * exports are ignored; a table name that appears in two included schemas
142
+ * throws, since a bare name could not tell them apart.
143
+ */
144
+ export const functionPermissionTables = (
145
+ schemaObject: Record<string, unknown>,
146
+ { schemas = [DEFAULT_SCHEMA] }: FunctionPermissionTablesOptions = {}
147
+ ): FunctionPermissionTables => {
148
+ const tables = new Map<string, string>()
149
+
150
+ for (const value of Object.values(schemaObject)) {
151
+ const table = tableOf(value)
152
+ if (!table) {
153
+ continue
154
+ }
155
+ const { name, schema = DEFAULT_SCHEMA } = getTableConfig(table)
156
+ if (!schemas.includes(schema)) {
157
+ continue
158
+ }
159
+ const existing = tables.get(name)
160
+ if (existing !== undefined && existing !== schema) {
161
+ throw new Error(
162
+ `Table "${name}" exists in both "${existing}" and "${schema}"; include only one of those schemas`
163
+ )
164
+ }
165
+ tables.set(name, schema)
166
+ }
167
+
168
+ return tables
169
+ }
170
+
171
+ export type FunctionPermissions = Record<FunctionPolicyOperation, boolean>
172
+
173
+ /**
174
+ * Anything with Drizzle's `execute` returning rows: a postgres-js client from
175
+ * `@zeno-lib/db`, a `PgAsyncDatabase`, or a transaction.
176
+ */
177
+ export type QueryExecutor = {
178
+ execute: (query: SQL) => PromiseLike<readonly Record<string, unknown>[]>
179
+ }
180
+
181
+ export type SelectFunctionPermissionsOptions = {
182
+ /** From `functionPermissionTables`. */
183
+ tables: FunctionPermissionTables
184
+ /** The table asked about, checked against `tables`. */
185
+ table: string
186
+ /**
187
+ * The row, passed to every function but `insert` (there is no row yet) as
188
+ * a named argument. Omit to call all four with none.
189
+ */
190
+ id?: number | string
191
+ /** Name of the functions' row parameter. Default `"id"`. */
192
+ argumentName?: string
193
+ /** Function prefix, as given to `functionPolicies`. Default `"can"`. */
194
+ prefix?: string
195
+ /** Schema the functions live in. Default: the table's own. */
196
+ functionSchema?: string
197
+ }
198
+
199
+ /**
200
+ * The four `<prefix>_<operation>_<table>()` answers for one table, in one
201
+ * statement: the read side of `functionPolicies`, for showing or hiding UI.
202
+ *
203
+ * `table` is only ever a map key: every identifier that reaches the statement
204
+ * comes from `tables` and is quoted with `sql.identifier`. A function missing
205
+ * or with another signature raises `42883` (`SqlState.undefinedFunction`).
206
+ * A `null` answer, which a function gives for a row the caller cannot see,
207
+ * counts as `false`, as does anything else that is not `true`.
208
+ */
209
+ export const selectFunctionPermissions = async (
210
+ db: QueryExecutor,
211
+ {
212
+ argumentName = "id",
213
+ functionSchema,
214
+ id,
215
+ prefix = DEFAULT_FUNCTION_PREFIX,
216
+ table,
217
+ tables,
218
+ }: SelectFunctionPermissionsOptions
219
+ ): Promise<FunctionPermissions> => {
220
+ const tableSchema = tables.get(table)
221
+
222
+ if (tableSchema === undefined) {
223
+ throw new Error(`No permission functions for table "${table}"`)
224
+ }
225
+
226
+ const schema = functionSchema ?? tableSchema
227
+
228
+ const call = (operation: FunctionPolicyOperation) => {
229
+ const callee = sql`${sql.identifier(schema)}.${sql.identifier(functionPolicyName(prefix, operation, table))}`
230
+ return operation === "insert" || id === undefined
231
+ ? sql`${callee}()`
232
+ : sql`${callee}(${sql.identifier(argumentName)} => ${id})`
233
+ }
234
+
235
+ const row = one(
236
+ await db.execute(
237
+ sql`select ${sql.join(
238
+ FUNCTION_POLICY_OPERATIONS.map(
239
+ (operation) => sql`${call(operation)} as ${sql.identifier(operation)}`
240
+ ),
241
+ sql`, `
242
+ )}`
243
+ )
244
+ )
245
+
246
+ return {
247
+ delete: row.delete === true,
248
+ insert: row.insert === true,
249
+ select: row.select === true,
250
+ update: row.update === true,
251
+ }
252
+ }
@@ -0,0 +1,37 @@
1
+ import { defineTableSchema } from "@zeno-lib/schema"
2
+ import { text } from "drizzle-orm/pg-core"
3
+ import { describe, expect, it } from "vitest"
4
+
5
+ import { auditColumns, sequentialPrimaryId, table } from "./schema.ts"
6
+
7
+ // `@zeno-lib/schema` cannot import this package, so it recognises the audit
8
+ // columns by the keys these helpers emit. This pins that contract.
9
+ describe("schema helpers under defineTableSchema", () => {
10
+ const notes = table("notes", {
11
+ ...auditColumns(),
12
+ body: text().notNull(),
13
+ id: sequentialPrimaryId(),
14
+ })
15
+ const schemas = defineTableSchema(notes)
16
+
17
+ it("keeps every audit column out of insert and update", () => {
18
+ const forged = {
19
+ body: "Hello",
20
+ createdAt: new Date(),
21
+ createdBy: "00000000-0000-4000-8000-000000000000",
22
+ updatedAt: new Date(),
23
+ updatedBy: "00000000-0000-4000-8000-000000000000",
24
+ }
25
+
26
+ expect(schemas.insert.parse(forged)).toEqual({ body: "Hello" })
27
+ expect(schemas.update.parse(forged)).toEqual({ body: "Hello" })
28
+ })
29
+
30
+ it("accepts only positive sequential ids", () => {
31
+ expect(schemas.insert.safeParse({ body: "x", id: 0 }).success).toBe(false)
32
+ expect(schemas.insert.parse({ body: "x", id: 1 })).toEqual({
33
+ body: "x",
34
+ id: 1,
35
+ })
36
+ })
37
+ })
@@ -7,6 +7,7 @@ import {
7
7
  primaryId,
8
8
  sequentialPrimaryId,
9
9
  table,
10
+ timestamps,
10
11
  userId,
11
12
  uuidPrimaryId,
12
13
  } from "./schema.ts"
@@ -119,3 +120,26 @@ test("reference actions are rejected when there is no reference", () => {
119
120
  // @ts-expect-error actions are meaningless without a foreign key
120
121
  authUserId({ actions: { onDelete: "cascade" }, reference: null })
121
122
  })
123
+
124
+ test("timestamps reads a Date by default and a string with mode: string", () => {
125
+ const dated = table("dated", { ...timestamps() })
126
+ const stringly = table("stringly", { ...timestamps({ mode: "string" }) })
127
+ const audited = table("audited_strings", {
128
+ ...auditColumns({ mode: "string" }),
129
+ })
130
+
131
+ expectTypeOf<
132
+ (typeof dated)["$inferSelect"]["createdAt"]
133
+ >().toEqualTypeOf<Date>()
134
+ expectTypeOf<
135
+ (typeof stringly)["$inferSelect"]["updatedAt"]
136
+ >().toEqualTypeOf<string>()
137
+ expectTypeOf<
138
+ (typeof audited)["$inferSelect"]["createdAt"]
139
+ >().toEqualTypeOf<string>()
140
+ // Both still have a default, so neither is required on insert.
141
+ expectTypeOf<(typeof stringly)["$inferInsert"]>().toEqualTypeOf<{
142
+ createdAt?: string | undefined
143
+ updatedAt?: string | undefined
144
+ }>()
145
+ })