@zeno-lib/db 0.0.1 → 0.2.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,171 @@
1
+ // SQL text for the triggers a Supabase schema wants but drizzle-kit cannot
2
+ // generate. drizzle-orm 1.0.0-rc.3 exports no trigger API and the drizzle-kit
3
+ // bundle contains no `CREATE TRIGGER`, so triggers are not part of the snapshot
4
+ // format and `drizzle-kit generate` will never emit one from your schema.
5
+ //
6
+ // What it does give you is `drizzle-kit generate --custom`, which writes an
7
+ // empty, *tracked* migration (its snapshot links into the chain via `prevIds`)
8
+ // for you to fill in. These helpers produce the SQL that goes in it, so the
9
+ // text is versioned here rather than retyped per table.
10
+ import { getTableName, is } from "drizzle-orm"
11
+ import { getTableConfig, PgTable } from "drizzle-orm/pg-core"
12
+
13
+ // Supabase installs extensions into `extensions`, not `public`.
14
+ const DEFAULT_EXTENSION_SCHEMA = "extensions"
15
+ const DEFAULT_COLUMN = "updated_at"
16
+ const DEFAULT_AUTHOR_COLUMN = "updated_by"
17
+ const DEFAULT_TABLE_SCHEMA = "public"
18
+
19
+ type UpdatedAtTriggerOptions = {
20
+ /** Column the trigger maintains. Default `"updated_at"`. */
21
+ column?: string
22
+ /** Schema holding the table. Default `"public"`, or the table's own. */
23
+ schema?: string
24
+ /** Trigger name. Default `handle_<column>`. */
25
+ name?: string
26
+ /** Schema `moddatetime` is installed into. Default `"extensions"`. */
27
+ extensionSchema?: string
28
+ /** Emit `create extension if not exists`. Default `true`. */
29
+ createExtension?: boolean
30
+ }
31
+
32
+ const quote = (identifier: string) => `"${identifier.split('"').join('""')}"`
33
+
34
+ function resolveTable(table: PgTable | string, schema?: string) {
35
+ const name = is(table, PgTable) ? getTableName(table) : table
36
+ const tableSchema =
37
+ schema ??
38
+ (is(table, PgTable)
39
+ ? (getTableConfig(table).schema ?? DEFAULT_TABLE_SCHEMA)
40
+ : DEFAULT_TABLE_SCHEMA)
41
+
42
+ return { qualified: `${quote(tableSchema)}.${quote(name)}` }
43
+ }
44
+
45
+ /**
46
+ * `CREATE TRIGGER` SQL that keeps `updated_at` current for **every** writer,
47
+ * including PostgREST, the dashboard and psql.
48
+ *
49
+ * `timestamps()` gives the column a `DEFAULT now()`, which covers the insert.
50
+ * SQL has no "on update" default, so without this trigger the column is only
51
+ * ever the insert time. Required, not optional.
52
+ *
53
+ * ```ts
54
+ * // pnpm db:generate --custom --name=posts_audit_triggers
55
+ * // then paste the output into the generated migration:
56
+ * console.log(updatedAtTrigger(posts))
57
+ * ```
58
+ */
59
+ export function updatedAtTrigger(
60
+ table: PgTable | string,
61
+ {
62
+ column = DEFAULT_COLUMN,
63
+ createExtension = true,
64
+ extensionSchema = DEFAULT_EXTENSION_SCHEMA,
65
+ name,
66
+ schema,
67
+ }: UpdatedAtTriggerOptions = {}
68
+ ) {
69
+ const { qualified } = resolveTable(table, schema)
70
+ const triggerName = name ?? `handle_${column}`
71
+
72
+ // `moddatetime` takes the column name as a trigger argument and sets it to
73
+ // now() on the NEW row, so it wins over whatever the UPDATE supplied.
74
+ const trigger = [
75
+ `drop trigger if exists ${quote(triggerName)} on ${qualified};`,
76
+ `create trigger ${quote(triggerName)}`,
77
+ ` before update on ${qualified}`,
78
+ " for each row",
79
+ ` execute function ${quote(extensionSchema)}.moddatetime (${quote(column)});`,
80
+ ].join("\n")
81
+
82
+ return createExtension
83
+ ? `${moddatetimeExtension(extensionSchema)}\n\n${trigger}`
84
+ : trigger
85
+ }
86
+
87
+ /** `create extension if not exists moddatetime`, safe to repeat per migration. */
88
+ export function moddatetimeExtension(schema = DEFAULT_EXTENSION_SCHEMA) {
89
+ return `create extension if not exists moddatetime with schema ${quote(schema)};`
90
+ }
91
+
92
+ type UpdatedByTriggerOptions = {
93
+ /** Column the trigger maintains. Default `"updated_by"`. */
94
+ column?: string
95
+ /** Schema holding the table. Default `"public"`, or the table's own. */
96
+ schema?: string
97
+ /** Trigger name. Default `handle_<column>`. */
98
+ name?: string
99
+ /** Schema the helper function is created in. Default `"public"`. */
100
+ functionSchema?: string
101
+ /** Emit `create or replace function`. Default `true`. */
102
+ createFunction?: boolean
103
+ }
104
+
105
+ /**
106
+ * `CREATE TRIGGER` SQL that stamps an author column with `auth.uid()` on every
107
+ * update, the `updated_at` story for `updated_by`.
108
+ *
109
+ * `updatedBy()` gives the column a `DEFAULT (select auth.uid())`, which covers
110
+ * the insert. This covers the update, for every writer rather than only the
111
+ * ones going through Drizzle.
112
+ *
113
+ * A write with no session (the admin or `service_role` clients) records `NULL`,
114
+ * because that is what `auth.uid()` returns there.
115
+ */
116
+ export function updatedByTrigger(
117
+ table: PgTable | string,
118
+ {
119
+ column = DEFAULT_AUTHOR_COLUMN,
120
+ createFunction = true,
121
+ functionSchema = DEFAULT_TABLE_SCHEMA,
122
+ name,
123
+ schema,
124
+ }: UpdatedByTriggerOptions = {}
125
+ ) {
126
+ const { qualified } = resolveTable(table, schema)
127
+ const triggerName = name ?? `handle_${column}`
128
+ const functionName = `set_${column}`
129
+ const qualifiedFunction = `${quote(functionSchema)}.${quote(functionName)}`
130
+
131
+ // One function serves every table using this column name. `security invoker`
132
+ // is deliberate: auth.uid() reads a transaction-local setting, so the trigger
133
+ // needs no privileges of its own.
134
+ const routine = [
135
+ `create or replace function ${qualifiedFunction}()`,
136
+ " returns trigger",
137
+ " language plpgsql",
138
+ " security invoker",
139
+ ` set search_path = ''`,
140
+ "as $$",
141
+ "begin",
142
+ ` new.${quote(column)} = (select auth.uid());`,
143
+ " return new;",
144
+ "end;",
145
+ "$$;",
146
+ ].join("\n")
147
+
148
+ const trigger = [
149
+ `drop trigger if exists ${quote(triggerName)} on ${qualified};`,
150
+ `create trigger ${quote(triggerName)}`,
151
+ ` before update on ${qualified}`,
152
+ " for each row",
153
+ ` execute function ${qualifiedFunction}();`,
154
+ ].join("\n")
155
+
156
+ return createFunction ? `${routine}\n\n${trigger}` : trigger
157
+ }
158
+
159
+ /**
160
+ * Both update triggers `auditColumns()` needs, in one string.
161
+ *
162
+ * `auditColumns()` and `timestamps()` leave `updated_at` and `updated_by` at
163
+ * their insert values without these, so a table using either helper wants this
164
+ * in a `drizzle-kit generate --custom` migration.
165
+ */
166
+ export function auditTriggers(
167
+ table: PgTable | string,
168
+ options: UpdatedAtTriggerOptions & UpdatedByTriggerOptions = {}
169
+ ) {
170
+ return `${updatedAtTrigger(table, options)}\n\n${updatedByTrigger(table, options)}`
171
+ }