@btravstack/prisma 0.12.0 → 0.13.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 +65 -5
- package/dist/rls.cjs +40 -0
- package/dist/rls.d.cts +55 -0
- package/dist/rls.d.mts +55 -0
- package/dist/rls.mjs +39 -0
- package/package.json +25 -10
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.
|
|
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
|
|
91
|
-
_before the process starts_
|
|
92
|
-
|
|
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.
|
|
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/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,55 @@
|
|
|
1
|
+
//#region src/rls.d.ts
|
|
2
|
+
/** Options of {@link tenantScoped}. */
|
|
3
|
+
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
|
+
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
|
+
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
|
+
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
|
|
55
|
+
export { ScopedTransaction, ScopedTransactionClient, TenantScopedOptions, tenantScoped };
|
package/dist/rls.d.mts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
//#region src/rls.d.ts
|
|
2
|
+
/** Options of {@link tenantScoped}. */
|
|
3
|
+
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
|
+
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
|
+
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
|
+
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
|
|
55
|
+
export { ScopedTransaction, ScopedTransactionClient, TenantScopedOptions, tenantScoped };
|
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.
|
|
3
|
+
"version": "0.13.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,11 +40,22 @@
|
|
|
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.
|
|
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
60
|
"@types/node": "26.4.0",
|
|
50
61
|
"@unthrown/vitest": "5.7.0",
|
|
@@ -53,19 +64,23 @@
|
|
|
53
64
|
"typescript": "7.0.2",
|
|
54
65
|
"unthrown": "5.7.0",
|
|
55
66
|
"vitest": "4.1.11",
|
|
56
|
-
"@btravstack/config": "0.
|
|
57
|
-
"@btravstack/core": "0.
|
|
58
|
-
"@btravstack/di": "0.
|
|
67
|
+
"@btravstack/config": "0.13.0",
|
|
68
|
+
"@btravstack/core": "0.13.0",
|
|
69
|
+
"@btravstack/di": "0.13.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.
|
|
65
|
-
"@btravstack/core": "^0.
|
|
66
|
-
"@btravstack/di": "^0.
|
|
76
|
+
"@btravstack/config": "^0.13.0",
|
|
77
|
+
"@btravstack/core": "^0.13.0",
|
|
78
|
+
"@btravstack/di": "^0.13.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"
|