@btravstack/prisma 0.15.0 → 0.16.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 +108 -96
- package/dist/index.cjs +68 -112
- package/dist/index.d.cts +95 -31
- package/dist/index.d.mts +95 -31
- package/dist/index.mjs +69 -113
- package/dist/result.cjs +78 -0
- package/dist/result.d.cts +71 -0
- package/dist/result.d.mts +71 -0
- package/dist/result.mjs +73 -0
- package/dist/rls.cjs +28 -28
- package/dist/rls.d.cts +43 -35
- package/dist/rls.d.mts +43 -35
- package/dist/rls.mjs +28 -28
- package/package.json +21 -23
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { AsyncResult } from "unthrown";
|
|
2
|
+
//#region src/result.d.ts
|
|
3
|
+
declare const UniqueConstraintViolation_base: import("unthrown").TaggedErrorConstructor<"UniqueConstraintViolation">;
|
|
4
|
+
/**
|
|
5
|
+
* A write refused by a unique index or constraint — SQLSTATE `23505`.
|
|
6
|
+
*
|
|
7
|
+
* `constraint` is the index the database named, which is what makes this
|
|
8
|
+
* translatable: a table with two unique constraints produces one error type and
|
|
9
|
+
* the caller still knows which one fired.
|
|
10
|
+
*/
|
|
11
|
+
export declare class UniqueConstraintViolation extends UniqueConstraintViolation_base<{
|
|
12
|
+
readonly constraint: string | undefined;
|
|
13
|
+
readonly table: string | undefined;
|
|
14
|
+
}> {}
|
|
15
|
+
declare const ForeignKeyViolation_base: import("unthrown").TaggedErrorConstructor<"ForeignKeyViolation">;
|
|
16
|
+
/** A write refused by a foreign key — SQLSTATE `23503`. */
|
|
17
|
+
export declare class ForeignKeyViolation extends ForeignKeyViolation_base<{
|
|
18
|
+
readonly constraint: string | undefined;
|
|
19
|
+
readonly table: string | undefined;
|
|
20
|
+
}> {}
|
|
21
|
+
declare const NotAuthorized_base: import("unthrown").TaggedErrorConstructor<"NotAuthorized">;
|
|
22
|
+
/**
|
|
23
|
+
* The database refused the statement outright — SQLSTATE `42501`.
|
|
24
|
+
*
|
|
25
|
+
* On a table with row-level security this is what a `WITH CHECK` violation
|
|
26
|
+
* looks like — an `INSERT` or an `UPDATE` whose resulting row falls outside the
|
|
27
|
+
* policy. It is **not** what every cross-tenant statement produces: `USING`
|
|
28
|
+
* FILTERS, so a `SELECT`, `UPDATE` or `DELETE` that matches another tenant's
|
|
29
|
+
* rows answers nothing at all rather than failing. Only a write that lands
|
|
30
|
+
* outside the policy raises this.
|
|
31
|
+
*
|
|
32
|
+
* It is modeled rather than left as a defect because only the caller knows
|
|
33
|
+
* whether reaching it is a bug (an adapter bound to one tenant) or a request
|
|
34
|
+
* (a caller asking for something it may not have).
|
|
35
|
+
*/
|
|
36
|
+
export declare class NotAuthorized extends NotAuthorized_base<{
|
|
37
|
+
readonly table: string | undefined;
|
|
38
|
+
}> {}
|
|
39
|
+
/** What {@link qualify} models. Anything else is a defect. */
|
|
40
|
+
export type SqlError = UniqueConstraintViolation | ForeignKeyViolation | NotAuthorized;
|
|
41
|
+
/**
|
|
42
|
+
* SQLSTATE → a tagged error, or the caller's defect for anything unmodeled.
|
|
43
|
+
*
|
|
44
|
+
* The three arms are the ones an application triages. Everything else — a
|
|
45
|
+
* syntax error, a dead connection, a deadlock — is infrastructure a caller
|
|
46
|
+
* cannot act on differently, and belongs on the defect channel where an
|
|
47
|
+
* unexpected failure already goes.
|
|
48
|
+
*/
|
|
49
|
+
export declare const qualify: <D>(cause: unknown, defect: (cause: unknown) => D) => SqlError | D;
|
|
50
|
+
/**
|
|
51
|
+
* Runs a query and answers a `Result` instead of rejecting.
|
|
52
|
+
*
|
|
53
|
+
* It takes a **thunk**, not a promise: an `AsyncResult` is eager, and a promise
|
|
54
|
+
* built at the call site has already started before the Result exists. Passing
|
|
55
|
+
* the work in unstarted is what keeps a sequence of these a sequence rather
|
|
56
|
+
* than a race.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```ts
|
|
60
|
+
* const save = (order: Order) =>
|
|
61
|
+
* tryQuery(() => db.orm.orders.Order.create({ orderId: order.id })).mapErrCases(
|
|
62
|
+
* (matcher, defect) =>
|
|
63
|
+
* matcher
|
|
64
|
+
* .with(P.tag("UniqueConstraintViolation"), () => new DuplicateOrder({ id: order.id }))
|
|
65
|
+
* .with(P.tag("ForeignKeyViolation"), (violation) => defect(violation))
|
|
66
|
+
* .with(P.tag("NotAuthorized"), (refused) => defect(refused)),
|
|
67
|
+
* );
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export declare const tryQuery: <T>(run: () => PromiseLike<T>) => AsyncResult<T, SqlError>;
|
|
71
|
+
//#endregion
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { AsyncResult } from "unthrown";
|
|
2
|
+
//#region src/result.d.ts
|
|
3
|
+
declare const UniqueConstraintViolation_base: import("unthrown").TaggedErrorConstructor<"UniqueConstraintViolation">;
|
|
4
|
+
/**
|
|
5
|
+
* A write refused by a unique index or constraint — SQLSTATE `23505`.
|
|
6
|
+
*
|
|
7
|
+
* `constraint` is the index the database named, which is what makes this
|
|
8
|
+
* translatable: a table with two unique constraints produces one error type and
|
|
9
|
+
* the caller still knows which one fired.
|
|
10
|
+
*/
|
|
11
|
+
export declare class UniqueConstraintViolation extends UniqueConstraintViolation_base<{
|
|
12
|
+
readonly constraint: string | undefined;
|
|
13
|
+
readonly table: string | undefined;
|
|
14
|
+
}> {}
|
|
15
|
+
declare const ForeignKeyViolation_base: import("unthrown").TaggedErrorConstructor<"ForeignKeyViolation">;
|
|
16
|
+
/** A write refused by a foreign key — SQLSTATE `23503`. */
|
|
17
|
+
export declare class ForeignKeyViolation extends ForeignKeyViolation_base<{
|
|
18
|
+
readonly constraint: string | undefined;
|
|
19
|
+
readonly table: string | undefined;
|
|
20
|
+
}> {}
|
|
21
|
+
declare const NotAuthorized_base: import("unthrown").TaggedErrorConstructor<"NotAuthorized">;
|
|
22
|
+
/**
|
|
23
|
+
* The database refused the statement outright — SQLSTATE `42501`.
|
|
24
|
+
*
|
|
25
|
+
* On a table with row-level security this is what a `WITH CHECK` violation
|
|
26
|
+
* looks like — an `INSERT` or an `UPDATE` whose resulting row falls outside the
|
|
27
|
+
* policy. It is **not** what every cross-tenant statement produces: `USING`
|
|
28
|
+
* FILTERS, so a `SELECT`, `UPDATE` or `DELETE` that matches another tenant's
|
|
29
|
+
* rows answers nothing at all rather than failing. Only a write that lands
|
|
30
|
+
* outside the policy raises this.
|
|
31
|
+
*
|
|
32
|
+
* It is modeled rather than left as a defect because only the caller knows
|
|
33
|
+
* whether reaching it is a bug (an adapter bound to one tenant) or a request
|
|
34
|
+
* (a caller asking for something it may not have).
|
|
35
|
+
*/
|
|
36
|
+
export declare class NotAuthorized extends NotAuthorized_base<{
|
|
37
|
+
readonly table: string | undefined;
|
|
38
|
+
}> {}
|
|
39
|
+
/** What {@link qualify} models. Anything else is a defect. */
|
|
40
|
+
export type SqlError = UniqueConstraintViolation | ForeignKeyViolation | NotAuthorized;
|
|
41
|
+
/**
|
|
42
|
+
* SQLSTATE → a tagged error, or the caller's defect for anything unmodeled.
|
|
43
|
+
*
|
|
44
|
+
* The three arms are the ones an application triages. Everything else — a
|
|
45
|
+
* syntax error, a dead connection, a deadlock — is infrastructure a caller
|
|
46
|
+
* cannot act on differently, and belongs on the defect channel where an
|
|
47
|
+
* unexpected failure already goes.
|
|
48
|
+
*/
|
|
49
|
+
export declare const qualify: <D>(cause: unknown, defect: (cause: unknown) => D) => SqlError | D;
|
|
50
|
+
/**
|
|
51
|
+
* Runs a query and answers a `Result` instead of rejecting.
|
|
52
|
+
*
|
|
53
|
+
* It takes a **thunk**, not a promise: an `AsyncResult` is eager, and a promise
|
|
54
|
+
* built at the call site has already started before the Result exists. Passing
|
|
55
|
+
* the work in unstarted is what keeps a sequence of these a sequence rather
|
|
56
|
+
* than a race.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```ts
|
|
60
|
+
* const save = (order: Order) =>
|
|
61
|
+
* tryQuery(() => db.orm.orders.Order.create({ orderId: order.id })).mapErrCases(
|
|
62
|
+
* (matcher, defect) =>
|
|
63
|
+
* matcher
|
|
64
|
+
* .with(P.tag("UniqueConstraintViolation"), () => new DuplicateOrder({ id: order.id }))
|
|
65
|
+
* .with(P.tag("ForeignKeyViolation"), (violation) => defect(violation))
|
|
66
|
+
* .with(P.tag("NotAuthorized"), (refused) => defect(refused)),
|
|
67
|
+
* );
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export declare const tryQuery: <T>(run: () => PromiseLike<T>) => AsyncResult<T, SqlError>;
|
|
71
|
+
//#endregion
|
package/dist/result.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { TaggedError, fromPromise } from "unthrown";
|
|
2
|
+
//#region src/result.ts
|
|
3
|
+
/**
|
|
4
|
+
* A write refused by a unique index or constraint — SQLSTATE `23505`.
|
|
5
|
+
*
|
|
6
|
+
* `constraint` is the index the database named, which is what makes this
|
|
7
|
+
* translatable: a table with two unique constraints produces one error type and
|
|
8
|
+
* the caller still knows which one fired.
|
|
9
|
+
*/
|
|
10
|
+
var UniqueConstraintViolation = class extends TaggedError("UniqueConstraintViolation") {};
|
|
11
|
+
/** A write refused by a foreign key — SQLSTATE `23503`. */
|
|
12
|
+
var ForeignKeyViolation = class extends TaggedError("ForeignKeyViolation") {};
|
|
13
|
+
/**
|
|
14
|
+
* The database refused the statement outright — SQLSTATE `42501`.
|
|
15
|
+
*
|
|
16
|
+
* On a table with row-level security this is what a `WITH CHECK` violation
|
|
17
|
+
* looks like — an `INSERT` or an `UPDATE` whose resulting row falls outside the
|
|
18
|
+
* policy. It is **not** what every cross-tenant statement produces: `USING`
|
|
19
|
+
* FILTERS, so a `SELECT`, `UPDATE` or `DELETE` that matches another tenant's
|
|
20
|
+
* rows answers nothing at all rather than failing. Only a write that lands
|
|
21
|
+
* outside the policy raises this.
|
|
22
|
+
*
|
|
23
|
+
* It is modeled rather than left as a defect because only the caller knows
|
|
24
|
+
* whether reaching it is a bug (an adapter bound to one tenant) or a request
|
|
25
|
+
* (a caller asking for something it may not have).
|
|
26
|
+
*/
|
|
27
|
+
var NotAuthorized = class extends TaggedError("NotAuthorized") {};
|
|
28
|
+
/**
|
|
29
|
+
* SQLSTATE → a tagged error, or the caller's defect for anything unmodeled.
|
|
30
|
+
*
|
|
31
|
+
* The three arms are the ones an application triages. Everything else — a
|
|
32
|
+
* syntax error, a dead connection, a deadlock — is infrastructure a caller
|
|
33
|
+
* cannot act on differently, and belongs on the defect channel where an
|
|
34
|
+
* unexpected failure already goes.
|
|
35
|
+
*/
|
|
36
|
+
const qualify = (cause, defect) => {
|
|
37
|
+
const error = cause;
|
|
38
|
+
switch (error?.sqlState) {
|
|
39
|
+
case "23505": return new UniqueConstraintViolation({
|
|
40
|
+
constraint: error.constraint,
|
|
41
|
+
table: error.table
|
|
42
|
+
});
|
|
43
|
+
case "23503": return new ForeignKeyViolation({
|
|
44
|
+
constraint: error.constraint,
|
|
45
|
+
table: error.table
|
|
46
|
+
});
|
|
47
|
+
case "42501": return new NotAuthorized({ table: error.table });
|
|
48
|
+
default: return defect(cause);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Runs a query and answers a `Result` instead of rejecting.
|
|
53
|
+
*
|
|
54
|
+
* It takes a **thunk**, not a promise: an `AsyncResult` is eager, and a promise
|
|
55
|
+
* built at the call site has already started before the Result exists. Passing
|
|
56
|
+
* the work in unstarted is what keeps a sequence of these a sequence rather
|
|
57
|
+
* than a race.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* const save = (order: Order) =>
|
|
62
|
+
* tryQuery(() => db.orm.orders.Order.create({ orderId: order.id })).mapErrCases(
|
|
63
|
+
* (matcher, defect) =>
|
|
64
|
+
* matcher
|
|
65
|
+
* .with(P.tag("UniqueConstraintViolation"), () => new DuplicateOrder({ id: order.id }))
|
|
66
|
+
* .with(P.tag("ForeignKeyViolation"), (violation) => defect(violation))
|
|
67
|
+
* .with(P.tag("NotAuthorized"), (refused) => defect(refused)),
|
|
68
|
+
* );
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
const tryQuery = (run) => fromPromise(Promise.resolve().then(run), qualify);
|
|
72
|
+
//#endregion
|
|
73
|
+
export { ForeignKeyViolation, NotAuthorized, UniqueConstraintViolation, qualify, tryQuery };
|
package/dist/rls.cjs
CHANGED
|
@@ -1,40 +1,40 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
|
|
2
|
+
const require_result = require("./result.cjs");
|
|
3
3
|
//#region src/rls.ts
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Runs `work` in a transaction whose connection is pinned to `tenant`, so a
|
|
6
|
+
* row-level-security policy reading `current_setting('app.tenant_id', true)`
|
|
7
|
+
* admits that tenant's rows and no others.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* The pin is `set_config(setting, tenant, true)` — **transaction-local**, which
|
|
10
|
+
* is the whole reason this opens a transaction rather than issuing the pin and
|
|
11
|
+
* handing back a client. A session-scoped pin followed by a separate query
|
|
12
|
+
* works only while the pool happens to hand back the same connection, and fails
|
|
13
|
+
* the first time it does not; it is the shape a middleware would take, and it
|
|
14
|
+
* is why there is no middleware here.
|
|
15
|
+
*
|
|
16
|
+
* It answers an `AsyncResult`, qualified by {@link tryQuery}: the pin and the
|
|
17
|
+
* work run on one connection and fail on one channel, so a caller never wraps
|
|
18
|
+
* this in a second combinator. `work` still hands back a `PromiseLike`, because
|
|
19
|
+
* that is what Prisma's own transaction callback is.
|
|
13
20
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
21
|
+
* @remarks
|
|
22
|
+
* The policy must read the **same** setting name. A `tenantPinned(db, tenant,
|
|
23
|
+
* work, { setting })` whose policy names a different one denies every row and
|
|
24
|
+
* every write, which looks exactly like row security working.
|
|
16
25
|
*
|
|
17
26
|
* @example
|
|
18
27
|
* ```ts
|
|
19
|
-
* const
|
|
28
|
+
* const orders = await tenantPinned(db, tenant, (tx) => tx.orm.orders.Order.all());
|
|
20
29
|
* ```
|
|
21
30
|
*/
|
|
22
|
-
const
|
|
31
|
+
const tenantPinned = (db, tenant, work, options) => {
|
|
23
32
|
const setting = options?.setting ?? "app.tenant_id";
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
});
|
|
33
|
+
const plan = db.raw.sql`SELECT set_config(${setting}, ${tenant}, ${true}) AS pinned`.returnsRow({ pinned: "pg/text@1" }).build();
|
|
34
|
+
return require_result.tryQuery(() => db.transaction(async (tx) => {
|
|
35
|
+
await tx.query(plan);
|
|
36
|
+
return work(tx);
|
|
37
|
+
}));
|
|
38
|
+
};
|
|
39
39
|
//#endregion
|
|
40
|
-
exports.
|
|
40
|
+
exports.tenantPinned = tenantPinned;
|
package/dist/rls.d.cts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { SqlError } from "./result.cjs";
|
|
2
|
+
import { AsyncResult } from "unthrown";
|
|
1
3
|
//#region src/rls.d.ts
|
|
2
|
-
/** Options of {@link
|
|
3
|
-
export type
|
|
4
|
+
/** Options of {@link tenantPinned}. */
|
|
5
|
+
export type TenantPinnedOptions = {
|
|
4
6
|
/**
|
|
5
7
|
* The PostgreSQL run-time setting the policy reads. Defaults to
|
|
6
8
|
* `app.tenant_id`, matching `current_setting('app.tenant_id', true)`.
|
|
@@ -8,47 +10,53 @@ export type TenantScopedOptions = {
|
|
|
8
10
|
readonly setting?: string;
|
|
9
11
|
};
|
|
10
12
|
/**
|
|
11
|
-
* The
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* @remarks
|
|
15
|
-
* Not `@unthrown/prisma`'s `TransactionClient`, which denies a longer list; an
|
|
16
|
-
* application applying both extensions imports both names.
|
|
13
|
+
* The little of a Prisma 8 client this needs: the raw lane to build the pin
|
|
14
|
+
* with, and `transaction` to run it in. Structural, so the starter never has to
|
|
15
|
+
* name a contract it cannot see.
|
|
17
16
|
*/
|
|
18
|
-
|
|
17
|
+
type Pinnable<Tx> = {
|
|
18
|
+
/**
|
|
19
|
+
* Required to EXIST and deliberately not described further. Spelling the
|
|
20
|
+
* tag's signature out would refuse every real client: a parameter is
|
|
21
|
+
* contravariant, and the genuine `raw.sql` takes the target's own
|
|
22
|
+
* interpolation union and its own row-spec type — both narrower than
|
|
23
|
+
* anything this package, which cannot see a contract, could name.
|
|
24
|
+
*/
|
|
25
|
+
readonly raw: {
|
|
26
|
+
readonly sql: unknown;
|
|
27
|
+
};
|
|
28
|
+
readonly transaction: <R>(fn: (tx: Tx) => PromiseLike<R>) => Promise<R>;
|
|
29
|
+
};
|
|
30
|
+
/** What the pin needs of a transaction context: somewhere to run a plan. */
|
|
31
|
+
type Queryable = {
|
|
32
|
+
readonly query: (plan: never) => PromiseLike<unknown>;
|
|
33
|
+
};
|
|
19
34
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
35
|
+
* Runs `work` in a transaction whose connection is pinned to `tenant`, so a
|
|
36
|
+
* row-level-security policy reading `current_setting('app.tenant_id', true)`
|
|
37
|
+
* admits that tenant's rows and no others.
|
|
22
38
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
|
|
28
|
-
|
|
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.
|
|
39
|
+
* The pin is `set_config(setting, tenant, true)` — **transaction-local**, which
|
|
40
|
+
* is the whole reason this opens a transaction rather than issuing the pin and
|
|
41
|
+
* handing back a client. A session-scoped pin followed by a separate query
|
|
42
|
+
* works only while the pool happens to hand back the same connection, and fails
|
|
43
|
+
* the first time it does not; it is the shape a middleware would take, and it
|
|
44
|
+
* is why there is no middleware here.
|
|
37
45
|
*
|
|
38
|
-
* @
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
46
|
+
* It answers an `AsyncResult`, qualified by {@link tryQuery}: the pin and the
|
|
47
|
+
* work run on one connection and fail on one channel, so a caller never wraps
|
|
48
|
+
* this in a second combinator. `work` still hands back a `PromiseLike`, because
|
|
49
|
+
* that is what Prisma's own transaction callback is.
|
|
42
50
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
51
|
+
* @remarks
|
|
52
|
+
* The policy must read the **same** setting name. A `tenantPinned(db, tenant,
|
|
53
|
+
* work, { setting })` whose policy names a different one denies every row and
|
|
54
|
+
* every write, which looks exactly like row security working.
|
|
45
55
|
*
|
|
46
56
|
* @example
|
|
47
57
|
* ```ts
|
|
48
|
-
* const
|
|
58
|
+
* const orders = await tenantPinned(db, tenant, (tx) => tx.orm.orders.Order.all());
|
|
49
59
|
* ```
|
|
50
60
|
*/
|
|
51
|
-
export declare const
|
|
52
|
-
$transaction: ScopedTransaction;
|
|
53
|
-
}> & import("@prisma/client/runtime/client").DefaultArgs>;
|
|
61
|
+
export declare const tenantPinned: <Tx extends Queryable, R>(db: Pinnable<Tx>, tenant: string, work: (tx: Tx) => PromiseLike<R>, options?: TenantPinnedOptions) => AsyncResult<R, SqlError>;
|
|
54
62
|
//#endregion
|
package/dist/rls.d.mts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { SqlError } from "./result.mjs";
|
|
2
|
+
import { AsyncResult } from "unthrown";
|
|
1
3
|
//#region src/rls.d.ts
|
|
2
|
-
/** Options of {@link
|
|
3
|
-
export type
|
|
4
|
+
/** Options of {@link tenantPinned}. */
|
|
5
|
+
export type TenantPinnedOptions = {
|
|
4
6
|
/**
|
|
5
7
|
* The PostgreSQL run-time setting the policy reads. Defaults to
|
|
6
8
|
* `app.tenant_id`, matching `current_setting('app.tenant_id', true)`.
|
|
@@ -8,47 +10,53 @@ export type TenantScopedOptions = {
|
|
|
8
10
|
readonly setting?: string;
|
|
9
11
|
};
|
|
10
12
|
/**
|
|
11
|
-
* The
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* @remarks
|
|
15
|
-
* Not `@unthrown/prisma`'s `TransactionClient`, which denies a longer list; an
|
|
16
|
-
* application applying both extensions imports both names.
|
|
13
|
+
* The little of a Prisma 8 client this needs: the raw lane to build the pin
|
|
14
|
+
* with, and `transaction` to run it in. Structural, so the starter never has to
|
|
15
|
+
* name a contract it cannot see.
|
|
17
16
|
*/
|
|
18
|
-
|
|
17
|
+
type Pinnable<Tx> = {
|
|
18
|
+
/**
|
|
19
|
+
* Required to EXIST and deliberately not described further. Spelling the
|
|
20
|
+
* tag's signature out would refuse every real client: a parameter is
|
|
21
|
+
* contravariant, and the genuine `raw.sql` takes the target's own
|
|
22
|
+
* interpolation union and its own row-spec type — both narrower than
|
|
23
|
+
* anything this package, which cannot see a contract, could name.
|
|
24
|
+
*/
|
|
25
|
+
readonly raw: {
|
|
26
|
+
readonly sql: unknown;
|
|
27
|
+
};
|
|
28
|
+
readonly transaction: <R>(fn: (tx: Tx) => PromiseLike<R>) => Promise<R>;
|
|
29
|
+
};
|
|
30
|
+
/** What the pin needs of a transaction context: somewhere to run a plan. */
|
|
31
|
+
type Queryable = {
|
|
32
|
+
readonly query: (plan: never) => PromiseLike<unknown>;
|
|
33
|
+
};
|
|
19
34
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
35
|
+
* Runs `work` in a transaction whose connection is pinned to `tenant`, so a
|
|
36
|
+
* row-level-security policy reading `current_setting('app.tenant_id', true)`
|
|
37
|
+
* admits that tenant's rows and no others.
|
|
22
38
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
|
|
28
|
-
|
|
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.
|
|
39
|
+
* The pin is `set_config(setting, tenant, true)` — **transaction-local**, which
|
|
40
|
+
* is the whole reason this opens a transaction rather than issuing the pin and
|
|
41
|
+
* handing back a client. A session-scoped pin followed by a separate query
|
|
42
|
+
* works only while the pool happens to hand back the same connection, and fails
|
|
43
|
+
* the first time it does not; it is the shape a middleware would take, and it
|
|
44
|
+
* is why there is no middleware here.
|
|
37
45
|
*
|
|
38
|
-
* @
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
46
|
+
* It answers an `AsyncResult`, qualified by {@link tryQuery}: the pin and the
|
|
47
|
+
* work run on one connection and fail on one channel, so a caller never wraps
|
|
48
|
+
* this in a second combinator. `work` still hands back a `PromiseLike`, because
|
|
49
|
+
* that is what Prisma's own transaction callback is.
|
|
42
50
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
51
|
+
* @remarks
|
|
52
|
+
* The policy must read the **same** setting name. A `tenantPinned(db, tenant,
|
|
53
|
+
* work, { setting })` whose policy names a different one denies every row and
|
|
54
|
+
* every write, which looks exactly like row security working.
|
|
45
55
|
*
|
|
46
56
|
* @example
|
|
47
57
|
* ```ts
|
|
48
|
-
* const
|
|
58
|
+
* const orders = await tenantPinned(db, tenant, (tx) => tx.orm.orders.Order.all());
|
|
49
59
|
* ```
|
|
50
60
|
*/
|
|
51
|
-
export declare const
|
|
52
|
-
$transaction: ScopedTransaction;
|
|
53
|
-
}> & import("@prisma/client/runtime/client").DefaultArgs>;
|
|
61
|
+
export declare const tenantPinned: <Tx extends Queryable, R>(db: Pinnable<Tx>, tenant: string, work: (tx: Tx) => PromiseLike<R>, options?: TenantPinnedOptions) => AsyncResult<R, SqlError>;
|
|
54
62
|
//#endregion
|
package/dist/rls.mjs
CHANGED
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { tryQuery } from "./result.mjs";
|
|
2
2
|
//#region src/rls.ts
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Runs `work` in a transaction whose connection is pinned to `tenant`, so a
|
|
5
|
+
* row-level-security policy reading `current_setting('app.tenant_id', true)`
|
|
6
|
+
* admits that tenant's rows and no others.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* The pin is `set_config(setting, tenant, true)` — **transaction-local**, which
|
|
9
|
+
* is the whole reason this opens a transaction rather than issuing the pin and
|
|
10
|
+
* handing back a client. A session-scoped pin followed by a separate query
|
|
11
|
+
* works only while the pool happens to hand back the same connection, and fails
|
|
12
|
+
* the first time it does not; it is the shape a middleware would take, and it
|
|
13
|
+
* is why there is no middleware here.
|
|
14
|
+
*
|
|
15
|
+
* It answers an `AsyncResult`, qualified by {@link tryQuery}: the pin and the
|
|
16
|
+
* work run on one connection and fail on one channel, so a caller never wraps
|
|
17
|
+
* this in a second combinator. `work` still hands back a `PromiseLike`, because
|
|
18
|
+
* that is what Prisma's own transaction callback is.
|
|
12
19
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
20
|
+
* @remarks
|
|
21
|
+
* The policy must read the **same** setting name. A `tenantPinned(db, tenant,
|
|
22
|
+
* work, { setting })` whose policy names a different one denies every row and
|
|
23
|
+
* every write, which looks exactly like row security working.
|
|
15
24
|
*
|
|
16
25
|
* @example
|
|
17
26
|
* ```ts
|
|
18
|
-
* const
|
|
27
|
+
* const orders = await tenantPinned(db, tenant, (tx) => tx.orm.orders.Order.all());
|
|
19
28
|
* ```
|
|
20
29
|
*/
|
|
21
|
-
const
|
|
30
|
+
const tenantPinned = (db, tenant, work, options) => {
|
|
22
31
|
const setting = options?.setting ?? "app.tenant_id";
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
});
|
|
32
|
+
const plan = db.raw.sql`SELECT set_config(${setting}, ${tenant}, ${true}) AS pinned`.returnsRow({ pinned: "pg/text@1" }).build();
|
|
33
|
+
return tryQuery(() => db.transaction(async (tx) => {
|
|
34
|
+
await tx.query(plan);
|
|
35
|
+
return work(tx);
|
|
36
|
+
}));
|
|
37
|
+
};
|
|
38
38
|
//#endregion
|
|
39
|
-
export {
|
|
39
|
+
export { tenantPinned };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@btravstack/prisma",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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",
|
|
@@ -50,16 +50,24 @@
|
|
|
50
50
|
"default": "./dist/rls.cjs"
|
|
51
51
|
}
|
|
52
52
|
},
|
|
53
|
+
"./result": {
|
|
54
|
+
"import": {
|
|
55
|
+
"types": "./dist/result.d.mts",
|
|
56
|
+
"default": "./dist/result.mjs"
|
|
57
|
+
},
|
|
58
|
+
"require": {
|
|
59
|
+
"types": "./dist/result.d.cts",
|
|
60
|
+
"default": "./dist/result.cjs"
|
|
61
|
+
}
|
|
62
|
+
},
|
|
53
63
|
"./package.json": "./package.json"
|
|
54
64
|
},
|
|
55
65
|
"devDependencies": {
|
|
56
|
-
"@btravstack/config": "0.
|
|
57
|
-
"@btravstack/core": "0.
|
|
58
|
-
"@btravstack/di": "0.
|
|
66
|
+
"@btravstack/config": "0.16.0",
|
|
67
|
+
"@btravstack/core": "0.16.0",
|
|
68
|
+
"@btravstack/di": "0.16.0",
|
|
59
69
|
"@btravstack/tsconfig": "0.4.0",
|
|
60
|
-
"@prisma/
|
|
61
|
-
"@prisma/client": "7.10.0",
|
|
62
|
-
"@prisma/instrumentation": "7.10.0",
|
|
70
|
+
"@prisma/orm-postgres": "8.0.0-rc.11",
|
|
63
71
|
"@types/node": "26.4.1",
|
|
64
72
|
"@unthrown/vitest": "5.8.0",
|
|
65
73
|
"@vitest/coverage-v8": "4.1.11",
|
|
@@ -69,28 +77,18 @@
|
|
|
69
77
|
"vitest": "4.1.11"
|
|
70
78
|
},
|
|
71
79
|
"peerDependencies": {
|
|
72
|
-
"@btravstack/config": "^0.
|
|
73
|
-
"@btravstack/core": "^0.
|
|
74
|
-
"@btravstack/di": "^0.
|
|
75
|
-
"@prisma/
|
|
76
|
-
"@prisma/client": "^7.10.0",
|
|
77
|
-
"@prisma/instrumentation": "^7.0.0",
|
|
80
|
+
"@btravstack/config": "^0.16.0",
|
|
81
|
+
"@btravstack/core": "^0.16.0",
|
|
82
|
+
"@btravstack/di": "^0.16.0",
|
|
83
|
+
"@prisma/orm-postgres": "^8.0.0-rc.11",
|
|
78
84
|
"unthrown": "^5.0.0"
|
|
79
85
|
},
|
|
80
|
-
"peerDependenciesMeta": {
|
|
81
|
-
"@prisma/client": {
|
|
82
|
-
"optional": true
|
|
83
|
-
},
|
|
84
|
-
"@prisma/instrumentation": {
|
|
85
|
-
"optional": true
|
|
86
|
-
}
|
|
87
|
-
},
|
|
88
86
|
"engines": {
|
|
89
87
|
"node": ">=22"
|
|
90
88
|
},
|
|
91
89
|
"scripts": {
|
|
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",
|
|
90
|
+
"build": "tsdown src/index.ts src/rls.ts src/result.ts --format cjs,esm --dts --clean",
|
|
91
|
+
"dev": "tsdown src/index.ts src/rls.ts src/result.ts --format cjs,esm --dts --watch",
|
|
94
92
|
"test": "vitest run --coverage",
|
|
95
93
|
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json"
|
|
96
94
|
}
|