@zeno-lib/db 0.1.0 → 0.3.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/package.json +5 -3
- package/src/auth-schema.test.ts +59 -0
- package/src/auth-schema.ts +94 -0
- package/src/casing.test.ts +582 -28
- package/src/config.ts +51 -2
- package/src/schema.test-d.ts +121 -0
- package/src/schema.ts +494 -43
- package/src/triggers.test.ts +131 -0
- package/src/triggers.ts +171 -0
package/src/schema.ts
CHANGED
|
@@ -1,18 +1,32 @@
|
|
|
1
|
-
// https://orm.drizzle.team/docs/rls#using-with-supabase (re-exported roles,
|
|
2
|
-
import { sql } from "drizzle-orm"
|
|
1
|
+
// https://orm.drizzle.team/docs/rls#using-with-supabase (re-exported roles, authUid, realtimeMessages)
|
|
2
|
+
import { Column, getColumnTable, getTableName, is, sql } from "drizzle-orm"
|
|
3
3
|
import {
|
|
4
4
|
type AnyPgColumn,
|
|
5
|
+
bigint,
|
|
6
|
+
type ExtraConfigColumn,
|
|
7
|
+
type HasIdentity,
|
|
5
8
|
integer,
|
|
9
|
+
type PgBigInt53Builder,
|
|
10
|
+
type PgBigInt64Builder,
|
|
11
|
+
type PgIntegerBuilder,
|
|
6
12
|
type PgPolicyConfig,
|
|
13
|
+
type PgUUIDBuilder,
|
|
14
|
+
type Precision,
|
|
7
15
|
pgPolicy,
|
|
16
|
+
type ReferenceConfig,
|
|
17
|
+
type SetHasDefault,
|
|
18
|
+
type SetIsPrimaryKey,
|
|
19
|
+
type SetNotNull,
|
|
8
20
|
timestamp,
|
|
9
21
|
uuid,
|
|
22
|
+
varchar,
|
|
10
23
|
} from "drizzle-orm/pg-core"
|
|
11
24
|
import { snakeCase } from "drizzle-orm/pg-core/casing"
|
|
12
|
-
import { authenticatedRole, authUid
|
|
25
|
+
import { authenticatedRole, authUid } from "drizzle-orm/supabase"
|
|
26
|
+
import { authUsers } from "./auth-schema.ts"
|
|
13
27
|
|
|
14
|
-
//
|
|
15
|
-
//
|
|
28
|
+
// pg-core primitives without the `pg` prefix they repeat at every call site.
|
|
29
|
+
// `table` is missing on purpose. Zeno's own is at the bottom of this file.
|
|
16
30
|
// biome-ignore lint/performance/noBarrelFile: intentional public re-export surface
|
|
17
31
|
export {
|
|
18
32
|
isPgEnum as isEnum,
|
|
@@ -24,19 +38,17 @@ export {
|
|
|
24
38
|
pgMaterializedView as materializedView,
|
|
25
39
|
pgPolicy as policy,
|
|
26
40
|
pgRole as role,
|
|
27
|
-
pgSchema as schema,
|
|
28
41
|
pgSequence as sequence,
|
|
29
42
|
pgTableCreator as tableCreator,
|
|
30
43
|
pgView as view,
|
|
31
44
|
} from "drizzle-orm/pg-core"
|
|
32
45
|
|
|
33
|
-
//
|
|
34
|
-
//
|
|
46
|
+
// Supabase roles, auth.users and SQL helpers, re-exported so a schema file
|
|
47
|
+
// imports everything it needs from here.
|
|
35
48
|
export {
|
|
36
49
|
anonRole,
|
|
37
50
|
authenticatedRole,
|
|
38
51
|
authUid,
|
|
39
|
-
authUsers,
|
|
40
52
|
postgresRole,
|
|
41
53
|
realtimeMessages,
|
|
42
54
|
realtimeTopic,
|
|
@@ -44,51 +56,292 @@ export {
|
|
|
44
56
|
supabaseAuthAdminRole,
|
|
45
57
|
} from "drizzle-orm/supabase"
|
|
46
58
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
.defaultNow(),
|
|
52
|
-
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
53
|
-
.notNull()
|
|
54
|
-
.defaultNow()
|
|
55
|
-
.$onUpdate(() => new Date()),
|
|
59
|
+
type TimestampsOptions = {
|
|
60
|
+
withTimezone?: boolean
|
|
61
|
+
/** Fractional-second digits. Postgres allows 0 to 6. */
|
|
62
|
+
precision?: Precision
|
|
56
63
|
}
|
|
57
64
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
// `created_at` is a column DEFAULT, so Postgres fills it for every writer.
|
|
66
|
+
// `updated_at` has no equivalent: SQL has no "on update" default, and Drizzle's
|
|
67
|
+
// `$onUpdateFn` is applied while Drizzle builds its own statement, so a write
|
|
68
|
+
// arriving through PostgREST never runs it. In a Supabase app that is most
|
|
69
|
+
// writes, which made the hook a column half Drizzle claimed and never
|
|
70
|
+
// maintained. It is gone: `updatedAtTrigger` from `@zeno-lib/db/triggers` puts
|
|
71
|
+
// the column in Postgres's hands, where every writer reaches it.
|
|
72
|
+
//
|
|
73
|
+
// Call and spread into a column map. Every audit mixin here is a factory rather
|
|
74
|
+
// than a shared object, because Drizzle's builder methods mutate `this` and
|
|
75
|
+
// return it. One builder in two tables would leak `.notNull()`, `.references()`
|
|
76
|
+
// and its name from whichever table customised it first.
|
|
77
|
+
export const timestamps = ({
|
|
78
|
+
precision,
|
|
79
|
+
withTimezone = true,
|
|
80
|
+
}: TimestampsOptions = {}) => {
|
|
81
|
+
const config = { precision, withTimezone }
|
|
62
82
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
83
|
+
return {
|
|
84
|
+
createdAt: timestamp("created_at", config).notNull().defaultNow(),
|
|
85
|
+
updatedAt: timestamp("updated_at", config).notNull().defaultNow(),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type ReferenceActions = ReferenceConfig["config"]
|
|
90
|
+
|
|
91
|
+
// An audit row outlives its author, so the reference blanks rather than
|
|
92
|
+
// blocking the delete. `set null` against a NOT NULL column is a foreign key
|
|
93
|
+
// that can never fire, so a required author restricts the delete instead: still
|
|
94
|
+
// the old behaviour, but now because you asked for it.
|
|
95
|
+
const NULLABLE_AUTHOR_ACTIONS = {
|
|
96
|
+
onDelete: "set null",
|
|
97
|
+
onUpdate: "cascade",
|
|
98
|
+
} as const satisfies ReferenceActions
|
|
99
|
+
|
|
100
|
+
const REQUIRED_AUTHOR_ACTIONS = {
|
|
101
|
+
onDelete: "restrict",
|
|
102
|
+
onUpdate: "cascade",
|
|
103
|
+
} as const satisfies ReferenceActions
|
|
104
|
+
|
|
105
|
+
// `[T] extends [U]` blocks distribution, so a plain `boolean` gives one column
|
|
106
|
+
// type rather than a union of both.
|
|
107
|
+
type AuthorColumn<TNotNull extends boolean> = [TNotNull] extends [true]
|
|
108
|
+
? SetNotNull<PgUUIDBuilder>
|
|
109
|
+
: PgUUIDBuilder
|
|
110
|
+
|
|
111
|
+
// `actions` is meaningless without a reference, the same way
|
|
112
|
+
// `sequentialPrimaryId` rejects `mode` for an integer key.
|
|
113
|
+
type AuthorReference =
|
|
114
|
+
| { reference?: () => AnyPgColumn; actions?: ReferenceActions }
|
|
115
|
+
| { reference: null; actions?: never }
|
|
116
|
+
|
|
117
|
+
export type AuthorshipOptions<TNotNull extends boolean = false> =
|
|
118
|
+
AuthorReference & { notNull?: TNotNull }
|
|
119
|
+
|
|
120
|
+
// The loose shape the implementation signatures take. Callers only ever see the
|
|
121
|
+
// overload above each helper.
|
|
122
|
+
type AuthorColumnConfig = {
|
|
123
|
+
name?: string
|
|
124
|
+
reference?: (() => AnyPgColumn) | null
|
|
125
|
+
actions?: ReferenceActions
|
|
126
|
+
notNull?: boolean
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const authorColumn = ({
|
|
130
|
+
name,
|
|
131
|
+
reference = () => authUsers.id,
|
|
132
|
+
actions,
|
|
133
|
+
notNull = false,
|
|
134
|
+
}: AuthorColumnConfig) => {
|
|
135
|
+
const column =
|
|
136
|
+
reference === null
|
|
137
|
+
? uuid(name)
|
|
138
|
+
: uuid(name).references(
|
|
139
|
+
reference,
|
|
140
|
+
actions ??
|
|
141
|
+
(notNull ? REQUIRED_AUTHOR_ACTIONS : NULLABLE_AUTHOR_ACTIONS)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return notNull ? column.notNull() : column
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Nullable by default: a required reference to auth.users means deleting the
|
|
148
|
+
// user fails, because the audit trail holds the row.
|
|
149
|
+
export function authUserId<TNotNull extends boolean = false>(
|
|
150
|
+
options?: AuthorshipOptions<TNotNull> & { name?: string }
|
|
151
|
+
): AuthorColumn<TNotNull>
|
|
152
|
+
export function authUserId(options: AuthorColumnConfig = {}) {
|
|
153
|
+
return authorColumn(options)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// RLS policies and PostgREST joins cannot read auth.users, so applications
|
|
157
|
+
// mirror it into a public `profiles` table. This points at that instead.
|
|
158
|
+
export function userId<TNotNull extends boolean = false>(
|
|
159
|
+
reference: () => AnyPgColumn,
|
|
160
|
+
options?: {
|
|
161
|
+
name?: string
|
|
162
|
+
actions?: ReferenceActions
|
|
163
|
+
notNull?: TNotNull
|
|
164
|
+
}
|
|
165
|
+
): AuthorColumn<TNotNull>
|
|
166
|
+
export function userId(
|
|
167
|
+
reference: () => AnyPgColumn,
|
|
168
|
+
options: AuthorColumnConfig = {}
|
|
169
|
+
) {
|
|
170
|
+
return authorColumn({ ...options, reference })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// `authUid` is `(select auth.uid())`, which is right for a policy predicate
|
|
174
|
+
// (the wrapper lets the planner evaluate it once per statement) but invalid in
|
|
175
|
+
// a column DEFAULT: Postgres rejects a subquery there with
|
|
176
|
+
// "cannot use subquery in DEFAULT expression". The bare call is what a DEFAULT
|
|
177
|
+
// takes, and it resolves the same request.jwt.claims setting.
|
|
178
|
+
const AUTH_UID_DEFAULT = sql`auth.uid()`
|
|
67
179
|
|
|
68
|
-
export
|
|
69
|
-
|
|
70
|
-
|
|
180
|
+
export function createdBy<TNotNull extends boolean = false>(
|
|
181
|
+
options?: AuthorshipOptions<TNotNull>
|
|
182
|
+
): SetHasDefault<AuthorColumn<TNotNull>>
|
|
183
|
+
export function createdBy(options: AuthorColumnConfig = {}) {
|
|
184
|
+
return authorColumn({ ...options, name: "created_by" }).default(
|
|
185
|
+
AUTH_UID_DEFAULT
|
|
186
|
+
)
|
|
71
187
|
}
|
|
72
188
|
|
|
73
|
-
export
|
|
74
|
-
|
|
75
|
-
|
|
189
|
+
export function updatedBy<TNotNull extends boolean = false>(
|
|
190
|
+
options?: AuthorshipOptions<TNotNull>
|
|
191
|
+
): SetHasDefault<AuthorColumn<TNotNull>>
|
|
192
|
+
export function updatedBy(options: AuthorColumnConfig = {}) {
|
|
193
|
+
// The DEFAULT covers the insert for every writer. The update side is
|
|
194
|
+
// `updatedByTrigger`, for the same reason `updated_at` needs one.
|
|
195
|
+
return authorColumn({ ...options, name: "updated_by" }).default(
|
|
196
|
+
AUTH_UID_DEFAULT
|
|
197
|
+
)
|
|
76
198
|
}
|
|
77
199
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
200
|
+
// One options object covers both columns. A table that needs them to differ
|
|
201
|
+
// calls `createdBy()` and `updatedBy()` separately, which also covers a schema
|
|
202
|
+
// with `created_by` and no `updated_by`.
|
|
203
|
+
export const authorship = <TNotNull extends boolean = false>(
|
|
204
|
+
options?: AuthorshipOptions<TNotNull>
|
|
205
|
+
) => ({
|
|
206
|
+
createdBy: createdBy<TNotNull>(options),
|
|
207
|
+
updatedBy: updatedBy<TNotNull>(options),
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
// Takes both halves' options, since it builds both halves.
|
|
211
|
+
export const auditColumns = <TNotNull extends boolean = false>(
|
|
212
|
+
options: AuthorshipOptions<TNotNull> & TimestampsOptions = {}
|
|
213
|
+
) => ({
|
|
214
|
+
...timestamps(options),
|
|
215
|
+
...authorship<TNotNull>(options),
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
const DEFAULT_ID_NAME = "id"
|
|
219
|
+
|
|
220
|
+
// `[T] extends [U]` blocks distribution, so a plain `boolean` gives one column
|
|
221
|
+
// type rather than a union of both.
|
|
222
|
+
type UuidPrimaryIdColumn<TDefaultRandom extends boolean> = [
|
|
223
|
+
TDefaultRandom,
|
|
224
|
+
] extends [false]
|
|
225
|
+
? SetIsPrimaryKey<PgUUIDBuilder>
|
|
226
|
+
: SetHasDefault<SetIsPrimaryKey<PgUUIDBuilder>>
|
|
227
|
+
|
|
228
|
+
// `defaultRandom: false` when the id comes from elsewhere. A mirror of
|
|
229
|
+
// auth.users takes its id from the referenced row, and skipping the default
|
|
230
|
+
// also makes the column required on insert.
|
|
231
|
+
export function uuidPrimaryId<TDefaultRandom extends boolean = true>(options?: {
|
|
232
|
+
name?: string
|
|
233
|
+
defaultRandom?: TDefaultRandom
|
|
234
|
+
}): UuidPrimaryIdColumn<TDefaultRandom>
|
|
235
|
+
export function uuidPrimaryId(
|
|
236
|
+
options: { name?: string; defaultRandom?: boolean } = {}
|
|
237
|
+
) {
|
|
238
|
+
const { name = DEFAULT_ID_NAME, defaultRandom = true } = options
|
|
239
|
+
const column = uuid(name).primaryKey()
|
|
240
|
+
|
|
241
|
+
return defaultRandom ? column.defaultRandom() : column
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
type IdentityType = "integer" | "bigint"
|
|
245
|
+
type IdentityMode = "number" | "bigint"
|
|
246
|
+
type IdentityGeneration = "byDefault" | "always"
|
|
247
|
+
|
|
248
|
+
type IdentityBuilder<
|
|
249
|
+
TType extends IdentityType,
|
|
250
|
+
TMode extends IdentityMode,
|
|
251
|
+
> = TType extends "integer"
|
|
252
|
+
? PgIntegerBuilder
|
|
253
|
+
: TMode extends "bigint"
|
|
254
|
+
? PgBigInt64Builder
|
|
255
|
+
: PgBigInt53Builder
|
|
256
|
+
|
|
257
|
+
type SequentialPrimaryIdColumn<
|
|
258
|
+
TType extends IdentityType,
|
|
259
|
+
TMode extends IdentityMode,
|
|
260
|
+
TGeneration extends IdentityGeneration,
|
|
261
|
+
> = HasIdentity<SetIsPrimaryKey<IdentityBuilder<TType, TMode>>, TGeneration>
|
|
262
|
+
|
|
263
|
+
// Defaults match the Supabase table editor: bigint, generated by default as
|
|
264
|
+
// identity. Option names follow Postgres, which calls this an identity column.
|
|
265
|
+
// `mode` is "number" because PostgREST serialises to JSON numbers.
|
|
266
|
+
export function sequentialPrimaryId<
|
|
267
|
+
TType extends IdentityType = "bigint",
|
|
268
|
+
TMode extends IdentityMode = "number",
|
|
269
|
+
TGeneration extends IdentityGeneration = "byDefault",
|
|
270
|
+
>(
|
|
271
|
+
options: {
|
|
272
|
+
name?: string
|
|
273
|
+
type?: TType
|
|
274
|
+
// `never` for integer, which has one JavaScript representation.
|
|
275
|
+
mode?: TType extends "integer" ? never : TMode
|
|
276
|
+
generated?: TGeneration
|
|
277
|
+
} = {}
|
|
278
|
+
): SequentialPrimaryIdColumn<TType, TMode, TGeneration> {
|
|
279
|
+
const {
|
|
280
|
+
name = DEFAULT_ID_NAME,
|
|
281
|
+
type = "bigint",
|
|
282
|
+
mode = "number",
|
|
283
|
+
generated = "byDefault",
|
|
284
|
+
} = options as {
|
|
285
|
+
name?: string
|
|
286
|
+
type?: IdentityType
|
|
287
|
+
mode?: IdentityMode
|
|
288
|
+
generated?: IdentityGeneration
|
|
289
|
+
}
|
|
290
|
+
const column =
|
|
291
|
+
type === "integer"
|
|
292
|
+
? integer(name).primaryKey()
|
|
293
|
+
: bigint(name, { mode }).primaryKey()
|
|
294
|
+
|
|
295
|
+
// TypeScript can't follow the runtime branch. A single cast is rejected too,
|
|
296
|
+
// because Drizzle types `$default` off a polymorphic `this`, which leaves the
|
|
297
|
+
// concrete builders unassignable to the intersected return type.
|
|
298
|
+
return (generated === "always"
|
|
299
|
+
? column.generatedAlwaysAsIdentity()
|
|
300
|
+
: column.generatedByDefaultAsIdentity()) as unknown as SequentialPrimaryIdColumn<
|
|
301
|
+
TType,
|
|
302
|
+
TMode,
|
|
303
|
+
TGeneration
|
|
304
|
+
>
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export const assignedPrimaryId = ({
|
|
308
|
+
name = DEFAULT_ID_NAME,
|
|
309
|
+
length,
|
|
310
|
+
}: {
|
|
311
|
+
name?: string
|
|
312
|
+
length?: number
|
|
313
|
+
} = {}) => varchar(name, { length }).primaryKey()
|
|
314
|
+
|
|
315
|
+
type PrimaryIdKind = "uuid" | "sequential" | "assigned"
|
|
316
|
+
|
|
317
|
+
type PrimaryIdColumn<TKind extends PrimaryIdKind> = TKind extends "uuid"
|
|
318
|
+
? UuidPrimaryIdColumn<true>
|
|
319
|
+
: TKind extends "sequential"
|
|
320
|
+
? SequentialPrimaryIdColumn<"bigint", "number", "byDefault">
|
|
321
|
+
: ReturnType<typeof assignedPrimaryId>
|
|
322
|
+
|
|
323
|
+
// Takes the kind and nothing else, so it can't regrow the overload set it
|
|
324
|
+
// replaced. Renaming a column or changing how the value is generated goes
|
|
325
|
+
// through the helper behind the kind. "sequential" is the default because it is
|
|
326
|
+
// the id the Supabase table editor gives a new table.
|
|
327
|
+
export function primaryId<TKind extends PrimaryIdKind = "sequential">(
|
|
328
|
+
kind?: TKind
|
|
329
|
+
): PrimaryIdColumn<TKind>
|
|
330
|
+
export function primaryId(kind: PrimaryIdKind = "sequential") {
|
|
331
|
+
if (kind === "uuid") {
|
|
332
|
+
return uuidPrimaryId()
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (kind === "assigned") {
|
|
336
|
+
return assignedPrimaryId()
|
|
337
|
+
}
|
|
81
338
|
|
|
82
|
-
|
|
83
|
-
export function primaryId(
|
|
84
|
-
kind: "sequential"
|
|
85
|
-
): ReturnType<typeof sequentialPrimaryId>
|
|
86
|
-
export function primaryId(kind: "uuid" | "sequential" = "uuid") {
|
|
87
|
-
return kind === "sequential" ? sequentialPrimaryId() : uuidPrimaryId()
|
|
339
|
+
return sequentialPrimaryId()
|
|
88
340
|
}
|
|
89
341
|
|
|
90
342
|
type PolicyOptions = Omit<PgPolicyConfig, "for">
|
|
91
343
|
type PolicyOperation = NonNullable<PgPolicyConfig["for"]>
|
|
344
|
+
type AuthenticatedPolicyOptions = Omit<PgPolicyConfig, "for" | "to">
|
|
92
345
|
|
|
93
346
|
function operationPolicy(
|
|
94
347
|
name: string,
|
|
@@ -113,6 +366,187 @@ export const deletePolicy = (name: string, config: PolicyOptions = {}) =>
|
|
|
113
366
|
export const allPolicy = (name: string, config: PolicyOptions = {}) =>
|
|
114
367
|
operationPolicy(name, "all", config)
|
|
115
368
|
|
|
369
|
+
// `to: authenticatedRole` on its own, which every hand-written policy repeats.
|
|
370
|
+
// The condition stays yours, unlike the owner helpers below.
|
|
371
|
+
export const authenticatedSelectPolicy = (
|
|
372
|
+
name: string,
|
|
373
|
+
config: AuthenticatedPolicyOptions = {}
|
|
374
|
+
) => selectPolicy(name, { ...config, to: authenticatedRole })
|
|
375
|
+
|
|
376
|
+
export const authenticatedInsertPolicy = (
|
|
377
|
+
name: string,
|
|
378
|
+
config: AuthenticatedPolicyOptions = {}
|
|
379
|
+
) => insertPolicy(name, { ...config, to: authenticatedRole })
|
|
380
|
+
|
|
381
|
+
export const authenticatedUpdatePolicy = (
|
|
382
|
+
name: string,
|
|
383
|
+
config: AuthenticatedPolicyOptions = {}
|
|
384
|
+
) => updatePolicy(name, { ...config, to: authenticatedRole })
|
|
385
|
+
|
|
386
|
+
export const authenticatedDeletePolicy = (
|
|
387
|
+
name: string,
|
|
388
|
+
config: AuthenticatedPolicyOptions = {}
|
|
389
|
+
) => deletePolicy(name, { ...config, to: authenticatedRole })
|
|
390
|
+
|
|
391
|
+
export const authenticatedAllPolicy = (
|
|
392
|
+
name: string,
|
|
393
|
+
config: AuthenticatedPolicyOptions = {}
|
|
394
|
+
) => allPolicy(name, { ...config, to: authenticatedRole })
|
|
395
|
+
|
|
396
|
+
// The clause each operation's condition belongs in: `using` filters the rows
|
|
397
|
+
// already there, `withCheck` vets the rows going in. `update` and `all` accept
|
|
398
|
+
// both, but Postgres reuses `using` for the check when `withCheck` is left out,
|
|
399
|
+
// so one clause says the same thing and leaves the catalog matching the
|
|
400
|
+
// `USING`-only policies `drizzle-kit pull` reads back from an existing database.
|
|
401
|
+
const POLICY_CLAUSE = {
|
|
402
|
+
all: "using",
|
|
403
|
+
delete: "using",
|
|
404
|
+
insert: "withCheck",
|
|
405
|
+
select: "using",
|
|
406
|
+
update: "using",
|
|
407
|
+
} as const satisfies Record<PolicyOperation, "using" | "withCheck">
|
|
408
|
+
|
|
409
|
+
const POLICY_BUILDERS = {
|
|
410
|
+
all: allPolicy,
|
|
411
|
+
delete: deletePolicy,
|
|
412
|
+
insert: insertPolicy,
|
|
413
|
+
select: selectPolicy,
|
|
414
|
+
update: updatePolicy,
|
|
415
|
+
} as const satisfies Record<PolicyOperation, typeof selectPolicy>
|
|
416
|
+
|
|
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
|
+
/** Columns one function is called with. `null` calls it with none. */
|
|
427
|
+
type FunctionArgument = AnyPgColumn | readonly AnyPgColumn[] | null
|
|
428
|
+
|
|
429
|
+
type FunctionPoliciesOptions = {
|
|
430
|
+
/**
|
|
431
|
+
* Passed to each function, e.g. the row's id: one column, an array for a
|
|
432
|
+
* function taking several, or a record to vary them per operation. The
|
|
433
|
+
* common shape is that the row-scoped operations take the row and `insert`
|
|
434
|
+
* takes nothing, there being no row yet to authorise, only the caller. An
|
|
435
|
+
* operation missing from the record, or set to `null`, is called with no
|
|
436
|
+
* arguments; so is every operation when `argument` is omitted.
|
|
437
|
+
*/
|
|
438
|
+
argument?:
|
|
439
|
+
| FunctionArgument
|
|
440
|
+
| Partial<Record<FunctionPolicyOperation, FunctionArgument>>
|
|
441
|
+
/**
|
|
442
|
+
* Schema the functions live in, e.g. `"billing"` for
|
|
443
|
+
* `billing.can_select_invoices`. Omit to emit the name unqualified and let
|
|
444
|
+
* the `search_path` in effect resolve it.
|
|
445
|
+
*/
|
|
446
|
+
schema?: string
|
|
447
|
+
/** Prefix for the default function and policy names. Default `"can"`. */
|
|
448
|
+
prefix?: string
|
|
449
|
+
/** Overrides the generated policy name. */
|
|
450
|
+
name?: (operation: PolicyOperation, table: string) => string
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// A column and an array of them are the whole-set form; anything else is the
|
|
454
|
+
// per-operation record. `is` is how drizzle asks "is this one of mine", and a
|
|
455
|
+
// column is the only entity either form can hold.
|
|
456
|
+
const isWholeSetArgument = (
|
|
457
|
+
argument: NonNullable<FunctionPoliciesOptions["argument"]>
|
|
458
|
+
): argument is NonNullable<FunctionArgument> =>
|
|
459
|
+
is(argument, Column) || Array.isArray(argument)
|
|
460
|
+
|
|
461
|
+
const argumentsFor = (
|
|
462
|
+
argument: FunctionPoliciesOptions["argument"],
|
|
463
|
+
operation: FunctionPolicyOperation
|
|
464
|
+
): AnyPgColumn[] => {
|
|
465
|
+
if (!argument) {
|
|
466
|
+
return []
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const forOperation = isWholeSetArgument(argument)
|
|
470
|
+
? argument
|
|
471
|
+
: argument[operation]
|
|
472
|
+
|
|
473
|
+
if (!forOperation) {
|
|
474
|
+
return []
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return is(forOperation, Column) ? [forOperation] : [...forOperation]
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// The columns object drizzle hands the extra-config callback. Taking it rather
|
|
481
|
+
// than the table is what keeps `functionPolicies` usable: naming the table
|
|
482
|
+
// inside its own definition makes its type circular, so `(t) => ...` is the
|
|
483
|
+
// only reference available at that point.
|
|
484
|
+
type ExtraConfigColumns = Record<string, ExtraConfigColumn>
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* One policy per operation, each delegating to a `security definer` function.
|
|
488
|
+
*
|
|
489
|
+
* Access rarely depends only on the row's own owner column, and the usual
|
|
490
|
+
* answer is a function, which is also the standard advice for keeping RLS
|
|
491
|
+
* predicates out of the planner's way:
|
|
492
|
+
*
|
|
493
|
+
* ```ts
|
|
494
|
+
* table("posts", { id: primaryId("uuid") }, (t) =>
|
|
495
|
+
* functionPolicies(t, { argument: t.id })
|
|
496
|
+
* )
|
|
497
|
+
* ```
|
|
498
|
+
*
|
|
499
|
+
* ```sql
|
|
500
|
+
* CREATE POLICY "can_select_posts" ON "posts" FOR SELECT TO "authenticated"
|
|
501
|
+
* USING ((select "can_select_posts"("posts"."id")));
|
|
502
|
+
* ```
|
|
503
|
+
*
|
|
504
|
+
* `argument` also takes a record, for the usual shape where `insert` has no
|
|
505
|
+
* row to authorise yet:
|
|
506
|
+
*
|
|
507
|
+
* ```ts
|
|
508
|
+
* functionPolicies(t, {
|
|
509
|
+
* argument: { delete: t.id, select: t.id, update: t.id },
|
|
510
|
+
* })
|
|
511
|
+
* ```
|
|
512
|
+
*/
|
|
513
|
+
export const functionPolicies = (
|
|
514
|
+
columns: ExtraConfigColumns,
|
|
515
|
+
{
|
|
516
|
+
argument,
|
|
517
|
+
name,
|
|
518
|
+
prefix = "can",
|
|
519
|
+
schema: functionSchema,
|
|
520
|
+
}: FunctionPoliciesOptions = {}
|
|
521
|
+
) => {
|
|
522
|
+
const [firstColumn] = Object.values(columns)
|
|
523
|
+
|
|
524
|
+
if (!firstColumn) {
|
|
525
|
+
throw new Error("functionPolicies needs a table with at least one column")
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const tableName = getTableName(getColumnTable(firstColumn))
|
|
529
|
+
|
|
530
|
+
return FUNCTION_POLICY_OPERATIONS.map((operation) => {
|
|
531
|
+
const functionName = `${prefix}_${operation}_${tableName}`
|
|
532
|
+
// Unqualified by default, so the name resolves through `search_path` the
|
|
533
|
+
// way a hand-written policy would; `schema` pins it to the functions that
|
|
534
|
+
// live beside their table instead.
|
|
535
|
+
const callee = functionSchema
|
|
536
|
+
? sql`${sql.identifier(functionSchema)}.${sql.identifier(functionName)}`
|
|
537
|
+
: sql`${sql.identifier(functionName)}`
|
|
538
|
+
// `select` wraps it so Postgres evaluates the call once per statement
|
|
539
|
+
// rather than once per row, the same shape `authUid` uses.
|
|
540
|
+
const condition = sql`(select ${callee}(${sql.join(argumentsFor(argument, operation), sql`, `)}))`
|
|
541
|
+
const clause = { [POLICY_CLAUSE[operation]]: condition }
|
|
542
|
+
|
|
543
|
+
return POLICY_BUILDERS[operation](
|
|
544
|
+
name?.(operation, tableName) ?? functionName,
|
|
545
|
+
{ to: authenticatedRole, ...clause }
|
|
546
|
+
)
|
|
547
|
+
})
|
|
548
|
+
}
|
|
549
|
+
|
|
116
550
|
export const authUserOwns = (ownerColumn: AnyPgColumn) =>
|
|
117
551
|
sql`${ownerColumn} = ${authUid}`
|
|
118
552
|
|
|
@@ -179,9 +613,26 @@ export const authenticatedOwnerAllPolicy = (
|
|
|
179
613
|
})
|
|
180
614
|
}
|
|
181
615
|
|
|
182
|
-
//
|
|
183
|
-
//
|
|
616
|
+
// Application-owned tables. Column keys stay camelCase, database identifiers
|
|
617
|
+
// become snake_case, and RLS is on.
|
|
184
618
|
export const table = snakeCase.table.withRLS
|
|
185
619
|
|
|
186
620
|
// Escape hatch for intentionally non-RLS tables such as seed/reference data.
|
|
187
621
|
export const unsecureTable = snakeCase.table
|
|
622
|
+
|
|
623
|
+
// A non-public schema, with the same two guarantees `table` and `unsecureTable`
|
|
624
|
+
// give at the top level. Drizzle's own `pgSchema(name)` takes no casing
|
|
625
|
+
// argument, so a schema built with it names every column after its TypeScript
|
|
626
|
+
// key; `snakeCase.schema` is the cased factory behind the same class.
|
|
627
|
+
// `.table` enables RLS and `.unsecureTable` is the escape hatch, so a table in
|
|
628
|
+
// a second schema doesn't have to remember `.withRLS`.
|
|
629
|
+
export const schema = <TName extends string>(name: TName) => {
|
|
630
|
+
const built = snakeCase.schema(name)
|
|
631
|
+
|
|
632
|
+
// `Object.assign` mutates and returns the PgSchema instance, so `isSchema`
|
|
633
|
+
// and the `entityKind` checks drizzle-kit runs still recognise it.
|
|
634
|
+
return Object.assign(built, {
|
|
635
|
+
table: built.table.withRLS,
|
|
636
|
+
unsecureTable: built.table,
|
|
637
|
+
})
|
|
638
|
+
}
|