@btravstack/prisma 0.27.0 → 0.29.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
@@ -79,6 +79,11 @@ the per-query observation below comes from.
79
79
  transactions, and a database that does not answer fails the boot with
80
80
  `DatabaseUnreachable` — the database's name and the driver's cause, never the
81
81
  URL.
82
+ - **A role row security applies to.** Where any table has row-level security
83
+ enabled, a role that is a superuser, has `BYPASSRLS`, or owns a row-secured
84
+ table that is not `FORCE`d fails the boot with `RowSecurityBypassed`, naming
85
+ the role and the tables it would bypass — every policy would admit every row
86
+ for it.
82
87
  - **A health check**, named after the starter — `SELECT 1` through the raw
83
88
  lane, folded into the kernel's `GET /healthz` with nothing wired. `/readyz`
84
89
  does not read it: failing readiness on a dependency every replica shares
@@ -95,7 +100,9 @@ Prisma 8 throws a **structured** error carrying PostgreSQL's own SQLSTATE.
95
100
  `tryQuery` turns that into a `Result` with three modeled arms —
96
101
  `UniqueConstraintViolation` (`23505`, with the `constraint` the database
97
102
  named), `ForeignKeyViolation` (`23503`) and `NotAuthorized` (`42501`,
98
- including a write a row-security policy refused). Anything else is a defect.
103
+ including a write a row-security policy refused). Anything else is a defect,
104
+ unless you list it in `raises` — a SQLSTATE your own trigger raises on purpose
105
+ then arrives as a `RaisedException` carrying that code.
99
106
 
100
107
  <!-- doctest: isolate
101
108
  import { tryQuery } from "@btravstack/prisma/result";
@@ -151,7 +158,7 @@ Two halves stay the deployment's, and both fail quietly when forgotten: Prisma 8
151
158
  authors policies but no `GRANT`s, and it emits `ENABLE ROW LEVEL SECURITY` with
152
159
  no way to say `FORCE` — so the table's **owner**, which is the role that ran the
153
160
  migrations, bypasses every policy. Connect as a role that is neither the owner
154
- nor a superuser. The
161
+ nor a superuser — the boot refuses either with `RowSecurityBypassed`. The
155
162
  [reference page](https://btravstack.github.io/btravstack/reference/prisma)
156
163
  carries the DDL and the smaller lines that fail the same way.
157
164
 
@@ -177,12 +184,13 @@ package's.
177
184
 
178
185
  ## Options
179
186
 
180
- | Option | Where | What it is |
181
- | -------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------- |
182
- | `name` | `prismaDatabase(name)` | the port's id and the health check's name — required |
183
- | `client` | `prismaDatabase(name)({ client })` | builds your client from the URL and middleware this package bound — required |
184
- | `DATABASE_URL` | environment, read by `prismaDatabase(name)` | the connection string — required, validated at graph build; blank is an error, exit `78` |
185
- | `setting` | `tenantPinned(db, tenant, work, { setting })` | the PostgreSQL run-time setting the policy reads — default `app.tenant_id` |
187
+ | Option | Where | What it is |
188
+ | -------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
189
+ | `name` | `prismaDatabase(name)` | the port's id and the health check's name — required |
190
+ | `client` | `prismaDatabase(name)({ client })` | builds your client from the URL and middleware this package bound — required |
191
+ | `DATABASE_URL` | environment, read by `prismaDatabase(name)` | the connection string — required, validated at graph build; blank is an error, exit `78` |
192
+ | `setting` | `tenantPinned(db, tenant, work, { setting })` | the PostgreSQL run-time setting the policy reads — default `app.tenant_id` |
193
+ | `raises` | `tryQuery(run, { raises })`, `tenantPinned(…, { raises })` | SQLSTATEs modeled as `RaisedException` rather than defects — default none |
186
194
 
187
195
  There is **no `instrumented` flag**: observation is a set port every call is
188
196
  handed to, so a graph composing no observability pays one inert call and no
package/dist/index.cjs CHANGED
@@ -66,6 +66,21 @@ const queryObserver = (observers) => {
66
66
  /** The one place the cast above is spelled. */
67
67
  const probeable = (db) => db;
68
68
  const probe = (db) => probeable(db).runtime().query(probeable(db).raw.sql`SELECT 1`.affectedCount().build());
69
+ const exemptions = (db) => probeable(db).runtime().query(probeable(db).raw.sql`SELECT current_user::text AS role, c.oid::regclass::text AS relation, CASE WHEN r.rolsuper THEN 'superuser' WHEN r.rolbypassrls THEN 'bypassrls' ELSE 'owner' END AS bypass FROM pg_class c JOIN pg_roles r ON r.rolname = current_user WHERE c.relrowsecurity AND (r.rolsuper OR r.rolbypassrls OR (NOT c.relforcerowsecurity AND pg_has_role(current_user, c.relowner, 'USAGE'))) ORDER BY 2`.returnsRow({
70
+ role: "pg/text@1",
71
+ relation: "pg/text@1",
72
+ bypass: "pg/text@1"
73
+ }).build());
74
+ /**
75
+ * The role the client connected as is exempt from row-level security on a
76
+ * table that has it enabled: a superuser, a role with `BYPASSRLS`, or the
77
+ * owner of a table whose security is not `FORCE`d — which Prisma 8 cannot
78
+ * declare, so every table a migration created qualifies for the role that ran
79
+ * it. Every policy would admit every row, so the boot is refused rather than
80
+ * served without isolation. `tables` are the ones it would bypass; the URL is
81
+ * never carried.
82
+ */
83
+ var RowSecurityBypassed = class extends (0, unthrown.TaggedError)("RowSecurityBypassed") {};
69
84
  /**
70
85
  * The database did not answer when the scope opened. Carries the database's
71
86
  * name and the driver's own failure, never the URL, which holds credentials.
@@ -84,6 +99,12 @@ var DatabaseUnreachable = class extends (0, unthrown.TaggedError)("DatabaseUnrea
84
99
  * database that does not answer fails the boot with
85
100
  * {@link DatabaseUnreachable}.
86
101
  *
102
+ * **Row-level security has to apply to the role it connected as.** Where any
103
+ * table has it enabled, a role that is a superuser, has `BYPASSRLS`, or owns
104
+ * such a table without `FORCE` fails the boot with {@link RowSecurityBypassed}.
105
+ * A database with no row-secured table passes as it is. The health check does
106
+ * not repeat it: a role does not change under a running pool.
107
+ *
87
108
  * **The pool closes on every exit path**, including a boot that fails after
88
109
  * this provider ran — that is what makes it resourceful rather than a plain
89
110
  * value. `runtime().close()` ends the pool; Prisma dials again lazily on the
@@ -97,7 +118,12 @@ var DatabaseUnreachable = class extends (0, unthrown.TaggedError)("DatabaseUnrea
97
118
  const prismaDatabase = (name) => ({ client }) => {
98
119
  const config = _btravstack_config.Config.provider(`${name}Config`)(_btravstack_config.Config.object({ url: _btravstack_config.Config.string("DATABASE_URL") }));
99
120
  const DatabasePort = (0, _btravstack_di.Port)(name);
100
- const verified = (db) => (0, unthrown.fromPromise)(probe(db).then(() => db, (cause) => probeable(db).runtime().close().finally(() => Promise.reject(cause))), (cause) => new DatabaseUnreachable({
121
+ const verified = (db) => (0, unthrown.fromPromise)(probe(db).then(() => exemptions(db)).then((exempt) => exempt[0] === void 0 ? db : Promise.reject(new RowSecurityBypassed({
122
+ database: name,
123
+ role: exempt[0].role,
124
+ bypass: exempt[0].bypass,
125
+ tables: exempt.map(({ relation }) => relation)
126
+ }))).catch((cause) => probeable(db).runtime().close().finally(() => Promise.reject(cause))), (cause) => cause instanceof RowSecurityBypassed ? cause : new DatabaseUnreachable({
101
127
  database: name,
102
128
  cause
103
129
  }));
@@ -134,4 +160,5 @@ const prismaDatabase = (name) => ({ client }) => {
134
160
  };
135
161
  //#endregion
136
162
  exports.DatabaseUnreachable = DatabaseUnreachable;
163
+ exports.RowSecurityBypassed = RowSecurityBypassed;
137
164
  exports.prismaDatabase = prismaDatabase;
package/dist/index.d.cts CHANGED
@@ -66,6 +66,24 @@ type PrismaLike = {
66
66
  */
67
67
  readonly runtime: unknown;
68
68
  };
69
+ /** Why row-level security does not apply to the role, as {@link RowSecurityBypassed} names it. */
70
+ type RowSecurityBypass = "superuser" | "bypassrls" | "owner";
71
+ declare const RowSecurityBypassed_base: import("unthrown").TaggedErrorConstructor<"RowSecurityBypassed">;
72
+ /**
73
+ * The role the client connected as is exempt from row-level security on a
74
+ * table that has it enabled: a superuser, a role with `BYPASSRLS`, or the
75
+ * owner of a table whose security is not `FORCE`d — which Prisma 8 cannot
76
+ * declare, so every table a migration created qualifies for the role that ran
77
+ * it. Every policy would admit every row, so the boot is refused rather than
78
+ * served without isolation. `tables` are the ones it would bypass; the URL is
79
+ * never carried.
80
+ */
81
+ export declare class RowSecurityBypassed extends RowSecurityBypassed_base<{
82
+ readonly database: string;
83
+ readonly role: string;
84
+ readonly bypass: RowSecurityBypass;
85
+ readonly tables: readonly string[];
86
+ }> {}
69
87
  declare const DatabaseUnreachable_base: import("unthrown").TaggedErrorConstructor<"DatabaseUnreachable">;
70
88
  /**
71
89
  * The database did not answer when the scope opened. Carries the database's
@@ -119,6 +137,12 @@ type PrismaOptions<C extends PrismaLike> = {
119
137
  * database that does not answer fails the boot with
120
138
  * {@link DatabaseUnreachable}.
121
139
  *
140
+ * **Row-level security has to apply to the role it connected as.** Where any
141
+ * table has it enabled, a role that is a superuser, has `BYPASSRLS`, or owns
142
+ * such a table without `FORCE` fails the boot with {@link RowSecurityBypassed}.
143
+ * A database with no row-secured table passes as it is. The health check does
144
+ * not repeat it: a role does not change under a running pool.
145
+ *
122
146
  * **The pool closes on every exit path**, including a boot that fails after
123
147
  * this provider ran — that is what makes it resourceful rather than a plain
124
148
  * value. `runtime().close()` ends the pool; Prisma dials again lazily on the
@@ -129,10 +153,10 @@ type PrismaOptions<C extends PrismaLike> = {
129
153
  * against this same URL *before the process starts*; an application that
130
154
  * migrates itself at boot races every other replica.
131
155
  */
132
- export declare const prismaDatabase: <const N extends string>(name: N) => <C extends PrismaLike>({ client }: PrismaOptions<C>) => Module<HealthChecks | (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 | DatabaseUnreachable, Exclude<import("@btravstack/config").EnvReading<"DATABASE_URL", never>, HealthChecks | Observers | import("@btravstack/di").PortInstance<string, C> | import("@btravstack/di").PortInstance<`${N}Config`, {
156
+ export declare const prismaDatabase: <const N extends string>(name: N) => <C extends PrismaLike>({ client }: PrismaOptions<C>) => Module<HealthChecks | (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 | DatabaseUnreachable | RowSecurityBypassed, Exclude<import("@btravstack/config").EnvReading<"DATABASE_URL", never>, HealthChecks | Observers | import("@btravstack/di").PortInstance<string, C> | import("@btravstack/di").PortInstance<`${N}Config`, {
133
157
  readonly url: string;
134
158
  }>>> & {
135
159
  readonly port: PortClassOf<N, C>;
136
160
  };
137
161
  //#endregion
138
- export type { PrismaBinding, PrismaLike, PrismaOptions, SqlMiddlewareLike };
162
+ export type { PrismaBinding, PrismaLike, PrismaOptions, RowSecurityBypass, SqlMiddlewareLike };
package/dist/index.d.mts CHANGED
@@ -66,6 +66,24 @@ type PrismaLike = {
66
66
  */
67
67
  readonly runtime: unknown;
68
68
  };
69
+ /** Why row-level security does not apply to the role, as {@link RowSecurityBypassed} names it. */
70
+ type RowSecurityBypass = "superuser" | "bypassrls" | "owner";
71
+ declare const RowSecurityBypassed_base: import("unthrown").TaggedErrorConstructor<"RowSecurityBypassed">;
72
+ /**
73
+ * The role the client connected as is exempt from row-level security on a
74
+ * table that has it enabled: a superuser, a role with `BYPASSRLS`, or the
75
+ * owner of a table whose security is not `FORCE`d — which Prisma 8 cannot
76
+ * declare, so every table a migration created qualifies for the role that ran
77
+ * it. Every policy would admit every row, so the boot is refused rather than
78
+ * served without isolation. `tables` are the ones it would bypass; the URL is
79
+ * never carried.
80
+ */
81
+ export declare class RowSecurityBypassed extends RowSecurityBypassed_base<{
82
+ readonly database: string;
83
+ readonly role: string;
84
+ readonly bypass: RowSecurityBypass;
85
+ readonly tables: readonly string[];
86
+ }> {}
69
87
  declare const DatabaseUnreachable_base: import("unthrown").TaggedErrorConstructor<"DatabaseUnreachable">;
70
88
  /**
71
89
  * The database did not answer when the scope opened. Carries the database's
@@ -119,6 +137,12 @@ type PrismaOptions<C extends PrismaLike> = {
119
137
  * database that does not answer fails the boot with
120
138
  * {@link DatabaseUnreachable}.
121
139
  *
140
+ * **Row-level security has to apply to the role it connected as.** Where any
141
+ * table has it enabled, a role that is a superuser, has `BYPASSRLS`, or owns
142
+ * such a table without `FORCE` fails the boot with {@link RowSecurityBypassed}.
143
+ * A database with no row-secured table passes as it is. The health check does
144
+ * not repeat it: a role does not change under a running pool.
145
+ *
122
146
  * **The pool closes on every exit path**, including a boot that fails after
123
147
  * this provider ran — that is what makes it resourceful rather than a plain
124
148
  * value. `runtime().close()` ends the pool; Prisma dials again lazily on the
@@ -129,10 +153,10 @@ type PrismaOptions<C extends PrismaLike> = {
129
153
  * against this same URL *before the process starts*; an application that
130
154
  * migrates itself at boot races every other replica.
131
155
  */
132
- export declare const prismaDatabase: <const N extends string>(name: N) => <C extends PrismaLike>({ client }: PrismaOptions<C>) => Module<HealthChecks | (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 | DatabaseUnreachable, Exclude<import("@btravstack/config").EnvReading<"DATABASE_URL", never>, HealthChecks | Observers | import("@btravstack/di").PortInstance<string, C> | import("@btravstack/di").PortInstance<`${N}Config`, {
156
+ export declare const prismaDatabase: <const N extends string>(name: N) => <C extends PrismaLike>({ client }: PrismaOptions<C>) => Module<HealthChecks | (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 | DatabaseUnreachable | RowSecurityBypassed, Exclude<import("@btravstack/config").EnvReading<"DATABASE_URL", never>, HealthChecks | Observers | import("@btravstack/di").PortInstance<string, C> | import("@btravstack/di").PortInstance<`${N}Config`, {
133
157
  readonly url: string;
134
158
  }>>> & {
135
159
  readonly port: PortClassOf<N, C>;
136
160
  };
137
161
  //#endregion
138
- export type { PrismaBinding, PrismaLike, PrismaOptions, SqlMiddlewareLike };
162
+ export type { PrismaBinding, PrismaLike, PrismaOptions, RowSecurityBypass, SqlMiddlewareLike };
package/dist/index.mjs CHANGED
@@ -65,6 +65,21 @@ const queryObserver = (observers) => {
65
65
  /** The one place the cast above is spelled. */
66
66
  const probeable = (db) => db;
67
67
  const probe = (db) => probeable(db).runtime().query(probeable(db).raw.sql`SELECT 1`.affectedCount().build());
68
+ const exemptions = (db) => probeable(db).runtime().query(probeable(db).raw.sql`SELECT current_user::text AS role, c.oid::regclass::text AS relation, CASE WHEN r.rolsuper THEN 'superuser' WHEN r.rolbypassrls THEN 'bypassrls' ELSE 'owner' END AS bypass FROM pg_class c JOIN pg_roles r ON r.rolname = current_user WHERE c.relrowsecurity AND (r.rolsuper OR r.rolbypassrls OR (NOT c.relforcerowsecurity AND pg_has_role(current_user, c.relowner, 'USAGE'))) ORDER BY 2`.returnsRow({
69
+ role: "pg/text@1",
70
+ relation: "pg/text@1",
71
+ bypass: "pg/text@1"
72
+ }).build());
73
+ /**
74
+ * The role the client connected as is exempt from row-level security on a
75
+ * table that has it enabled: a superuser, a role with `BYPASSRLS`, or the
76
+ * owner of a table whose security is not `FORCE`d — which Prisma 8 cannot
77
+ * declare, so every table a migration created qualifies for the role that ran
78
+ * it. Every policy would admit every row, so the boot is refused rather than
79
+ * served without isolation. `tables` are the ones it would bypass; the URL is
80
+ * never carried.
81
+ */
82
+ var RowSecurityBypassed = class extends TaggedError("RowSecurityBypassed") {};
68
83
  /**
69
84
  * The database did not answer when the scope opened. Carries the database's
70
85
  * name and the driver's own failure, never the URL, which holds credentials.
@@ -83,6 +98,12 @@ var DatabaseUnreachable = class extends TaggedError("DatabaseUnreachable") {};
83
98
  * database that does not answer fails the boot with
84
99
  * {@link DatabaseUnreachable}.
85
100
  *
101
+ * **Row-level security has to apply to the role it connected as.** Where any
102
+ * table has it enabled, a role that is a superuser, has `BYPASSRLS`, or owns
103
+ * such a table without `FORCE` fails the boot with {@link RowSecurityBypassed}.
104
+ * A database with no row-secured table passes as it is. The health check does
105
+ * not repeat it: a role does not change under a running pool.
106
+ *
86
107
  * **The pool closes on every exit path**, including a boot that fails after
87
108
  * this provider ran — that is what makes it resourceful rather than a plain
88
109
  * value. `runtime().close()` ends the pool; Prisma dials again lazily on the
@@ -96,7 +117,12 @@ var DatabaseUnreachable = class extends TaggedError("DatabaseUnreachable") {};
96
117
  const prismaDatabase = (name) => ({ client }) => {
97
118
  const config = Config.provider(`${name}Config`)(Config.object({ url: Config.string("DATABASE_URL") }));
98
119
  const DatabasePort = Port(name);
99
- const verified = (db) => fromPromise(probe(db).then(() => db, (cause) => probeable(db).runtime().close().finally(() => Promise.reject(cause))), (cause) => new DatabaseUnreachable({
120
+ const verified = (db) => fromPromise(probe(db).then(() => exemptions(db)).then((exempt) => exempt[0] === void 0 ? db : Promise.reject(new RowSecurityBypassed({
121
+ database: name,
122
+ role: exempt[0].role,
123
+ bypass: exempt[0].bypass,
124
+ tables: exempt.map(({ relation }) => relation)
125
+ }))).catch((cause) => probeable(db).runtime().close().finally(() => Promise.reject(cause))), (cause) => cause instanceof RowSecurityBypassed ? cause : new DatabaseUnreachable({
100
126
  database: name,
101
127
  cause
102
128
  }));
@@ -132,4 +158,4 @@ const prismaDatabase = (name) => ({ client }) => {
132
158
  return Object.assign(database, { port: DatabasePort });
133
159
  };
134
160
  //#endregion
135
- export { DatabaseUnreachable, prismaDatabase };
161
+ export { DatabaseUnreachable, RowSecurityBypassed, prismaDatabase };
package/dist/result.cjs CHANGED
@@ -27,16 +27,31 @@ var ForeignKeyViolation = class extends (0, unthrown.TaggedError)("ForeignKeyVio
27
27
  */
28
28
  var NotAuthorized = class extends (0, unthrown.TaggedError)("NotAuthorized") {};
29
29
  /**
30
+ * A SQLSTATE the application raises on purpose — a trigger or a function
31
+ * refusing a write the schema cannot express with `RAISE EXCEPTION … USING
32
+ * ERRCODE = '…'` — modeled because the caller listed it in `raises`.
33
+ *
34
+ * `message` is the one the `RAISE` wrote. Raise in a class of your own rather
35
+ * than in `XX`, which PostgreSQL reserves for its internal errors.
36
+ */
37
+ var RaisedException = class extends (0, unthrown.TaggedError)("RaisedException") {
38
+ constructor({ message, ...fields }) {
39
+ super(fields);
40
+ this.message = message;
41
+ }
42
+ };
43
+ /**
30
44
  * SQLSTATE → a tagged error, or the caller's defect for anything unmodeled.
31
45
  *
32
46
  * The three arms are the ones an application triages. Everything else — a
33
47
  * syntax error, a dead connection, a deadlock — is infrastructure a caller
34
48
  * cannot act on differently, and belongs on the defect channel where an
35
- * unexpected failure already goes.
49
+ * unexpected failure already goes — unless the caller listed its SQLSTATE in
50
+ * `raises`.
36
51
  */
37
- const qualify = (cause, defect) => {
38
- const error = cause;
39
- switch (error?.sqlState) {
52
+ const qualify = (cause, defect, options) => {
53
+ const error = cause ?? {};
54
+ switch (error.sqlState) {
40
55
  case "23505": return new UniqueConstraintViolation({
41
56
  constraint: error.constraint,
42
57
  table: error.table
@@ -46,7 +61,15 @@ const qualify = (cause, defect) => {
46
61
  table: error.table
47
62
  });
48
63
  case "42501": return new NotAuthorized({ table: error.table });
49
- default: return defect(cause);
64
+ default: {
65
+ const raised = options?.raises?.find((code) => code === error.sqlState);
66
+ return raised === void 0 ? defect(cause) : new RaisedException({
67
+ sqlState: raised,
68
+ message: error.message,
69
+ constraint: error.constraint,
70
+ table: error.table
71
+ });
72
+ }
50
73
  }
51
74
  };
52
75
  /**
@@ -68,11 +91,21 @@ const qualify = (cause, defect) => {
68
91
  * .with(P.tag("NotAuthorized"), (refused) => defect(refused)),
69
92
  * );
70
93
  * ```
94
+ *
95
+ * A trigger's own refusal arrives as a {@link RaisedException} once its
96
+ * SQLSTATE is listed:
97
+ *
98
+ * ```ts
99
+ * tryQuery(() => db.orm.orders.Order.create(row), { raises: ["OR001"] });
100
+ * ```
71
101
  */
72
- const tryQuery = (run) => (0, unthrown.fromPromise)(Promise.resolve().then(run), qualify);
102
+ const tryQuery = (run, options) => {
103
+ return (0, unthrown.fromPromise)(Promise.resolve().then(run), (cause, defect) => qualify(cause, defect, options));
104
+ };
73
105
  //#endregion
74
106
  exports.ForeignKeyViolation = ForeignKeyViolation;
75
107
  exports.NotAuthorized = NotAuthorized;
108
+ exports.RaisedException = RaisedException;
76
109
  exports.UniqueConstraintViolation = UniqueConstraintViolation;
77
110
  exports.qualify = qualify;
78
111
  exports.tryQuery = tryQuery;
package/dist/result.d.cts CHANGED
@@ -36,17 +36,53 @@ declare const NotAuthorized_base: import("unthrown").TaggedErrorConstructor<"Not
36
36
  export declare class NotAuthorized extends NotAuthorized_base<{
37
37
  readonly table: string | undefined;
38
38
  }> {}
39
+ declare const RaisedException_base: import("unthrown").TaggedErrorConstructor<"RaisedException">;
40
+ /**
41
+ * A SQLSTATE the application raises on purpose — a trigger or a function
42
+ * refusing a write the schema cannot express with `RAISE EXCEPTION … USING
43
+ * ERRCODE = '…'` — modeled because the caller listed it in `raises`.
44
+ *
45
+ * `message` is the one the `RAISE` wrote. Raise in a class of your own rather
46
+ * than in `XX`, which PostgreSQL reserves for its internal errors.
47
+ */
48
+ export declare class RaisedException<S extends string = string> extends RaisedException_base<{
49
+ readonly sqlState: S;
50
+ readonly constraint: string | undefined;
51
+ readonly table: string | undefined;
52
+ }> {
53
+ constructor({ message, ...fields }: {
54
+ readonly sqlState: S;
55
+ readonly message: string;
56
+ readonly constraint: string | undefined;
57
+ readonly table: string | undefined;
58
+ });
59
+ }
39
60
  /** What {@link qualify} models. Anything else is a defect. */
40
61
  export type SqlError = UniqueConstraintViolation | ForeignKeyViolation | NotAuthorized;
62
+ /**
63
+ * One {@link RaisedException} per SQLSTATE listed, and nothing when none is,
64
+ * so a caller that lists no code keeps the three arms it already matches.
65
+ */
66
+ export type Raised<S extends string> = S extends string ? RaisedException<S> : never;
67
+ /** Options of {@link tryQuery} and {@link qualify}. */
68
+ export type QualifyOptions<S extends string> = {
69
+ /**
70
+ * The SQLSTATEs the application raises on purpose and wants as a
71
+ * {@link RaisedException} rather than a defect. The three SQLSTATEs
72
+ * {@link SqlError} already models keep their own arms.
73
+ */
74
+ readonly raises?: readonly S[];
75
+ };
41
76
  /**
42
77
  * SQLSTATE → a tagged error, or the caller's defect for anything unmodeled.
43
78
  *
44
79
  * The three arms are the ones an application triages. Everything else — a
45
80
  * syntax error, a dead connection, a deadlock — is infrastructure a caller
46
81
  * cannot act on differently, and belongs on the defect channel where an
47
- * unexpected failure already goes.
82
+ * unexpected failure already goes — unless the caller listed its SQLSTATE in
83
+ * `raises`.
48
84
  */
49
- export declare const qualify: <D>(cause: unknown, defect: (cause: unknown) => D) => SqlError | D;
85
+ export declare const qualify: <D, const S extends string = never>(cause: unknown, defect: (cause: unknown) => D, options?: QualifyOptions<S>) => SqlError | Raised<S> | D;
50
86
  /**
51
87
  * Runs a query and answers a `Result` instead of rejecting.
52
88
  *
@@ -66,6 +102,13 @@ export declare const qualify: <D>(cause: unknown, defect: (cause: unknown) => D)
66
102
  * .with(P.tag("NotAuthorized"), (refused) => defect(refused)),
67
103
  * );
68
104
  * ```
105
+ *
106
+ * A trigger's own refusal arrives as a {@link RaisedException} once its
107
+ * SQLSTATE is listed:
108
+ *
109
+ * ```ts
110
+ * tryQuery(() => db.orm.orders.Order.create(row), { raises: ["OR001"] });
111
+ * ```
69
112
  */
70
- export declare const tryQuery: <T>(run: () => PromiseLike<T>) => AsyncResult<T, SqlError>;
113
+ export declare const tryQuery: <T, const S extends string = never>(run: () => PromiseLike<T>, options?: QualifyOptions<S>) => AsyncResult<T, SqlError | Raised<S>>;
71
114
  //#endregion
package/dist/result.d.mts CHANGED
@@ -36,17 +36,53 @@ declare const NotAuthorized_base: import("unthrown").TaggedErrorConstructor<"Not
36
36
  export declare class NotAuthorized extends NotAuthorized_base<{
37
37
  readonly table: string | undefined;
38
38
  }> {}
39
+ declare const RaisedException_base: import("unthrown").TaggedErrorConstructor<"RaisedException">;
40
+ /**
41
+ * A SQLSTATE the application raises on purpose — a trigger or a function
42
+ * refusing a write the schema cannot express with `RAISE EXCEPTION … USING
43
+ * ERRCODE = '…'` — modeled because the caller listed it in `raises`.
44
+ *
45
+ * `message` is the one the `RAISE` wrote. Raise in a class of your own rather
46
+ * than in `XX`, which PostgreSQL reserves for its internal errors.
47
+ */
48
+ export declare class RaisedException<S extends string = string> extends RaisedException_base<{
49
+ readonly sqlState: S;
50
+ readonly constraint: string | undefined;
51
+ readonly table: string | undefined;
52
+ }> {
53
+ constructor({ message, ...fields }: {
54
+ readonly sqlState: S;
55
+ readonly message: string;
56
+ readonly constraint: string | undefined;
57
+ readonly table: string | undefined;
58
+ });
59
+ }
39
60
  /** What {@link qualify} models. Anything else is a defect. */
40
61
  export type SqlError = UniqueConstraintViolation | ForeignKeyViolation | NotAuthorized;
62
+ /**
63
+ * One {@link RaisedException} per SQLSTATE listed, and nothing when none is,
64
+ * so a caller that lists no code keeps the three arms it already matches.
65
+ */
66
+ export type Raised<S extends string> = S extends string ? RaisedException<S> : never;
67
+ /** Options of {@link tryQuery} and {@link qualify}. */
68
+ export type QualifyOptions<S extends string> = {
69
+ /**
70
+ * The SQLSTATEs the application raises on purpose and wants as a
71
+ * {@link RaisedException} rather than a defect. The three SQLSTATEs
72
+ * {@link SqlError} already models keep their own arms.
73
+ */
74
+ readonly raises?: readonly S[];
75
+ };
41
76
  /**
42
77
  * SQLSTATE → a tagged error, or the caller's defect for anything unmodeled.
43
78
  *
44
79
  * The three arms are the ones an application triages. Everything else — a
45
80
  * syntax error, a dead connection, a deadlock — is infrastructure a caller
46
81
  * cannot act on differently, and belongs on the defect channel where an
47
- * unexpected failure already goes.
82
+ * unexpected failure already goes — unless the caller listed its SQLSTATE in
83
+ * `raises`.
48
84
  */
49
- export declare const qualify: <D>(cause: unknown, defect: (cause: unknown) => D) => SqlError | D;
85
+ export declare const qualify: <D, const S extends string = never>(cause: unknown, defect: (cause: unknown) => D, options?: QualifyOptions<S>) => SqlError | Raised<S> | D;
50
86
  /**
51
87
  * Runs a query and answers a `Result` instead of rejecting.
52
88
  *
@@ -66,6 +102,13 @@ export declare const qualify: <D>(cause: unknown, defect: (cause: unknown) => D)
66
102
  * .with(P.tag("NotAuthorized"), (refused) => defect(refused)),
67
103
  * );
68
104
  * ```
105
+ *
106
+ * A trigger's own refusal arrives as a {@link RaisedException} once its
107
+ * SQLSTATE is listed:
108
+ *
109
+ * ```ts
110
+ * tryQuery(() => db.orm.orders.Order.create(row), { raises: ["OR001"] });
111
+ * ```
69
112
  */
70
- export declare const tryQuery: <T>(run: () => PromiseLike<T>) => AsyncResult<T, SqlError>;
113
+ export declare const tryQuery: <T, const S extends string = never>(run: () => PromiseLike<T>, options?: QualifyOptions<S>) => AsyncResult<T, SqlError | Raised<S>>;
71
114
  //#endregion
package/dist/result.mjs CHANGED
@@ -26,16 +26,31 @@ var ForeignKeyViolation = class extends TaggedError("ForeignKeyViolation") {};
26
26
  */
27
27
  var NotAuthorized = class extends TaggedError("NotAuthorized") {};
28
28
  /**
29
+ * A SQLSTATE the application raises on purpose — a trigger or a function
30
+ * refusing a write the schema cannot express with `RAISE EXCEPTION … USING
31
+ * ERRCODE = '…'` — modeled because the caller listed it in `raises`.
32
+ *
33
+ * `message` is the one the `RAISE` wrote. Raise in a class of your own rather
34
+ * than in `XX`, which PostgreSQL reserves for its internal errors.
35
+ */
36
+ var RaisedException = class extends TaggedError("RaisedException") {
37
+ constructor({ message, ...fields }) {
38
+ super(fields);
39
+ this.message = message;
40
+ }
41
+ };
42
+ /**
29
43
  * SQLSTATE → a tagged error, or the caller's defect for anything unmodeled.
30
44
  *
31
45
  * The three arms are the ones an application triages. Everything else — a
32
46
  * syntax error, a dead connection, a deadlock — is infrastructure a caller
33
47
  * cannot act on differently, and belongs on the defect channel where an
34
- * unexpected failure already goes.
48
+ * unexpected failure already goes — unless the caller listed its SQLSTATE in
49
+ * `raises`.
35
50
  */
36
- const qualify = (cause, defect) => {
37
- const error = cause;
38
- switch (error?.sqlState) {
51
+ const qualify = (cause, defect, options) => {
52
+ const error = cause ?? {};
53
+ switch (error.sqlState) {
39
54
  case "23505": return new UniqueConstraintViolation({
40
55
  constraint: error.constraint,
41
56
  table: error.table
@@ -45,7 +60,15 @@ const qualify = (cause, defect) => {
45
60
  table: error.table
46
61
  });
47
62
  case "42501": return new NotAuthorized({ table: error.table });
48
- default: return defect(cause);
63
+ default: {
64
+ const raised = options?.raises?.find((code) => code === error.sqlState);
65
+ return raised === void 0 ? defect(cause) : new RaisedException({
66
+ sqlState: raised,
67
+ message: error.message,
68
+ constraint: error.constraint,
69
+ table: error.table
70
+ });
71
+ }
49
72
  }
50
73
  };
51
74
  /**
@@ -67,7 +90,16 @@ const qualify = (cause, defect) => {
67
90
  * .with(P.tag("NotAuthorized"), (refused) => defect(refused)),
68
91
  * );
69
92
  * ```
93
+ *
94
+ * A trigger's own refusal arrives as a {@link RaisedException} once its
95
+ * SQLSTATE is listed:
96
+ *
97
+ * ```ts
98
+ * tryQuery(() => db.orm.orders.Order.create(row), { raises: ["OR001"] });
99
+ * ```
70
100
  */
71
- const tryQuery = (run) => fromPromise(Promise.resolve().then(run), qualify);
101
+ const tryQuery = (run, options) => {
102
+ return fromPromise(Promise.resolve().then(run), (cause, defect) => qualify(cause, defect, options));
103
+ };
72
104
  //#endregion
73
- export { ForeignKeyViolation, NotAuthorized, UniqueConstraintViolation, qualify, tryQuery };
105
+ export { ForeignKeyViolation, NotAuthorized, RaisedException, UniqueConstraintViolation, qualify, tryQuery };
package/dist/rls.cjs CHANGED
@@ -13,7 +13,8 @@ const require_result = require("./result.cjs");
13
13
  * the first time it does not; it is the shape a middleware would take, and it
14
14
  * is why there is no middleware here.
15
15
  *
16
- * It answers an `AsyncResult`, qualified by {@link tryQuery}: the pin and the
16
+ * It answers an `AsyncResult`, qualified by {@link tryQuery} — with
17
+ * `raises` as it takes it: the pin and the
17
18
  * work run on one connection and fail on one channel, so a caller never wraps
18
19
  * this in a second combinator. `work` still hands back a `PromiseLike`, because
19
20
  * that is what Prisma's own transaction callback is.
@@ -34,7 +35,7 @@ const tenantPinned = (db, tenant, work, options) => {
34
35
  return require_result.tryQuery(() => db.transaction(async (tx) => {
35
36
  await tx.query(plan);
36
37
  return work(tx);
37
- }));
38
+ }), options);
38
39
  };
39
40
  //#endregion
40
41
  exports.tenantPinned = tenantPinned;
package/dist/rls.d.cts CHANGED
@@ -1,8 +1,11 @@
1
- import { SqlError } from "./result.cjs";
1
+ import { QualifyOptions, Raised, SqlError } from "./result.cjs";
2
2
  import { AsyncResult } from "unthrown";
3
3
  //#region src/rls.d.ts
4
- /** Options of {@link tenantPinned}. */
5
- export type TenantPinnedOptions = {
4
+ /**
5
+ * Options of {@link tenantPinned}: `raises`, as {@link tryQuery} takes it,
6
+ * and the setting the pin writes.
7
+ */
8
+ export type TenantPinnedOptions<S extends string = never> = QualifyOptions<S> & {
6
9
  /**
7
10
  * The PostgreSQL run-time setting the policy reads. Defaults to
8
11
  * `app.tenant_id`, matching `current_setting('app.tenant_id', true)`.
@@ -43,7 +46,8 @@ type Queryable = {
43
46
  * the first time it does not; it is the shape a middleware would take, and it
44
47
  * is why there is no middleware here.
45
48
  *
46
- * It answers an `AsyncResult`, qualified by {@link tryQuery}: the pin and the
49
+ * It answers an `AsyncResult`, qualified by {@link tryQuery} — with
50
+ * `raises` as it takes it: the pin and the
47
51
  * work run on one connection and fail on one channel, so a caller never wraps
48
52
  * this in a second combinator. `work` still hands back a `PromiseLike`, because
49
53
  * that is what Prisma's own transaction callback is.
@@ -58,5 +62,5 @@ type Queryable = {
58
62
  * const orders = await tenantPinned(db, tenant, (tx) => tx.orm.orders.Order.all());
59
63
  * ```
60
64
  */
61
- export declare const tenantPinned: <Tx extends Queryable, R>(db: Pinnable<Tx>, tenant: string, work: (tx: Tx) => PromiseLike<R>, options?: TenantPinnedOptions) => AsyncResult<R, SqlError>;
65
+ export declare const tenantPinned: <Tx extends Queryable, R, const S extends string = never>(db: Pinnable<Tx>, tenant: string, work: (tx: Tx) => PromiseLike<R>, options?: TenantPinnedOptions<S>) => AsyncResult<R, SqlError | Raised<S>>;
62
66
  //#endregion
package/dist/rls.d.mts CHANGED
@@ -1,8 +1,11 @@
1
- import { SqlError } from "./result.mjs";
1
+ import { QualifyOptions, Raised, SqlError } from "./result.mjs";
2
2
  import { AsyncResult } from "unthrown";
3
3
  //#region src/rls.d.ts
4
- /** Options of {@link tenantPinned}. */
5
- export type TenantPinnedOptions = {
4
+ /**
5
+ * Options of {@link tenantPinned}: `raises`, as {@link tryQuery} takes it,
6
+ * and the setting the pin writes.
7
+ */
8
+ export type TenantPinnedOptions<S extends string = never> = QualifyOptions<S> & {
6
9
  /**
7
10
  * The PostgreSQL run-time setting the policy reads. Defaults to
8
11
  * `app.tenant_id`, matching `current_setting('app.tenant_id', true)`.
@@ -43,7 +46,8 @@ type Queryable = {
43
46
  * the first time it does not; it is the shape a middleware would take, and it
44
47
  * is why there is no middleware here.
45
48
  *
46
- * It answers an `AsyncResult`, qualified by {@link tryQuery}: the pin and the
49
+ * It answers an `AsyncResult`, qualified by {@link tryQuery} — with
50
+ * `raises` as it takes it: the pin and the
47
51
  * work run on one connection and fail on one channel, so a caller never wraps
48
52
  * this in a second combinator. `work` still hands back a `PromiseLike`, because
49
53
  * that is what Prisma's own transaction callback is.
@@ -58,5 +62,5 @@ type Queryable = {
58
62
  * const orders = await tenantPinned(db, tenant, (tx) => tx.orm.orders.Order.all());
59
63
  * ```
60
64
  */
61
- export declare const tenantPinned: <Tx extends Queryable, R>(db: Pinnable<Tx>, tenant: string, work: (tx: Tx) => PromiseLike<R>, options?: TenantPinnedOptions) => AsyncResult<R, SqlError>;
65
+ export declare const tenantPinned: <Tx extends Queryable, R, const S extends string = never>(db: Pinnable<Tx>, tenant: string, work: (tx: Tx) => PromiseLike<R>, options?: TenantPinnedOptions<S>) => AsyncResult<R, SqlError | Raised<S>>;
62
66
  //#endregion
package/dist/rls.mjs CHANGED
@@ -12,7 +12,8 @@ import { tryQuery } from "./result.mjs";
12
12
  * the first time it does not; it is the shape a middleware would take, and it
13
13
  * is why there is no middleware here.
14
14
  *
15
- * It answers an `AsyncResult`, qualified by {@link tryQuery}: the pin and the
15
+ * It answers an `AsyncResult`, qualified by {@link tryQuery} — with
16
+ * `raises` as it takes it: the pin and the
16
17
  * work run on one connection and fail on one channel, so a caller never wraps
17
18
  * this in a second combinator. `work` still hands back a `PromiseLike`, because
18
19
  * that is what Prisma's own transaction callback is.
@@ -33,7 +34,7 @@ const tenantPinned = (db, tenant, work, options) => {
33
34
  return tryQuery(() => db.transaction(async (tx) => {
34
35
  await tx.query(plan);
35
36
  return work(tx);
36
- }));
37
+ }), options);
37
38
  };
38
39
  //#endregion
39
40
  export { tenantPinned };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btravstack/prisma",
3
- "version": "0.27.0",
3
+ "version": "0.29.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",
@@ -63,9 +63,9 @@
63
63
  "./package.json": "./package.json"
64
64
  },
65
65
  "devDependencies": {
66
- "@btravstack/config": "0.27.0",
67
- "@btravstack/core": "0.27.0",
68
- "@btravstack/di": "0.27.0",
66
+ "@btravstack/config": "0.29.0",
67
+ "@btravstack/core": "0.29.0",
68
+ "@btravstack/di": "0.29.0",
69
69
  "@btravstack/tsconfig": "0.4.0",
70
70
  "@prisma/orm-postgres": "8.0.0-rc.11",
71
71
  "@types/node": "26.6.4",
@@ -77,9 +77,9 @@
77
77
  "vitest": "5.0.3"
78
78
  },
79
79
  "peerDependencies": {
80
- "@btravstack/config": "^0.27.0",
81
- "@btravstack/core": "^0.27.0",
82
- "@btravstack/di": "^0.27.0",
80
+ "@btravstack/config": "^0.29.0",
81
+ "@btravstack/core": "^0.29.0",
82
+ "@btravstack/di": "^0.29.0",
83
83
  "@prisma/orm-postgres": "^8.0.0-rc.11",
84
84
  "unthrown": "^5.0.0"
85
85
  },