@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/src/schema.ts CHANGED
@@ -1,18 +1,32 @@
1
- // https://orm.drizzle.team/docs/rls#using-with-supabase (re-exported roles, authUsers, authUid, realtimeMessages)
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, authUsers } from "drizzle-orm/supabase"
25
+ import { authenticatedRole, authUid } from "drizzle-orm/supabase"
26
+ import { authUsers } from "./auth-schema.ts"
13
27
 
14
- // Curated pg-core aliases for schema primitives that otherwise repeat the pg
15
- // prefix at every call site. `table` is Zeno's RLS-by-default helper below.
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
- // Curated Supabase primitives from drizzle-orm/supabase so consumers can import
34
- // roles, the auth.users table, and helpers from one Zeno-owned schema entrypoint.
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
- // Reusable created_at / updated_at columns — spread into a pgTable column map.
48
- export const timestamps = {
49
- createdAt: timestamp("created_at", { withTimezone: true })
50
- .notNull()
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
- export const authUserId = (name?: string) =>
59
- uuid(name)
60
- .notNull()
61
- .references(() => authUsers.id)
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
- export const createdBy = authUserId("created_by").default(authUid)
64
- export const updatedBy = authUserId("updated_by")
65
- .default(authUid)
66
- .$onUpdate(() => authUid)
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 const authorship = {
69
- createdBy,
70
- updatedBy,
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 const auditColumns = {
74
- ...timestamps,
75
- ...authorship,
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
- const uuidPrimaryId = () => uuid("id").primaryKey().defaultRandom()
79
- const sequentialPrimaryId = () =>
80
- integer("id").primaryKey().generatedAlwaysAsIdentity()
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
- export function primaryId(kind?: "uuid"): ReturnType<typeof uuidPrimaryId>
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
- // Default table helper for application-owned tables: TypeScript columns stay
183
- // camelCase, database identifiers become snake_case, and RLS is enabled.
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
+ }