@btravstack/prisma 0.14.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.
@@ -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
@@ -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
- let _prisma_client_extension = require("@prisma/client/extension");
2
+ const require_result = require("./result.cjs");
3
3
  //#region src/rls.ts
4
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.
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
- * @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.
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
- * `$transaction([...])` is refused — the reasoning is in
15
- * `packages/prisma/CLAUDE.md`. Use the callback form.
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 db = new PrismaClient({ adapter }).$extends(unthrownPrisma).$extends(tenantScoped(tenant));
28
+ * const orders = await tenantPinned(db, tenant, (tx) => tx.orm.orders.Order.all());
20
29
  * ```
21
30
  */
22
- const tenantScoped = (tenant, options) => _prisma_client_extension.Prisma.defineExtension((client) => {
31
+ const tenantPinned = (db, tenant, work, options) => {
23
32
  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
- });
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.tenantScoped = tenantScoped;
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 tenantScoped}. */
3
- export type TenantScopedOptions = {
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 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.
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
- export type ScopedTransactionClient<C> = Omit<C, "$connect" | "$disconnect" | "$extends" | "$on" | "$use">;
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
- * The `$transaction` a {@link tenantScoped} client exposes: the callback form
21
- * only, with `this` carrying the extended client through to `tx`.
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
- * @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.
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
- * @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.
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
- * `$transaction([...])` is refused — the reasoning is in
44
- * `packages/prisma/CLAUDE.md`. Use the callback form.
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 db = new PrismaClient({ adapter }).$extends(unthrownPrisma).$extends(tenantScoped(tenant));
58
+ * const orders = await tenantPinned(db, tenant, (tx) => tx.orm.orders.Order.all());
49
59
  * ```
50
60
  */
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>;
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 tenantScoped}. */
3
- export type TenantScopedOptions = {
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 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.
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
- export type ScopedTransactionClient<C> = Omit<C, "$connect" | "$disconnect" | "$extends" | "$on" | "$use">;
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
- * The `$transaction` a {@link tenantScoped} client exposes: the callback form
21
- * only, with `this` carrying the extended client through to `tx`.
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
- * @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.
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
- * @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.
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
- * `$transaction([...])` is refused — the reasoning is in
44
- * `packages/prisma/CLAUDE.md`. Use the callback form.
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 db = new PrismaClient({ adapter }).$extends(unthrownPrisma).$extends(tenantScoped(tenant));
58
+ * const orders = await tenantPinned(db, tenant, (tx) => tx.orm.orders.Order.all());
49
59
  * ```
50
60
  */
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>;
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 { Prisma } from "@prisma/client/extension";
1
+ import { tryQuery } from "./result.mjs";
2
2
  //#region src/rls.ts
3
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.
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
- * @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.
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
- * `$transaction([...])` is refused — the reasoning is in
14
- * `packages/prisma/CLAUDE.md`. Use the callback form.
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 db = new PrismaClient({ adapter }).$extends(unthrownPrisma).$extends(tenantScoped(tenant));
27
+ * const orders = await tenantPinned(db, tenant, (tx) => tx.orm.orders.Order.all());
19
28
  * ```
20
29
  */
21
- const tenantScoped = (tenant, options) => Prisma.defineExtension((client) => {
30
+ const tenantPinned = (db, tenant, work, options) => {
22
31
  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
- });
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 { tenantScoped };
39
+ export { tenantPinned };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btravstack/prisma",
3
- "version": "0.14.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,49 +50,46 @@
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": {
66
+ "@btravstack/config": "0.16.0",
67
+ "@btravstack/core": "0.16.0",
68
+ "@btravstack/di": "0.16.0",
56
69
  "@btravstack/tsconfig": "0.4.0",
57
- "@prisma/adapter-pg": "7.10.0",
58
- "@prisma/client": "7.10.0",
59
- "@prisma/instrumentation": "7.10.0",
70
+ "@prisma/orm-postgres": "8.0.0-rc.11",
60
71
  "@types/node": "26.4.1",
61
72
  "@unthrown/vitest": "5.8.0",
62
73
  "@vitest/coverage-v8": "4.1.11",
63
74
  "tsdown": "0.23.0",
64
75
  "typescript": "7.0.2",
65
76
  "unthrown": "5.8.0",
66
- "vitest": "4.1.11",
67
- "@btravstack/config": "0.14.0",
68
- "@btravstack/core": "0.14.0",
69
- "@btravstack/di": "0.14.0"
77
+ "vitest": "4.1.11"
70
78
  },
71
79
  "peerDependencies": {
72
- "@prisma/adapter-pg": "^7.0.0",
73
- "@prisma/client": "^7.10.0",
74
- "@prisma/instrumentation": "^7.0.0",
75
- "unthrown": "^5.0.0",
76
- "@btravstack/config": "^0.14.0",
77
- "@btravstack/core": "^0.14.0",
78
- "@btravstack/di": "^0.14.0"
79
- },
80
- "peerDependenciesMeta": {
81
- "@prisma/client": {
82
- "optional": true
83
- },
84
- "@prisma/instrumentation": {
85
- "optional": true
86
- }
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",
84
+ "unthrown": "^5.0.0"
87
85
  },
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
- "test:types": "tsc -p tsconfig.test-d.json --noEmit",
96
93
  "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json"
97
94
  }
98
95
  }