@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.
@@ -1,4 +1,4 @@
1
- import { getTableColumns, sql } from "drizzle-orm"
1
+ import { getTableColumns, type SQL, sql } from "drizzle-orm"
2
2
  import {
3
3
  getTableConfig,
4
4
  isPgEnum,
@@ -6,11 +6,11 @@ import {
6
6
  isPgSchema,
7
7
  isPgSequence,
8
8
  isPgView,
9
+ PgDialect,
9
10
  pgEnum,
10
11
  pgMaterializedView,
11
12
  pgPolicy,
12
13
  pgRole,
13
- pgSchema,
14
14
  pgSequence,
15
15
  pgTableCreator,
16
16
  pgView,
@@ -19,23 +19,29 @@ import {
19
19
  } from "drizzle-orm/pg-core"
20
20
  import { camelCase, snakeCase } from "drizzle-orm/pg-core/casing"
21
21
  import { describe, expect, it } from "vitest"
22
+ import { authUsers } from "./auth-schema.ts"
22
23
  import { createAdminClient } from "./clients.ts"
23
- import { defineDrizzleConfig } from "./config.ts"
24
+ import { defineDrizzleConfig, supabaseManagedRoles } from "./config.ts"
24
25
  import {
25
26
  allPolicy,
27
+ assignedPrimaryId,
26
28
  auditColumns,
29
+ authenticatedAllPolicy,
30
+ authenticatedDeletePolicy,
31
+ authenticatedInsertPolicy,
27
32
  authenticatedOwnerDeletePolicy,
28
33
  authenticatedOwnerInsertPolicy,
29
34
  authenticatedOwnerSelectPolicy,
30
35
  authenticatedOwnerUpdatePolicy,
31
36
  authenticatedRole,
37
+ authenticatedSelectPolicy,
38
+ authenticatedUpdatePolicy,
32
39
  authorship,
33
- authUid,
34
40
  authUserId,
35
- authUsers,
36
41
  createdBy,
37
42
  deletePolicy,
38
43
  enum as enum_,
44
+ functionPolicies,
39
45
  insertPolicy,
40
46
  isEnum,
41
47
  isMaterializedView,
@@ -49,12 +55,15 @@ import {
49
55
  schema,
50
56
  selectPolicy,
51
57
  sequence,
58
+ sequentialPrimaryId,
52
59
  table,
53
60
  tableCreator,
54
61
  timestamps,
55
62
  unsecureTable,
56
63
  updatedBy,
57
64
  updatePolicy,
65
+ userId,
66
+ uuidPrimaryId,
58
67
  view,
59
68
  } from "./schema.ts"
60
69
 
@@ -63,6 +72,33 @@ describe("default casing", () => {
63
72
  expect(defineDrizzleConfig()).not.toHaveProperty("casing")
64
73
  })
65
74
 
75
+ it("excludes every Supabase-managed role from the role diff", () => {
76
+ const roles = defineDrizzleConfig().entities?.roles
77
+
78
+ expect(roles).toMatchObject({ provider: "supabase" })
79
+ expect(typeof roles === "object" ? roles.exclude : []).toEqual(
80
+ expect.arrayContaining([...supabaseManagedRoles])
81
+ )
82
+ })
83
+
84
+ it("keeps a caller's own excluded roles alongside the Supabase ones", () => {
85
+ const roles = defineDrizzleConfig({
86
+ entities: { roles: { exclude: ["my_reporting_role"] } },
87
+ }).entities?.roles
88
+ const exclude = typeof roles === "object" ? (roles.exclude ?? []) : []
89
+
90
+ expect(exclude).toContain("my_reporting_role")
91
+ expect(exclude).toContain("supabase_replication_admin")
92
+ expect(roles).toMatchObject({ provider: "supabase" })
93
+ })
94
+
95
+ it("diffs only the public schema unless told otherwise", () => {
96
+ expect(defineDrizzleConfig().schemaFilter).toEqual(["public"])
97
+ expect(
98
+ defineDrizzleConfig({ schemaFilter: ["public", "billing"] }).schemaFilter
99
+ ).toEqual(["public", "billing"])
100
+ })
101
+
66
102
  it("works with Drizzle's snake_case table builders", async () => {
67
103
  const posts = snakeCase.table("posts", {
68
104
  displayName: text(),
@@ -127,38 +163,254 @@ describe("default casing", () => {
127
163
  expect(materializedView).toBe(pgMaterializedView)
128
164
  expect(policy).toBe(pgPolicy)
129
165
  expect(role).toBe(pgRole)
130
- expect(schema).toBe(pgSchema)
131
166
  expect(sequence).toBe(pgSequence)
132
167
  expect(tableCreator).toBe(pgTableCreator)
133
168
  expect(view).toBe(pgView)
134
169
  })
135
170
 
136
- it("exports common ID and Supabase auth-user column helpers", () => {
171
+ it("exports a Supabase auth-user column helper", () => {
137
172
  const posts = table("posts", {
138
173
  id: primaryId("uuid"),
139
174
  ownerId: authUserId(),
140
- sequentialId: primaryId("sequential"),
141
175
  })
142
176
  const columns = getTableColumns(posts)
143
177
  const config = getTableConfig(posts)
178
+ const foreignKey = config.foreignKeys[0]
179
+
180
+ expect(columns.ownerId.getSQLType()).toBe("uuid")
181
+ // Nullable so deleting the user blanks the author instead of failing.
182
+ expect(columns.ownerId.notNull).toBe(false)
183
+ expect(config.foreignKeys).toHaveLength(1)
184
+ expect(foreignKey?.reference().foreignTable).toBe(authUsers)
185
+ expect(foreignKey?.onDelete).toBe("set null")
186
+ expect(foreignKey?.onUpdate).toBe("cascade")
187
+ })
188
+
189
+ it("restricts the delete when an author column is required", () => {
190
+ const posts = table("posts", { ownerId: authUserId({ notNull: true }) })
191
+ const foreignKey = getTableConfig(posts).foreignKeys[0]
192
+
193
+ expect(getTableColumns(posts).ownerId.notNull).toBe(true)
194
+ // `set null` against a NOT NULL column is a foreign key that can never
195
+ // fire, so a required author restricts instead.
196
+ expect(foreignKey?.onDelete).toBe("restrict")
197
+ expect(foreignKey?.onUpdate).toBe("cascade")
198
+ })
199
+
200
+ it("takes an explicit column name and reference actions", () => {
201
+ const posts = table("posts", {
202
+ ownerId: authUserId({
203
+ actions: { onDelete: "cascade", onUpdate: "no action" },
204
+ name: "owner_id",
205
+ }),
206
+ })
207
+ const foreignKey = getTableConfig(posts).foreignKeys[0]
208
+
209
+ expect(getTableColumns(posts).ownerId.name).toBe("owner_id")
210
+ expect(foreignKey?.onDelete).toBe("cascade")
211
+ expect(foreignKey?.onUpdate).toBe("no action")
212
+ })
213
+
214
+ it("drops the foreign key entirely when the reference is null", () => {
215
+ const posts = table("posts", {
216
+ ownerId: authUserId({ reference: null }),
217
+ ...authorship({ reference: null }),
218
+ })
219
+
220
+ expect(getTableConfig(posts).foreignKeys).toHaveLength(0)
221
+ expect(getTableColumns(posts).ownerId.getSQLType()).toBe("uuid")
222
+ expect(getTableColumns(posts).createdBy.name).toBe("created_by")
223
+ })
224
+
225
+ it("points author columns at a public profiles mirror", () => {
226
+ const profiles = table("profiles", {
227
+ id: uuidPrimaryId({ defaultRandom: false }),
228
+ })
229
+ const posts = table("posts", {
230
+ ownerId: userId(() => profiles.id),
231
+ ...authorship({ reference: () => profiles.id }),
232
+ })
233
+ const foreignKeys = getTableConfig(posts).foreignKeys
234
+
235
+ expect(foreignKeys).toHaveLength(3)
236
+ for (const foreignKey of foreignKeys) {
237
+ expect(foreignKey.reference().foreignTable).toBe(profiles)
238
+ expect(foreignKey.onDelete).toBe("set null")
239
+ }
240
+ expect(getTableColumns(posts).ownerId.notNull).toBe(false)
241
+ })
242
+
243
+ it("builds the same column through userId as through authUserId", () => {
244
+ const viaAuth = table("a", { ownerId: authUserId({ notNull: true }) })
245
+ const viaUserId = table("b", {
246
+ ownerId: userId(() => authUsers.id, { notNull: true }),
247
+ })
248
+ const authForeignKey = getTableConfig(viaAuth).foreignKeys[0]
249
+ const userForeignKey = getTableConfig(viaUserId).foreignKeys[0]
144
250
 
251
+ expect(getTableColumns(viaUserId).ownerId.notNull).toBe(
252
+ getTableColumns(viaAuth).ownerId.notNull
253
+ )
254
+ expect(userForeignKey?.reference().foreignTable).toBe(
255
+ authForeignKey?.reference().foreignTable
256
+ )
257
+ expect(userForeignKey?.onDelete).toBe(authForeignKey?.onDelete)
258
+ })
259
+
260
+ // Each helper gets its own table: all three name the column "id" by default,
261
+ // and Drizzle's setName returns early once a name is set, so two of them in
262
+ // one table would silently share the name.
263
+ it("builds a random-UUID primary key", () => {
264
+ const columns = getTableColumns(table("posts", { id: primaryId("uuid") }))
265
+
266
+ expect(columns.id.name).toBe("id")
145
267
  expect(columns.id.getSQLType()).toBe("uuid")
146
268
  expect(columns.id.primary).toBe(true)
147
269
  expect(columns.id.default).toBeDefined()
148
- expect(columns.sequentialId.getSQLType()).toBe("integer")
149
- expect(columns.sequentialId.primary).toBe(true)
150
- expect(columns.sequentialId.generatedIdentity?.type).toBe("always")
151
- expect(columns.ownerId.getSQLType()).toBe("uuid")
152
- expect(columns.ownerId.notNull).toBe(true)
153
- expect(config.foreignKeys).toHaveLength(1)
154
- expect(config.foreignKeys[0]?.reference().foreignTable).toBe(authUsers)
270
+ expect(columns.id.generatedIdentity).toBeUndefined()
271
+ })
272
+
273
+ it("builds a UUID primary key without a default", () => {
274
+ const columns = getTableColumns(
275
+ table("profiles", { id: uuidPrimaryId({ defaultRandom: false }) })
276
+ )
277
+
278
+ expect(columns.id.getSQLType()).toBe("uuid")
279
+ expect(columns.id.primary).toBe(true)
280
+ expect(columns.id.hasDefault).toBe(false)
281
+ expect(columns.id.default).toBeUndefined()
282
+ })
283
+
284
+ it("defaults a sequential primary key to Supabase's bigint by-default shape", () => {
285
+ const columns = getTableColumns(
286
+ table("posts", { id: primaryId("sequential") })
287
+ )
288
+
289
+ expect(columns.id.name).toBe("id")
290
+ expect(columns.id.getSQLType()).toBe("bigint")
291
+ expect(columns.id.primary).toBe(true)
292
+ expect(columns.id.generatedIdentity?.type).toBe("byDefault")
293
+ })
294
+
295
+ it("builds always-generated, integer, and bigint-mode sequential keys", () => {
296
+ const always = getTableColumns(
297
+ table("always", { id: sequentialPrimaryId({ generated: "always" }) })
298
+ )
299
+ const int = getTableColumns(
300
+ table("int", { id: sequentialPrimaryId({ type: "integer" }) })
301
+ )
302
+ const big = getTableColumns(
303
+ table("big", { id: sequentialPrimaryId({ mode: "bigint" }) })
304
+ )
305
+
306
+ expect(always.id.getSQLType()).toBe("bigint")
307
+ expect(always.id.generatedIdentity?.type).toBe("always")
308
+ expect(int.id.getSQLType()).toBe("integer")
309
+ expect(int.id.generatedIdentity?.type).toBe("byDefault")
310
+ expect(big.id.getSQLType()).toBe("bigint")
311
+ expect(big.id.generatedIdentity?.type).toBe("byDefault")
312
+ })
313
+
314
+ it("builds an application-assigned primary key with no default", () => {
315
+ const columns = getTableColumns(
316
+ table("invoices", { id: primaryId("assigned") })
317
+ )
318
+ const sized = getTableColumns(
319
+ table("sized", { id: assignedPrimaryId({ length: 32 }) })
320
+ )
321
+
322
+ expect(columns.id.name).toBe("id")
323
+ expect(columns.id.getSQLType()).toBe("varchar")
324
+ expect(columns.id.primary).toBe(true)
325
+ expect(columns.id.default).toBeUndefined()
326
+ expect(columns.id.generatedIdentity).toBeUndefined()
327
+ expect(sized.id.getSQLType()).toBe("varchar(32)")
328
+ })
329
+
330
+ it("lets every primary key helper override the column name", () => {
331
+ const columns = getTableColumns(
332
+ table("posts", {
333
+ assignedKey: assignedPrimaryId({ name: "assigned_key" }),
334
+ sequentialKey: sequentialPrimaryId({ name: "sequential_key" }),
335
+ uuidKey: uuidPrimaryId({ name: "uuid_key" }),
336
+ })
337
+ )
338
+
339
+ expect(columns.uuidKey.name).toBe("uuid_key")
340
+ expect(columns.sequentialKey.name).toBe("sequential_key")
341
+ expect(columns.assignedKey.name).toBe("assigned_key")
342
+ })
343
+
344
+ it("keeps a UUID key chainable into a cascading auth.users reference", () => {
345
+ const profiles = table("profiles", {
346
+ id: uuidPrimaryId({ defaultRandom: false }).references(
347
+ () => authUsers.id,
348
+ {
349
+ onDelete: "cascade",
350
+ onUpdate: "cascade",
351
+ }
352
+ ),
353
+ })
354
+ const columns = getTableColumns(profiles)
355
+ const foreignKey = getTableConfig(profiles).foreignKeys[0]
356
+
357
+ expect(columns.id.primary).toBe(true)
358
+ expect(columns.id.notNull).toBe(true)
359
+ expect(columns.id.default).toBeUndefined()
360
+ expect(foreignKey?.reference().foreignTable).toBe(authUsers)
361
+ expect(foreignKey?.onDelete).toBe("cascade")
362
+ expect(foreignKey?.onUpdate).toBe("cascade")
363
+ })
364
+
365
+ it("keeps the random default when a UUID key also references auth.users", () => {
366
+ const profiles = table("profiles", {
367
+ id: uuidPrimaryId().references(() => authUsers.id, {
368
+ onDelete: "cascade",
369
+ onUpdate: "cascade",
370
+ }),
371
+ })
372
+ const columns = getTableColumns(profiles)
373
+ const foreignKey = getTableConfig(profiles).foreignKeys[0]
374
+
375
+ expect(columns.id.primary).toBe(true)
376
+ expect(columns.id.default).toBeDefined()
377
+ expect(foreignKey?.reference().foreignTable).toBe(authUsers)
378
+ expect(foreignKey?.onDelete).toBe("cascade")
379
+ })
380
+
381
+ it("routes every primaryId kind to its dedicated helper", () => {
382
+ const selected = {
383
+ assigned: getTableColumns(table("t", { id: primaryId("assigned") })).id,
384
+ sequential: getTableColumns(table("t", { id: primaryId("sequential") }))
385
+ .id,
386
+ uuid: getTableColumns(table("t", { id: primaryId("uuid") })).id,
387
+ }
388
+ const direct = {
389
+ assigned: getTableColumns(table("t", { id: assignedPrimaryId() })).id,
390
+ sequential: getTableColumns(table("t", { id: sequentialPrimaryId() })).id,
391
+ uuid: getTableColumns(table("t", { id: uuidPrimaryId() })).id,
392
+ }
393
+
394
+ for (const kind of ["uuid", "sequential", "assigned"] as const) {
395
+ expect(selected[kind].getSQLType()).toBe(direct[kind].getSQLType())
396
+ expect(selected[kind].primary).toBe(direct[kind].primary)
397
+ expect(selected[kind].hasDefault).toBe(direct[kind].hasDefault)
398
+ expect(selected[kind].generatedIdentity?.type).toBe(
399
+ direct[kind].generatedIdentity?.type
400
+ )
401
+ }
402
+
403
+ // The bare call is Supabase's own default for a new table.
404
+ expect(
405
+ getTableColumns(table("t", { id: primaryId() })).id.getSQLType()
406
+ ).toBe("bigint")
155
407
  })
156
408
 
157
409
  it("exports audit timestamp and auth-user column helpers with runtime defaults", () => {
158
410
  const posts = table("posts", {
159
- createdBy,
160
- updatedBy,
161
- ...timestamps,
411
+ createdBy: createdBy(),
412
+ updatedBy: updatedBy(),
413
+ ...timestamps(),
162
414
  })
163
415
  const columns = getTableColumns(posts)
164
416
  const config = getTableConfig(posts)
@@ -170,15 +422,16 @@ describe("default casing", () => {
170
422
  expect(columns.updatedAt.name).toBe("updated_at")
171
423
  expect(columns.updatedAt.notNull).toBe(true)
172
424
  expect(columns.updatedAt.default).toBeDefined()
173
- expect(columns.updatedAt.onUpdateFn?.()).toBeInstanceOf(Date)
425
+ expect(columns.updatedAt.onUpdateFn).toBeUndefined()
174
426
  expect(columns.createdBy.name).toBe("created_by")
175
- expect(columns.createdBy.notNull).toBe(true)
176
- expect(columns.createdBy.default).toBe(authUid)
427
+ // Nullable by default so a user delete blanks the author, not fails.
428
+ expect(columns.createdBy.notNull).toBe(false)
429
+ expect(columns.createdBy.default).toBeDefined()
177
430
  expect(columns.createdBy.onUpdateFn).toBeUndefined()
178
431
  expect(columns.updatedBy.name).toBe("updated_by")
179
- expect(columns.updatedBy.notNull).toBe(true)
180
- expect(columns.updatedBy.default).toBe(authUid)
181
- expect(columns.updatedBy.onUpdateFn?.()).toBe(authUid)
432
+ expect(columns.updatedBy.notNull).toBe(false)
433
+ expect(columns.updatedBy.default).toBeDefined()
434
+ expect(columns.updatedBy.onUpdateFn).toBeUndefined()
182
435
  expect(config.foreignKeys).toHaveLength(2)
183
436
  expect(
184
437
  config.foreignKeys.map(
@@ -187,10 +440,76 @@ describe("default casing", () => {
187
440
  ).toEqual([authUsers, authUsers])
188
441
  })
189
442
 
443
+ it("applies one options object to both author columns", () => {
444
+ const posts = table("posts", {
445
+ ...auditColumns({ notNull: true }),
446
+ })
447
+ const columns = getTableColumns(posts)
448
+ const foreignKeys = getTableConfig(posts).foreignKeys
449
+
450
+ expect(columns.createdBy.notNull).toBe(true)
451
+ expect(columns.updatedBy.notNull).toBe(true)
452
+ expect(foreignKeys.map((foreignKey) => foreignKey.onDelete)).toEqual([
453
+ "restrict",
454
+ "restrict",
455
+ ])
456
+ // Timestamps are unaffected by the authorship options.
457
+ expect(columns.createdAt.notNull).toBe(true)
458
+ })
459
+
460
+ it("does not add updated_at to an UPDATE Drizzle builds", async () => {
461
+ const posts = table("posts", { title: text(), ...timestamps() })
462
+ const db = createAdminClient()
463
+ const { params, sql: statement } = db
464
+ .update(posts)
465
+ .set({ title: "hello" })
466
+ .toSQL()
467
+
468
+ // Drizzle no longer touches the column, so the trigger is the only thing
469
+ // that sets it and every writer gets the same behaviour.
470
+ expect(statement).not.toContain('"updated_at"')
471
+ expect(params).toEqual(["hello"])
472
+
473
+ await db.close()
474
+ })
475
+
476
+ it("leaves the update side of the audit columns to Postgres", () => {
477
+ const posts = table("posts", { ...auditColumns() })
478
+ const columns = getTableColumns(posts)
479
+
480
+ // No Drizzle-side hooks at all: `$onUpdateFn` is applied while Drizzle
481
+ // builds its own statement, so it would miss every PostgREST write. The
482
+ // triggers in @zeno-lib/db/triggers own these columns instead.
483
+ expect(columns.updatedAt.onUpdateFn).toBeUndefined()
484
+ expect(columns.updatedBy.onUpdateFn).toBeUndefined()
485
+
486
+ // The insert side stays real SQL, so it covers every writer.
487
+ expect(columns.createdAt.default).toBeDefined()
488
+ expect(columns.updatedAt.default).toBeDefined()
489
+ expect(columns.createdBy.default).toBeDefined()
490
+ expect(columns.updatedBy.default).toBeDefined()
491
+ })
492
+
493
+ it("passes timezone and precision through to the column type", () => {
494
+ const naive = table("naive", { ...timestamps({ withTimezone: false }) })
495
+ const precise = table("precise", { ...timestamps({ precision: 3 }) })
496
+ const audited = table("audited", {
497
+ ...auditColumns({ precision: 0, withTimezone: false }),
498
+ })
499
+
500
+ expect(getTableColumns(naive).createdAt.getSQLType()).toBe("timestamp")
501
+ expect(getTableColumns(precise).updatedAt.getSQLType()).toBe(
502
+ "timestamp (3) with time zone"
503
+ )
504
+ expect(getTableColumns(audited).createdAt.getSQLType()).toBe(
505
+ "timestamp (0)"
506
+ )
507
+ })
508
+
190
509
  it("exports grouped audit column mixins", () => {
191
- expect(Object.keys(timestamps)).toEqual(["createdAt", "updatedAt"])
192
- expect(Object.keys(authorship)).toEqual(["createdBy", "updatedBy"])
193
- expect(Object.keys(auditColumns)).toEqual([
510
+ expect(Object.keys(timestamps())).toEqual(["createdAt", "updatedAt"])
511
+ expect(Object.keys(authorship())).toEqual(["createdBy", "updatedBy"])
512
+ expect(Object.keys(auditColumns())).toEqual([
194
513
  "createdAt",
195
514
  "updatedAt",
196
515
  "createdBy",
@@ -199,7 +518,7 @@ describe("default casing", () => {
199
518
 
200
519
  const posts = table("posts", {
201
520
  id: primaryId("uuid"),
202
- ...auditColumns,
521
+ ...auditColumns(),
203
522
  })
204
523
  const columns = getTableColumns(posts)
205
524
 
@@ -209,6 +528,40 @@ describe("default casing", () => {
209
528
  expect(columns.updatedBy.name).toBe("updated_by")
210
529
  })
211
530
 
531
+ it("builds a fresh column builder on every audit mixin call", () => {
532
+ expect(timestamps().createdAt).not.toBe(timestamps().createdAt)
533
+ expect(authorship().createdBy).not.toBe(authorship().createdBy)
534
+ expect(createdBy()).not.toBe(createdBy())
535
+ expect(updatedBy()).not.toBe(updatedBy())
536
+ })
537
+
538
+ it("keeps tables built from separate audit mixin calls independent", () => {
539
+ const postsAuthorship = authorship()
540
+ const commentsAuthorship = authorship()
541
+ const posts = table("posts", {
542
+ ...postsAuthorship,
543
+ createdBy: postsAuthorship.createdBy.unique(),
544
+ })
545
+ const comments = table("comments", { ...commentsAuthorship })
546
+
547
+ expect(getTableColumns(posts).createdBy.isUnique).toBe(true)
548
+ expect(getTableColumns(comments).createdBy.isUnique).toBe(false)
549
+ })
550
+
551
+ it("does not share foreign keys between audit mixin calls", () => {
552
+ const profiles = table("profiles", { id: primaryId("uuid") })
553
+ const postsAudit = auditColumns()
554
+ const commentsAudit = auditColumns()
555
+ const posts = table("posts", {
556
+ ...postsAudit,
557
+ createdBy: postsAudit.createdBy.references(() => profiles.id),
558
+ })
559
+ const comments = table("comments", { ...commentsAudit })
560
+
561
+ expect(getTableConfig(posts).foreignKeys).toHaveLength(3)
562
+ expect(getTableConfig(comments).foreignKeys).toHaveLength(2)
563
+ })
564
+
212
565
  it("exports generic policy helpers that set the policy operation", () => {
213
566
  const condition = sql`true`
214
567
 
@@ -261,4 +614,135 @@ describe("default casing", () => {
261
614
  expect(policies[2]?.withCheck).toBeDefined()
262
615
  expect(policies[3]?.using).toBeDefined()
263
616
  })
617
+
618
+ it("cases column names in a non-public schema", async () => {
619
+ const billing = schema("billing")
620
+ const invoices = billing.table("invoices", {
621
+ displayName: text(),
622
+ ownerId: uuid(),
623
+ })
624
+ const db = createAdminClient()
625
+
626
+ expect(db.select().from(invoices).toSQL().sql).toContain('"display_name"')
627
+ expect(db.select().from(invoices).toSQL().sql).toContain('"owner_id"')
628
+ expect(getTableConfig(invoices).schema).toBe("billing")
629
+
630
+ await db.close()
631
+ })
632
+
633
+ it("enables RLS on a schema table and leaves unsecureTable alone", () => {
634
+ const billing = schema("billing")
635
+
636
+ expect(
637
+ getTableConfig(billing.table("invoices", { ownerId: uuid() })).enableRLS
638
+ ).toBe(true)
639
+ expect(
640
+ getTableConfig(billing.unsecureTable("rates", { ownerId: uuid() }))
641
+ .enableRLS
642
+ ).toBe(false)
643
+ })
644
+
645
+ it("stays a drizzle schema so the rest of its builders still work", () => {
646
+ const billing = schema("billing")
647
+
648
+ expect(isSchema(billing)).toBe(true)
649
+ expect(billing.schemaName).toBe("billing")
650
+ expect(billing.existing().isExisting).toBe(true)
651
+ expect(isEnum(billing.enum("plan", ["free", "paid"]))).toBe(true)
652
+ expect(isSequence(billing.sequence("invoice_no"))).toBe(true)
653
+ })
654
+
655
+ it("presets the authenticated role and leaves the condition to the caller", () => {
656
+ const condition = sql`true`
657
+ const presets = [
658
+ authenticatedSelectPolicy("s", { using: condition }),
659
+ authenticatedInsertPolicy("i", { withCheck: condition }),
660
+ authenticatedUpdatePolicy("u", { using: condition }),
661
+ authenticatedDeletePolicy("d", { using: condition }),
662
+ authenticatedAllPolicy("a", { using: condition }),
663
+ ]
664
+
665
+ expect(presets.map((preset) => preset.for)).toEqual([
666
+ "select",
667
+ "insert",
668
+ "update",
669
+ "delete",
670
+ "all",
671
+ ])
672
+ for (const preset of presets) {
673
+ expect(preset.to).toBe(authenticatedRole)
674
+ }
675
+ // No owner check is assumed; the caller's condition is what lands.
676
+ expect(presets[0]?.using).toBe(condition)
677
+ expect(presets[1]?.withCheck).toBe(condition)
678
+ })
679
+
680
+ it("delegates each operation to a security definer function", async () => {
681
+ const posts = table("posts", { id: primaryId("uuid") }, (t) =>
682
+ functionPolicies(t, { argument: t.id })
683
+ )
684
+ const policies = getTableConfig(posts).policies
685
+ const db = createAdminClient()
686
+
687
+ expect(policies.map((rlsPolicy) => rlsPolicy.name)).toEqual([
688
+ "can_select_posts",
689
+ "can_insert_posts",
690
+ "can_update_posts",
691
+ "can_delete_posts",
692
+ ])
693
+ for (const rlsPolicy of policies) {
694
+ expect(rlsPolicy.to).toBe(authenticatedRole)
695
+ }
696
+ // using for select and delete, withCheck for insert, both for update.
697
+ expect(policies[0]?.using).toBeDefined()
698
+ expect(policies[0]?.withCheck).toBeUndefined()
699
+ expect(policies[1]?.using).toBeUndefined()
700
+ expect(policies[1]?.withCheck).toBeDefined()
701
+ expect(policies[2]?.using).toBeDefined()
702
+ expect(policies[2]?.withCheck).toBeDefined()
703
+ expect(policies[3]?.using).toBeDefined()
704
+ expect(policies[3]?.withCheck).toBeUndefined()
705
+
706
+ const dialect = new PgDialect()
707
+
708
+ // The shape the issue asks for: the call wrapped in a select, with the
709
+ // column passed through.
710
+ expect(dialect.sqlToQuery(policies[0]?.using as SQL).sql).toBe(
711
+ '(select "can_select_posts"("posts"."id"))'
712
+ )
713
+ expect(dialect.sqlToQuery(policies[2]?.withCheck as SQL).sql).toBe(
714
+ '(select "can_update_posts"("posts"."id"))'
715
+ )
716
+
717
+ expect(db.select().from(posts).toSQL().sql).toBeDefined()
718
+ await db.close()
719
+ })
720
+
721
+ it("calls the function with no argument when none is given", () => {
722
+ const tags = table("tags", { id: primaryId("uuid") }, (t) =>
723
+ functionPolicies(t)
724
+ )
725
+ const policies = getTableConfig(tags).policies
726
+
727
+ expect(policies).toHaveLength(4)
728
+ expect(new PgDialect().sqlToQuery(policies[0]?.using as SQL).sql).toBe(
729
+ '(select "can_select_tags"())'
730
+ )
731
+ })
732
+
733
+ it("takes a prefix and a policy name override", () => {
734
+ const posts = table("posts", { id: primaryId("uuid") }, (t) =>
735
+ functionPolicies(t, {
736
+ name: (operation, tableName) => `${tableName}_${operation}`,
737
+ prefix: "may",
738
+ })
739
+ )
740
+
741
+ expect(getTableConfig(posts).policies.map((p) => p.name)).toEqual([
742
+ "posts_select",
743
+ "posts_insert",
744
+ "posts_update",
745
+ "posts_delete",
746
+ ])
747
+ })
264
748
  })