@zeno-lib/db 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -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
+ })
package/src/schema.ts CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  type PgBigInt64Builder,
11
11
  type PgIntegerBuilder,
12
12
  type PgPolicyConfig,
13
+ type PgTimestampBuilder,
14
+ type PgTimestampStringBuilder,
13
15
  type PgUUIDBuilder,
14
16
  type Precision,
15
17
  pgPolicy,
@@ -24,6 +26,12 @@ import {
24
26
  import { snakeCase } from "drizzle-orm/pg-core/casing"
25
27
  import { authenticatedRole, authUid } from "drizzle-orm/supabase"
26
28
  import { authUsers } from "./auth-schema.ts"
29
+ import {
30
+ DEFAULT_FUNCTION_PREFIX,
31
+ FUNCTION_POLICY_OPERATIONS,
32
+ type FunctionPolicyOperation,
33
+ functionPolicyName,
34
+ } from "./function-names.ts"
27
35
 
28
36
  // pg-core primitives without the `pg` prefix they repeat at every call site.
29
37
  // `table` is missing on purpose. Zeno's own is at the bottom of this file.
@@ -56,12 +64,34 @@ export {
56
64
  supabaseAuthAdminRole,
57
65
  } from "drizzle-orm/supabase"
58
66
 
59
- type TimestampsOptions = {
67
+ /** How a timestamp column is read back: a `Date` (the default) or Postgres's own text. */
68
+ export type TimestampMode = "date" | "string"
69
+
70
+ export type TimestampsOptions<TMode extends TimestampMode = "date"> = {
71
+ /**
72
+ * `"date"` (the default) reads a `Date`; `"string"` keeps Postgres's text
73
+ * form, which also keeps the microseconds a `Date` truncates. A JavaScript
74
+ * mapping only: it emits no SQL.
75
+ */
76
+ mode?: TMode
60
77
  withTimezone?: boolean
61
78
  /** Fractional-second digits. Postgres allows 0 to 6. */
62
79
  precision?: Precision
63
80
  }
64
81
 
82
+ // Mirrors drizzle's own `timestamp()` overload: only an exact `"string"` gives
83
+ // the string builder.
84
+ type TimestampColumn<TMode extends TimestampMode> = SetHasDefault<
85
+ SetNotNull<
86
+ [TMode] extends ["string"] ? PgTimestampStringBuilder : PgTimestampBuilder
87
+ >
88
+ >
89
+
90
+ export type TimestampColumns<TMode extends TimestampMode = "date"> = {
91
+ createdAt: TimestampColumn<TMode>
92
+ updatedAt: TimestampColumn<TMode>
93
+ }
94
+
65
95
  // `created_at` is a column DEFAULT, so Postgres fills it for every writer.
66
96
  // `updated_at` has no equivalent: SQL has no "on update" default, and Drizzle's
67
97
  // `$onUpdateFn` is applied while Drizzle builds its own statement, so a write
@@ -74,16 +104,22 @@ type TimestampsOptions = {
74
104
  // than a shared object, because Drizzle's builder methods mutate `this` and
75
105
  // return it. One builder in two tables would leak `.notNull()`, `.references()`
76
106
  // and its name from whichever table customised it first.
77
- export const timestamps = ({
107
+ export const timestamps = <TMode extends TimestampMode = "date">({
108
+ mode,
78
109
  precision,
79
110
  withTimezone = true,
80
- }: TimestampsOptions = {}) => {
81
- const config = { precision, withTimezone }
111
+ }: TimestampsOptions<TMode> = {}): TimestampColumns<TMode> => {
112
+ const column = (name: string) =>
113
+ mode === "string"
114
+ ? timestamp(name, { mode: "string", precision, withTimezone })
115
+ .notNull()
116
+ .defaultNow()
117
+ : timestamp(name, { precision, withTimezone }).notNull().defaultNow()
82
118
 
83
119
  return {
84
- createdAt: timestamp("created_at", config).notNull().defaultNow(),
85
- updatedAt: timestamp("updated_at", config).notNull().defaultNow(),
86
- }
120
+ createdAt: column("created_at"),
121
+ updatedAt: column("updated_at"),
122
+ } as TimestampColumns<TMode>
87
123
  }
88
124
 
89
125
  type ReferenceActions = ReferenceConfig["config"]
@@ -208,10 +244,13 @@ export const authorship = <TNotNull extends boolean = false>(
208
244
  })
209
245
 
210
246
  // Takes both halves' options, since it builds both halves.
211
- export const auditColumns = <TNotNull extends boolean = false>(
212
- options: AuthorshipOptions<TNotNull> & TimestampsOptions = {}
247
+ export const auditColumns = <
248
+ TNotNull extends boolean = false,
249
+ TMode extends TimestampMode = "date",
250
+ >(
251
+ options: AuthorshipOptions<TNotNull> & TimestampsOptions<TMode> = {}
213
252
  ) => ({
214
- ...timestamps(options),
253
+ ...timestamps<TMode>(options),
215
254
  ...authorship<TNotNull>(options),
216
255
  })
217
256
 
@@ -414,15 +453,6 @@ const POLICY_BUILDERS = {
414
453
  update: updatePolicy,
415
454
  } as const satisfies Record<PolicyOperation, typeof selectPolicy>
416
455
 
417
- const FUNCTION_POLICY_OPERATIONS = [
418
- "select",
419
- "insert",
420
- "update",
421
- "delete",
422
- ] as const
423
-
424
- type FunctionPolicyOperation = (typeof FUNCTION_POLICY_OPERATIONS)[number]
425
-
426
456
  /** Columns one function is called with. `null` calls it with none. */
427
457
  type FunctionArgument = AnyPgColumn | readonly AnyPgColumn[] | null
428
458
 
@@ -515,7 +545,7 @@ export const functionPolicies = (
515
545
  {
516
546
  argument,
517
547
  name,
518
- prefix = "can",
548
+ prefix = DEFAULT_FUNCTION_PREFIX,
519
549
  schema: functionSchema,
520
550
  }: FunctionPoliciesOptions = {}
521
551
  ) => {
@@ -528,7 +558,7 @@ export const functionPolicies = (
528
558
  const tableName = getTableName(getColumnTable(firstColumn))
529
559
 
530
560
  return FUNCTION_POLICY_OPERATIONS.map((operation) => {
531
- const functionName = `${prefix}_${operation}_${tableName}`
561
+ const functionName = functionPolicyName(prefix, operation, tableName)
532
562
  // Unqualified by default, so the name resolves through `search_path` the
533
563
  // way a hand-written policy would; `schema` pins it to the functions that
534
564
  // live beside their table instead.