@zeno-lib/db 0.2.0 → 0.3.1

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 CHANGED
@@ -1,15 +1,36 @@
1
1
  {
2
2
  "name": "@zeno-lib/db",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "exports": {
6
- ".": "./src/index.ts",
7
- "./auth": "./src/auth-schema.ts",
8
- "./config": "./src/config.ts",
9
- "./schema": "./src/schema.ts",
10
- "./triggers": "./src/triggers.ts"
6
+ ".": {
7
+ "types": "./dist/index.d.mts",
8
+ "import": "./dist/index.mjs",
9
+ "default": "./dist/index.mjs"
10
+ },
11
+ "./auth": {
12
+ "types": "./dist/auth-schema.d.mts",
13
+ "import": "./dist/auth-schema.mjs",
14
+ "default": "./dist/auth-schema.mjs"
15
+ },
16
+ "./config": {
17
+ "types": "./dist/config.d.mts",
18
+ "import": "./dist/config.mjs",
19
+ "default": "./dist/config.mjs"
20
+ },
21
+ "./schema": {
22
+ "types": "./dist/schema.d.mts",
23
+ "import": "./dist/schema.mjs",
24
+ "default": "./dist/schema.mjs"
25
+ },
26
+ "./triggers": {
27
+ "types": "./dist/triggers.d.mts",
28
+ "import": "./dist/triggers.mjs",
29
+ "default": "./dist/triggers.mjs"
30
+ }
11
31
  },
12
32
  "files": [
33
+ "dist",
13
34
  "src"
14
35
  ],
15
36
  "publishConfig": {
@@ -33,12 +54,15 @@
33
54
  "drizzle-orm": "1.0.0-rc.3",
34
55
  "postgres": "3.4.7",
35
56
  "supabase": "2.84.1",
57
+ "tsdown": "^0.22.14",
58
+ "typescript": "7.0.2",
36
59
  "vite": "8.0.16",
37
60
  "vitest": "4.1.0",
38
61
  "@zeno-lib/test": "0.0.3",
39
62
  "@zeno-lib/typescript": "^1.1.0"
40
63
  },
41
64
  "scripts": {
65
+ "build": "tsdown",
42
66
  "dev": "pnpm exec supabase start",
43
67
  "stop": "pnpm exec supabase stop",
44
68
  "reset": "pnpm exec supabase db reset",
@@ -1,5 +1,6 @@
1
1
  import { getTableColumns, type SQL, sql } from "drizzle-orm"
2
2
  import {
3
+ bigint,
3
4
  getTableConfig,
4
5
  isPgEnum,
5
6
  isPgMaterializedView,
@@ -693,13 +694,16 @@ describe("default casing", () => {
693
694
  for (const rlsPolicy of policies) {
694
695
  expect(rlsPolicy.to).toBe(authenticatedRole)
695
696
  }
696
- // using for select and delete, withCheck for insert, both for update.
697
+ // using for select, update and delete, withCheck for insert. update takes
698
+ // both clauses, but Postgres reuses using for the check, so spelling out a
699
+ // second identical expression would only add a polwithcheck that a
700
+ // hand-written USING-only policy does not have.
697
701
  expect(policies[0]?.using).toBeDefined()
698
702
  expect(policies[0]?.withCheck).toBeUndefined()
699
703
  expect(policies[1]?.using).toBeUndefined()
700
704
  expect(policies[1]?.withCheck).toBeDefined()
701
705
  expect(policies[2]?.using).toBeDefined()
702
- expect(policies[2]?.withCheck).toBeDefined()
706
+ expect(policies[2]?.withCheck).toBeUndefined()
703
707
  expect(policies[3]?.using).toBeDefined()
704
708
  expect(policies[3]?.withCheck).toBeUndefined()
705
709
 
@@ -710,7 +714,7 @@ describe("default casing", () => {
710
714
  expect(dialect.sqlToQuery(policies[0]?.using as SQL).sql).toBe(
711
715
  '(select "can_select_posts"("posts"."id"))'
712
716
  )
713
- expect(dialect.sqlToQuery(policies[2]?.withCheck as SQL).sql).toBe(
717
+ expect(dialect.sqlToQuery(policies[2]?.using as SQL).sql).toBe(
714
718
  '(select "can_update_posts"("posts"."id"))'
715
719
  )
716
720
 
@@ -745,4 +749,70 @@ describe("default casing", () => {
745
749
  "posts_delete",
746
750
  ])
747
751
  })
752
+
753
+ it("varies the argument per operation, insert usually taking none", () => {
754
+ const deals = table(
755
+ "deals",
756
+ { id: primaryId("assigned"), ownerId: uuid() },
757
+ (t) => functionPolicies(t, { argument: { delete: t.id, select: t.id } })
758
+ )
759
+ const policies = getTableConfig(deals).policies
760
+ const dialect = new PgDialect()
761
+
762
+ // select and delete get the row; insert and update fall to no arguments,
763
+ // insert because a missing key means "called with none" and update because
764
+ // it was left out of the record too.
765
+ expect(dialect.sqlToQuery(policies[0]?.using as SQL).sql).toBe(
766
+ '(select "can_select_deals"("deals"."id"))'
767
+ )
768
+ expect(dialect.sqlToQuery(policies[1]?.withCheck as SQL).sql).toBe(
769
+ '(select "can_insert_deals"())'
770
+ )
771
+ expect(dialect.sqlToQuery(policies[2]?.using as SQL).sql).toBe(
772
+ '(select "can_update_deals"())'
773
+ )
774
+ expect(dialect.sqlToQuery(policies[3]?.using as SQL).sql).toBe(
775
+ '(select "can_delete_deals"("deals"."id"))'
776
+ )
777
+ })
778
+
779
+ it("reads an explicit null as a call with no arguments", () => {
780
+ const deals = table("deals", { id: primaryId("assigned") }, (t) =>
781
+ functionPolicies(t, { argument: { insert: null, select: t.id } })
782
+ )
783
+ const policies = getTableConfig(deals).policies
784
+
785
+ expect(new PgDialect().sqlToQuery(policies[1]?.withCheck as SQL).sql).toBe(
786
+ '(select "can_insert_deals"())'
787
+ )
788
+ })
789
+
790
+ it("passes several columns to a function that takes several", () => {
791
+ const investors = table(
792
+ "investors",
793
+ {
794
+ id: primaryId("uuid"),
795
+ organisationId: bigint({ mode: "number" }),
796
+ profileId: uuid(),
797
+ },
798
+ (t) => functionPolicies(t, { argument: [t.profileId, t.organisationId] })
799
+ )
800
+ const policies = getTableConfig(investors).policies
801
+
802
+ expect(new PgDialect().sqlToQuery(policies[0]?.using as SQL).sql).toBe(
803
+ '(select "can_select_investors"("investors"."profile_id", "investors"."organisation_id"))'
804
+ )
805
+ })
806
+
807
+ it("qualifies the function with a schema when one is given", () => {
808
+ const bexio = schema("bexio")
809
+ const bills = bexio.table("bexio_bills", { id: primaryId("uuid") }, (t) =>
810
+ functionPolicies(t, { schema: "bexio" })
811
+ )
812
+ const policies = getTableConfig(bills).policies
813
+
814
+ expect(new PgDialect().sqlToQuery(policies[0]?.using as SQL).sql).toBe(
815
+ '(select "bexio"."can_select_bexio_bills"())'
816
+ )
817
+ })
748
818
  })
package/src/schema.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // https://orm.drizzle.team/docs/rls#using-with-supabase (re-exported roles, authUid, realtimeMessages)
2
- import { getColumnTable, getTableName, sql } from "drizzle-orm"
2
+ import { Column, getColumnTable, getTableName, is, sql } from "drizzle-orm"
3
3
  import {
4
4
  type AnyPgColumn,
5
5
  bigint,
@@ -393,15 +393,18 @@ export const authenticatedAllPolicy = (
393
393
  config: AuthenticatedPolicyOptions = {}
394
394
  ) => allPolicy(name, { ...config, to: authenticatedRole })
395
395
 
396
- // Postgres takes the condition in a different clause per operation: `using`
397
- // filters the rows already there, `withCheck` vets the rows going in.
398
- const POLICY_CLAUSES = {
399
- all: ["using", "withCheck"],
400
- delete: ["using"],
401
- insert: ["withCheck"],
402
- select: ["using"],
403
- update: ["using", "withCheck"],
404
- } as const satisfies Record<PolicyOperation, readonly ("using" | "withCheck")[]>
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">
405
408
 
406
409
  const POLICY_BUILDERS = {
407
410
  all: allPolicy,
@@ -418,15 +421,62 @@ const FUNCTION_POLICY_OPERATIONS = [
418
421
  "delete",
419
422
  ] as const
420
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
+
421
429
  type FunctionPoliciesOptions = {
422
- /** Passed to each function, e.g. the row's id. Omit for a function that takes none. */
423
- argument?: AnyPgColumn
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
424
447
  /** Prefix for the default function and policy names. Default `"can"`. */
425
448
  prefix?: string
426
449
  /** Overrides the generated policy name. */
427
450
  name?: (operation: PolicyOperation, table: string) => string
428
451
  }
429
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
+
430
480
  // The columns object drizzle hands the extra-config callback. Taking it rather
431
481
  // than the table is what keeps `functionPolicies` usable: naming the table
432
482
  // inside its own definition makes its type circular, so `(t) => ...` is the
@@ -450,10 +500,24 @@ type ExtraConfigColumns = Record<string, ExtraConfigColumn>
450
500
  * CREATE POLICY "can_select_posts" ON "posts" FOR SELECT TO "authenticated"
451
501
  * USING ((select "can_select_posts"("posts"."id")));
452
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
+ * ```
453
512
  */
454
513
  export const functionPolicies = (
455
514
  columns: ExtraConfigColumns,
456
- { argument, name, prefix = "can" }: FunctionPoliciesOptions = {}
515
+ {
516
+ argument,
517
+ name,
518
+ prefix = "can",
519
+ schema: functionSchema,
520
+ }: FunctionPoliciesOptions = {}
457
521
  ) => {
458
522
  const [firstColumn] = Object.values(columns)
459
523
 
@@ -465,19 +529,20 @@ export const functionPolicies = (
465
529
 
466
530
  return FUNCTION_POLICY_OPERATIONS.map((operation) => {
467
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)}`
468
538
  // `select` wraps it so Postgres evaluates the call once per statement
469
539
  // rather than once per row, the same shape `authUid` uses.
470
- const condition = argument
471
- ? sql`(select ${sql.identifier(functionName)}(${argument}))`
472
- : sql`(select ${sql.identifier(functionName)}())`
473
- const clauses = POLICY_CLAUSES[operation]
540
+ const condition = sql`(select ${callee}(${sql.join(argumentsFor(argument, operation), sql`, `)}))`
541
+ const clause = { [POLICY_CLAUSE[operation]]: condition }
474
542
 
475
543
  return POLICY_BUILDERS[operation](
476
544
  name?.(operation, tableName) ?? functionName,
477
- {
478
- to: authenticatedRole,
479
- ...Object.fromEntries(clauses.map((clause) => [clause, condition])),
480
- }
545
+ { to: authenticatedRole, ...clause }
481
546
  )
482
547
  })
483
548
  }