@btravstack/prisma 0.12.0 → 0.14.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/README.md CHANGED
@@ -15,7 +15,9 @@ pnpm add @btravstack/prisma @btravstack/core @btravstack/config @btravstack/di u
15
15
  Five peer dependencies — install every one, so the application holds a single
16
16
  copy of each. Your own `@prisma/client`, `prisma` and (if you want the `try*`
17
17
  twins) `@unthrown/prisma` are yours, not this package's: the client is
18
- generated from your schema. Node `>=22`.
18
+ generated from your schema. `@prisma/client` is also an **optional** peer here,
19
+ used only by the `@btravstack/prisma/rls` subpath below — a consumer that never
20
+ imports it installs nothing extra. Node `>=22`.
19
21
 
20
22
  ## A worked example
21
23
 
@@ -85,14 +87,71 @@ port is typed by exactly what it returns.
85
87
  the failures as lines and `otel()` mints the instruments — with no flag here
86
88
  and no port list to satisfy when you compose neither.
87
89
 
90
+ ## Row-level security, on the `@btravstack/prisma/rls` subpath
91
+
92
+ `tenantScoped(tenant)` is a Prisma client extension that pins **every** statement
93
+ — raw SQL included — to `tenant` through a transaction-local
94
+ `set_config('app.tenant_id', tenant, true)`, so a PostgreSQL row-level-security
95
+ policy reading `current_setting('app.tenant_id', true)` sees it. Apply it
96
+ **last**:
97
+
98
+ <!-- doctest: isolate
99
+ // `PrismaClient` and `unthrownPrisma` are the reader's own, exactly as in the
100
+ // worked example above — so what this fence holds is `tenantScoped`'s call
101
+ // shape and its place in the chain, not the generated client's `$extends`.
102
+ // That half is compiled on the reference page, over the real client.
103
+ import { PrismaPg } from "@prisma/adapter-pg";
104
+
105
+ declare class PrismaClient {
106
+ constructor(options: { readonly adapter: PrismaPg });
107
+ $extends(extension: unknown): this;
108
+ }
109
+ declare const adapter: PrismaPg;
110
+ declare const unthrownPrisma: unknown;
111
+ declare const tenant: string;
112
+ -->
113
+
114
+ ```ts
115
+ import { tenantScoped } from "@btravstack/prisma/rls";
116
+
117
+ const db = new PrismaClient({ adapter }).$extends(unthrownPrisma).$extends(tenantScoped(tenant));
118
+ ```
119
+
120
+ That fence compiles `tenantScoped`'s call shape and its place in the chain over
121
+ a client declared for the sample; the fence on the
122
+ [reference page](https://btravstack.github.io/btravstack/reference/prisma) is
123
+ the one compiled over a real generated client, which is what proves the
124
+ extension is assignable to its `$extends`.
125
+
126
+ It goes last because a transaction callback's `tx` comes from the client as it
127
+ stood when this extension was applied, so anything added after it is invisible
128
+ inside a transaction. `$transaction([...])` is refused rather than silently pinned: the
129
+ batch form stops being atomic under this design, and a rejected promise beats a
130
+ transaction that quietly no longer rolls back. Use the callback form.
131
+
132
+ The database half stays the deployment's, and every part of it fails quietly
133
+ when forgotten: a **superuser** role bypasses row security whatever the table
134
+ says, and a policy without `FORCE ROW LEVEL SECURITY` is not applied to the
135
+ table's **owner** — the role that ran the migrations. Either way the
136
+ application works and no tenant is isolated. The policy must also read the
137
+ **same** setting `tenantScoped` was given — `app.tenant_id` is only the default
138
+ — because a `tenantScoped(tenant, { setting })` and a policy naming a different
139
+ one deny every row and every write, which looks exactly like row security
140
+ working. The
141
+ [reference page](https://btravstack.github.io/btravstack/reference/prisma)
142
+ carries the DDL and the two smaller lines that fail the same way.
143
+
88
144
  ## What it does not
89
145
 
90
- **Migrations.** A deployment runs `prisma migrate deploy` against this same URL
91
- _before the process starts_. An application that migrates itself at boot races
92
- every other replica.
146
+ **Migrations.** A deployment runs `prisma migrate deploy` against the same
147
+ database _before the process starts_ — and, where row security is on, as its
148
+ **owner** rather than the role `DATABASE_URL` carries. An application that
149
+ migrates itself at boot races every other replica.
93
150
 
94
151
  **Transactions.** Commit boundaries belong to the adapter, spelled at the call —
95
- `@unthrown/prisma`'s `$tryTransaction` is the primitive. There is no unit-scoped
152
+ `@unthrown/prisma`'s `$tryTransaction` is the primitive. The `rls` subpath
153
+ overrides `$transaction` to pin the tenant on the connection and nothing more;
154
+ it opens no boundary of its own. There is no unit-scoped
96
155
  transaction and there will not be one; see
97
156
  [the kernel maps nothing](https://btravstack.github.io/btravstack/explanation/the-kernel-maps-nothing).
98
157
 
@@ -106,6 +165,7 @@ package's.
106
165
  | `name` | `prismaDatabase(name)` | the port's id and the health check's name — required |
107
166
  | `client` | `prismaDatabase(name)({ client })` | builds your client from the driver adapter this package built from the URL — required |
108
167
  | `DATABASE_URL` | environment, read by `prismaDatabase(name)` | the connection string — required, validated at graph build; blank is an error, exit `78` |
168
+ | `setting` | `tenantScoped(tenant, { setting })` | the PostgreSQL run-time setting the policy reads — default `app.tenant_id` |
109
169
 
110
170
  There is **no `instrumented` flag**: observation is a set port every call is
111
171
  handed to, so a graph composing no observability pays one inert call and no
package/dist/index.d.cts CHANGED
@@ -50,7 +50,7 @@ type PrismaOptions<C extends PrismaLike> = {
50
50
  * against this same URL *before the process starts*; an application that
51
51
  * migrates itself at boot races every other replica.
52
52
  */
53
- declare const prismaDatabase: <const N extends string>(name: N) => <C extends PrismaLike>({ client }: PrismaOptions<C>) => Module<HealthChecks | Instrumentations | (PortClassOf<N, C> extends (infer T) ? T extends PortClassOf<N, C> ? T extends import("@btravstack/di").AnyPort ? InstanceType<T> : T extends import("@btravstack/di").AnyProvider ? InstanceType<T["port"]> : never : never : never), import("@btravstack/config").ConfigInvalid, Exclude<Env, HealthChecks | Instrumentations | Observers | import("@btravstack/di").PortInstance<string, C> | import("@btravstack/di").PortInstance<`${N}Config`, {
53
+ export declare const prismaDatabase: <const N extends string>(name: N) => <C extends PrismaLike>({ client }: PrismaOptions<C>) => Module<HealthChecks | Instrumentations | (PortClassOf<N, C> extends (infer T) ? T extends PortClassOf<N, C> ? T extends import("@btravstack/di").AnyPort ? InstanceType<T> : T extends import("@btravstack/di").AnyProvider ? InstanceType<T["port"]> : never : never : never), import("@btravstack/config").ConfigInvalid, Exclude<Env, HealthChecks | Instrumentations | Observers | import("@btravstack/di").PortInstance<string, C> | import("@btravstack/di").PortInstance<`${N}Config`, {
54
54
  readonly url: string;
55
55
  }>> | Exclude<Logger, HealthChecks | Instrumentations | Observers | import("@btravstack/di").PortInstance<string, C> | import("@btravstack/di").PortInstance<`${N}Config`, {
56
56
  readonly url: string;
@@ -58,4 +58,4 @@ declare const prismaDatabase: <const N extends string>(name: N) => <C extends Pr
58
58
  readonly port: PortClassOf<N, C>;
59
59
  };
60
60
  //#endregion
61
- export { type PrismaLike, type PrismaOptions, prismaDatabase };
61
+ export type { PrismaLike, PrismaOptions };
package/dist/index.d.mts CHANGED
@@ -50,7 +50,7 @@ type PrismaOptions<C extends PrismaLike> = {
50
50
  * against this same URL *before the process starts*; an application that
51
51
  * migrates itself at boot races every other replica.
52
52
  */
53
- declare const prismaDatabase: <const N extends string>(name: N) => <C extends PrismaLike>({ client }: PrismaOptions<C>) => Module<HealthChecks | Instrumentations | (PortClassOf<N, C> extends (infer T) ? T extends PortClassOf<N, C> ? T extends import("@btravstack/di").AnyPort ? InstanceType<T> : T extends import("@btravstack/di").AnyProvider ? InstanceType<T["port"]> : never : never : never), import("@btravstack/config").ConfigInvalid, Exclude<Env, HealthChecks | Instrumentations | Observers | import("@btravstack/di").PortInstance<string, C> | import("@btravstack/di").PortInstance<`${N}Config`, {
53
+ export declare const prismaDatabase: <const N extends string>(name: N) => <C extends PrismaLike>({ client }: PrismaOptions<C>) => Module<HealthChecks | Instrumentations | (PortClassOf<N, C> extends (infer T) ? T extends PortClassOf<N, C> ? T extends import("@btravstack/di").AnyPort ? InstanceType<T> : T extends import("@btravstack/di").AnyProvider ? InstanceType<T["port"]> : never : never : never), import("@btravstack/config").ConfigInvalid, Exclude<Env, HealthChecks | Instrumentations | Observers | import("@btravstack/di").PortInstance<string, C> | import("@btravstack/di").PortInstance<`${N}Config`, {
54
54
  readonly url: string;
55
55
  }>> | Exclude<Logger, HealthChecks | Instrumentations | Observers | import("@btravstack/di").PortInstance<string, C> | import("@btravstack/di").PortInstance<`${N}Config`, {
56
56
  readonly url: string;
@@ -58,4 +58,4 @@ declare const prismaDatabase: <const N extends string>(name: N) => <C extends Pr
58
58
  readonly port: PortClassOf<N, C>;
59
59
  };
60
60
  //#endregion
61
- export { type PrismaLike, type PrismaOptions, prismaDatabase };
61
+ export type { PrismaLike, PrismaOptions };
package/dist/rls.cjs ADDED
@@ -0,0 +1,40 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _prisma_client_extension = require("@prisma/client/extension");
3
+ //#region src/rls.ts
4
+ /**
5
+ * A Prisma client extension pinning every statement to `tenant` through a
6
+ * transaction-local `set_config`, so a row-level-security policy reading
7
+ * `current_setting('app.tenant_id', true)` sees it.
8
+ *
9
+ * @remarks
10
+ * Apply it **last**: the transaction callback's `tx` comes from the client as
11
+ * it stood when this extension was applied, so an extension added after this
12
+ * one is invisible inside a transaction.
13
+ *
14
+ * `$transaction([...])` is refused — the reasoning is in
15
+ * `packages/prisma/CLAUDE.md`. Use the callback form.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * const db = new PrismaClient({ adapter }).$extends(unthrownPrisma).$extends(tenantScoped(tenant));
20
+ * ```
21
+ */
22
+ const tenantScoped = (tenant, options) => _prisma_client_extension.Prisma.defineExtension((client) => {
23
+ const setting = options?.setting ?? "app.tenant_id";
24
+ const bare = client;
25
+ const pinOn = (on) => on.$executeRaw`SELECT set_config(${setting}, ${tenant}, true)`;
26
+ return client.$extends({
27
+ name: "tenantScoped",
28
+ client: { $transaction: ((arg, txOptions) => {
29
+ if (Array.isArray(arg)) return Promise.reject(/* @__PURE__ */ new Error("tenantScoped: $transaction([...]) is unsupported — use the callback form."));
30
+ const fn = arg;
31
+ return bare.$transaction(async (tx) => {
32
+ await pinOn(tx);
33
+ return fn(tx);
34
+ }, txOptions);
35
+ }) },
36
+ query: { $allOperations: ({ args, query }) => bare.$transaction([pinOn(bare), query(args)]).then((results) => results[1]) }
37
+ });
38
+ });
39
+ //#endregion
40
+ exports.tenantScoped = tenantScoped;
package/dist/rls.d.cts ADDED
@@ -0,0 +1,54 @@
1
+ //#region src/rls.d.ts
2
+ /** Options of {@link tenantScoped}. */
3
+ export type TenantScopedOptions = {
4
+ /**
5
+ * The PostgreSQL run-time setting the policy reads. Defaults to
6
+ * `app.tenant_id`, matching `current_setting('app.tenant_id', true)`.
7
+ */
8
+ readonly setting?: string;
9
+ };
10
+ /**
11
+ * The client a pinned `$transaction` callback receives — the extended client
12
+ * minus what a transaction cannot do, exactly as Prisma's own deny list has it.
13
+ *
14
+ * @remarks
15
+ * Not `@unthrown/prisma`'s `TransactionClient`, which denies a longer list; an
16
+ * application applying both extensions imports both names.
17
+ */
18
+ export type ScopedTransactionClient<C> = Omit<C, "$connect" | "$disconnect" | "$extends" | "$on" | "$use">;
19
+ /**
20
+ * The `$transaction` a {@link tenantScoped} client exposes: the callback form
21
+ * only, with `this` carrying the extended client through to `tx`.
22
+ *
23
+ * @remarks
24
+ * The generic `this` is what keeps `tx` typed. The starter cannot name a
25
+ * generated client, so the receiver is the only place that type can come from.
26
+ * The array form is absent because it cannot be pinned atomically.
27
+ */
28
+ export type ScopedTransaction = <C, R>(this: C, fn: (tx: ScopedTransactionClient<C>) => Promise<R>, options?: {
29
+ maxWait?: number;
30
+ timeout?: number;
31
+ isolationLevel?: string;
32
+ }) => Promise<R>;
33
+ /**
34
+ * A Prisma client extension pinning every statement to `tenant` through a
35
+ * transaction-local `set_config`, so a row-level-security policy reading
36
+ * `current_setting('app.tenant_id', true)` sees it.
37
+ *
38
+ * @remarks
39
+ * Apply it **last**: the transaction callback's `tx` comes from the client as
40
+ * it stood when this extension was applied, so an extension added after this
41
+ * one is invisible inside a transaction.
42
+ *
43
+ * `$transaction([...])` is refused — the reasoning is in
44
+ * `packages/prisma/CLAUDE.md`. Use the callback form.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * const db = new PrismaClient({ adapter }).$extends(unthrownPrisma).$extends(tenantScoped(tenant));
49
+ * ```
50
+ */
51
+ export declare const tenantScoped: (tenant: string, options?: TenantScopedOptions) => (client: any) => import("@prisma/client/extension").PrismaClientExtends<import("@prisma/client/runtime/client").InternalArgs<{}, {}, {}, {
52
+ $transaction: ScopedTransaction;
53
+ }> & import("@prisma/client/runtime/client").DefaultArgs>;
54
+ //#endregion
package/dist/rls.d.mts ADDED
@@ -0,0 +1,54 @@
1
+ //#region src/rls.d.ts
2
+ /** Options of {@link tenantScoped}. */
3
+ export type TenantScopedOptions = {
4
+ /**
5
+ * The PostgreSQL run-time setting the policy reads. Defaults to
6
+ * `app.tenant_id`, matching `current_setting('app.tenant_id', true)`.
7
+ */
8
+ readonly setting?: string;
9
+ };
10
+ /**
11
+ * The client a pinned `$transaction` callback receives — the extended client
12
+ * minus what a transaction cannot do, exactly as Prisma's own deny list has it.
13
+ *
14
+ * @remarks
15
+ * Not `@unthrown/prisma`'s `TransactionClient`, which denies a longer list; an
16
+ * application applying both extensions imports both names.
17
+ */
18
+ export type ScopedTransactionClient<C> = Omit<C, "$connect" | "$disconnect" | "$extends" | "$on" | "$use">;
19
+ /**
20
+ * The `$transaction` a {@link tenantScoped} client exposes: the callback form
21
+ * only, with `this` carrying the extended client through to `tx`.
22
+ *
23
+ * @remarks
24
+ * The generic `this` is what keeps `tx` typed. The starter cannot name a
25
+ * generated client, so the receiver is the only place that type can come from.
26
+ * The array form is absent because it cannot be pinned atomically.
27
+ */
28
+ export type ScopedTransaction = <C, R>(this: C, fn: (tx: ScopedTransactionClient<C>) => Promise<R>, options?: {
29
+ maxWait?: number;
30
+ timeout?: number;
31
+ isolationLevel?: string;
32
+ }) => Promise<R>;
33
+ /**
34
+ * A Prisma client extension pinning every statement to `tenant` through a
35
+ * transaction-local `set_config`, so a row-level-security policy reading
36
+ * `current_setting('app.tenant_id', true)` sees it.
37
+ *
38
+ * @remarks
39
+ * Apply it **last**: the transaction callback's `tx` comes from the client as
40
+ * it stood when this extension was applied, so an extension added after this
41
+ * one is invisible inside a transaction.
42
+ *
43
+ * `$transaction([...])` is refused — the reasoning is in
44
+ * `packages/prisma/CLAUDE.md`. Use the callback form.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * const db = new PrismaClient({ adapter }).$extends(unthrownPrisma).$extends(tenantScoped(tenant));
49
+ * ```
50
+ */
51
+ export declare const tenantScoped: (tenant: string, options?: TenantScopedOptions) => (client: any) => import("@prisma/client/extension").PrismaClientExtends<import("@prisma/client/runtime/client").InternalArgs<{}, {}, {}, {
52
+ $transaction: ScopedTransaction;
53
+ }> & import("@prisma/client/runtime/client").DefaultArgs>;
54
+ //#endregion
package/dist/rls.mjs ADDED
@@ -0,0 +1,39 @@
1
+ import { Prisma } from "@prisma/client/extension";
2
+ //#region src/rls.ts
3
+ /**
4
+ * A Prisma client extension pinning every statement to `tenant` through a
5
+ * transaction-local `set_config`, so a row-level-security policy reading
6
+ * `current_setting('app.tenant_id', true)` sees it.
7
+ *
8
+ * @remarks
9
+ * Apply it **last**: the transaction callback's `tx` comes from the client as
10
+ * it stood when this extension was applied, so an extension added after this
11
+ * one is invisible inside a transaction.
12
+ *
13
+ * `$transaction([...])` is refused — the reasoning is in
14
+ * `packages/prisma/CLAUDE.md`. Use the callback form.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * const db = new PrismaClient({ adapter }).$extends(unthrownPrisma).$extends(tenantScoped(tenant));
19
+ * ```
20
+ */
21
+ const tenantScoped = (tenant, options) => Prisma.defineExtension((client) => {
22
+ const setting = options?.setting ?? "app.tenant_id";
23
+ const bare = client;
24
+ const pinOn = (on) => on.$executeRaw`SELECT set_config(${setting}, ${tenant}, true)`;
25
+ return client.$extends({
26
+ name: "tenantScoped",
27
+ client: { $transaction: ((arg, txOptions) => {
28
+ if (Array.isArray(arg)) return Promise.reject(/* @__PURE__ */ new Error("tenantScoped: $transaction([...]) is unsupported — use the callback form."));
29
+ const fn = arg;
30
+ return bare.$transaction(async (tx) => {
31
+ await pinOn(tx);
32
+ return fn(tx);
33
+ }, txOptions);
34
+ }) },
35
+ query: { $allOperations: ({ args, query }) => bare.$transaction([pinOn(bare), query(args)]).then((results) => results[1]) }
36
+ });
37
+ });
38
+ //#endregion
39
+ export { tenantScoped };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btravstack/prisma",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "The Prisma starter for @btravstack/core: DATABASE_URL through Config, the Postgres driver adapter, and a client whose pool is the application scope's",
5
5
  "keywords": [
6
6
  "database",
@@ -40,32 +40,47 @@
40
40
  "default": "./dist/index.cjs"
41
41
  }
42
42
  },
43
+ "./rls": {
44
+ "import": {
45
+ "types": "./dist/rls.d.mts",
46
+ "default": "./dist/rls.mjs"
47
+ },
48
+ "require": {
49
+ "types": "./dist/rls.d.cts",
50
+ "default": "./dist/rls.cjs"
51
+ }
52
+ },
43
53
  "./package.json": "./package.json"
44
54
  },
45
55
  "devDependencies": {
46
- "@btravstack/tsconfig": "0.2.0",
56
+ "@btravstack/tsconfig": "0.4.0",
47
57
  "@prisma/adapter-pg": "7.10.0",
58
+ "@prisma/client": "7.10.0",
48
59
  "@prisma/instrumentation": "7.10.0",
49
- "@types/node": "26.4.0",
50
- "@unthrown/vitest": "5.7.0",
60
+ "@types/node": "26.4.1",
61
+ "@unthrown/vitest": "5.8.0",
51
62
  "@vitest/coverage-v8": "4.1.11",
52
- "tsdown": "0.22.14",
63
+ "tsdown": "0.23.0",
53
64
  "typescript": "7.0.2",
54
- "unthrown": "5.7.0",
65
+ "unthrown": "5.8.0",
55
66
  "vitest": "4.1.11",
56
- "@btravstack/config": "0.12.0",
57
- "@btravstack/core": "0.12.0",
58
- "@btravstack/di": "0.12.0"
67
+ "@btravstack/config": "0.14.0",
68
+ "@btravstack/core": "0.14.0",
69
+ "@btravstack/di": "0.14.0"
59
70
  },
60
71
  "peerDependencies": {
61
72
  "@prisma/adapter-pg": "^7.0.0",
73
+ "@prisma/client": "^7.10.0",
62
74
  "@prisma/instrumentation": "^7.0.0",
63
75
  "unthrown": "^5.0.0",
64
- "@btravstack/config": "^0.12.0",
65
- "@btravstack/core": "^0.12.0",
66
- "@btravstack/di": "^0.12.0"
76
+ "@btravstack/config": "^0.14.0",
77
+ "@btravstack/core": "^0.14.0",
78
+ "@btravstack/di": "^0.14.0"
67
79
  },
68
80
  "peerDependenciesMeta": {
81
+ "@prisma/client": {
82
+ "optional": true
83
+ },
69
84
  "@prisma/instrumentation": {
70
85
  "optional": true
71
86
  }
@@ -74,8 +89,8 @@
74
89
  "node": ">=22"
75
90
  },
76
91
  "scripts": {
77
- "build": "tsdown src/index.ts --format cjs,esm --dts --clean",
78
- "dev": "tsdown src/index.ts --format cjs,esm --dts --watch",
92
+ "build": "tsdown src/index.ts src/rls.ts --format cjs,esm --dts --clean",
93
+ "dev": "tsdown src/index.ts src/rls.ts --format cjs,esm --dts --watch",
79
94
  "test": "vitest run --coverage",
80
95
  "test:types": "tsc -p tsconfig.test-d.json --noEmit",
81
96
  "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json"