@btravstack/prisma 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Benoit TRAVERS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # @btravstack/prisma
2
+
3
+ > The Prisma starter for [`@btravstack/core`](https://github.com/btravstack/btravstack):
4
+ > `DATABASE_URL` bound through `Config`, the Postgres driver adapter, and a
5
+ > client whose pool is the application scope's.
6
+
7
+ ๐Ÿ“– **[Documentation](https://btravstack.github.io/btravstack/reference/prisma)** ยท
8
+ [API Reference](https://btravstack.github.io/btravstack/api/prisma/)
9
+
10
+ ```sh
11
+ pnpm add @btravstack/prisma @btravstack/core @btravstack/config @btravstack/di unthrown \
12
+ @prisma/adapter-pg
13
+ ```
14
+
15
+ Five peer dependencies โ€” install every one, so the application holds a single
16
+ copy of each. Your own `@prisma/client`, `prisma` and (if you want the `try*`
17
+ twins) `@unthrown/prisma` are yours, not this package's: the client is
18
+ generated from your schema. Node `>=20`.
19
+
20
+ ## A worked example
21
+
22
+ <!-- doctest: group=order-api -->
23
+ <!-- doctest: prelude
24
+ import { Env } from "@btravstack/config";
25
+ import { Logger, Meter, Tracer } from "@btravstack/core";
26
+ import { Module } from "@btravstack/di";
27
+ import { PrismaPg } from "@prisma/adapter-pg";
28
+
29
+ // The stand-in for the client YOUR schema generates, and the extension you
30
+ // apply to it. Neither exists in this package โ€” that is what the `client`
31
+ // arrow is for.
32
+ declare class PrismaClient {
33
+ constructor(options: { readonly adapter: PrismaPg });
34
+ $disconnect(): Promise<void>;
35
+ $extends(extension: unknown): this;
36
+ }
37
+ declare const unthrownPrisma: unknown;
38
+ -->
39
+
40
+ ```ts
41
+ import { prismaDatabase } from "@btravstack/prisma";
42
+
43
+ const database = prismaDatabase("OrderDatabase")({
44
+ client: (adapter) => new PrismaClient({ adapter }).$extends(unthrownPrisma),
45
+ });
46
+ ```
47
+
48
+ That is the whole surface. `database` carries three pieces, and a composition
49
+ root wants all three:
50
+
51
+ ```ts
52
+ export const PersistenceModule = Module("Persistence")({
53
+ imports: [database],
54
+ exports: [database.port],
55
+ needs: [Env, Logger, Meter, Tracer],
56
+ });
57
+ ```
58
+
59
+ **The client type is yours, and that is deliberate.** A Prisma client is
60
+ generated from _your_ schema, so there is none for this package to ship โ€” the
61
+ `client` arrow is where your generated class and your extensions meet, and the
62
+ port is typed by exactly what it returns.
63
+
64
+ ## What it owns
65
+
66
+ - **`DATABASE_URL` through `Config`.** A missing or blank value is a modeled
67
+ `ConfigInvalid` naming the variable, not a throw โ€” so a misconfigured
68
+ deployment exits `78` with the reason on stderr instead of crashing on the
69
+ first query.
70
+ - **The Postgres driver adapter**, constructed from that URL. Another driver is
71
+ reachable โ€” build it in your own `client` arrow and ignore the one passed in.
72
+ - **The pool's lifetime.** The provider is _resourceful_, so `release` closes it
73
+ on every exit path, including a boot that fails after it ran.
74
+
75
+ ## What it does not
76
+
77
+ **Migrations.** A deployment runs `prisma migrate deploy` against this same URL
78
+ _before the process starts_. An application that migrates itself at boot races
79
+ every other replica.
80
+
81
+ **Transactions.** Commit boundaries belong to the adapter, spelled at the call โ€”
82
+ `@unthrown/prisma`'s `$tryTransaction` is the primitive. There is no unit-scoped
83
+ transaction and there will not be one; see
84
+ [the kernel maps nothing](https://btravstack.github.io/btravstack/explanation/the-kernel-maps-nothing).
85
+
86
+ **A repository base class.** Ports are your application's vocabulary, not this
87
+ package's.
88
+
89
+ ## Options
90
+
91
+ | Option | Default | What it does |
92
+ | -------- | ---------------- | -------------------------------------------------- |
93
+ | `client` | โ€” | Builds the client from the driver adapter and URL. |
94
+ | `urlVar` | `"DATABASE_URL"` | The variable the connection string is read from. |
95
+
96
+ ## License
97
+
98
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,150 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _btravstack_config = require("@btravstack/config");
3
+ let _btravstack_core = require("@btravstack/core");
4
+ let _btravstack_di = require("@btravstack/di");
5
+ let _prisma_adapter_pg = require("@prisma/adapter-pg");
6
+ let unthrown = require("unthrown");
7
+ //#region src/instrument.ts
8
+ /**
9
+ * Every query, wrapped: a span around it, a count of how it came out, and a log
10
+ * line if it failed.
11
+ *
12
+ * **This is why a generated client can be instrumented at all.** Prisma's
13
+ * `$extends` takes a `query` component, and `$allModels.$allOperations`
14
+ * intercepts every operation on every model โ€” so the wrapper never needs to
15
+ * know the schema, which is the thing this package cannot see. An earlier
16
+ * revision of this package claimed instrumentation was impossible for that
17
+ * reason; it was wrong, and this is the mechanism it missed.
18
+ *
19
+ * **It deliberately opens no span.** `@btravstack/prisma/otel` enables Prisma's
20
+ * own `@prisma/instrumentation`, which traces at the ENGINE level โ€” the real
21
+ * SQL, the connection acquisition, the serialisation โ€” all of it below what a
22
+ * client-level wrapper can see. Emitting a span here as well would put two
23
+ * spans on every query for strictly less information. What this wrapper keeps
24
+ * is the pair Prisma's instrumentation does not do at all: a metric, and an
25
+ * error line correlated with the ambient unit.
26
+ *
27
+ * The wrapper is transparent to the answer: whatever the query resolves or
28
+ * rejects with is what the caller receives, which is the kernel's own `RunUnit`
29
+ * rule one layer down. `Promise.reject` re-raises rather than `throw`, so the
30
+ * rejection propagates to `@unthrown/prisma`'s `try*` twin without this file
31
+ * needing a `no-throw` exemption.
32
+ */
33
+ const instrument = (client, logger, meter) => {
34
+ const operations = meter.createCounter("btravstack.database.operations", { description: "Database queries, by model, operation and outcome" });
35
+ return client.$extends({ query: { $allModels: { $allOperations: ({ model, operation, args, query }) => {
36
+ const label = model ?? "raw";
37
+ return query(args).then((value) => {
38
+ operations.add(1, {
39
+ model: label,
40
+ operation,
41
+ outcome: "ok"
42
+ });
43
+ return value;
44
+ }, (cause) => {
45
+ operations.add(1, {
46
+ model: label,
47
+ operation,
48
+ outcome: "error"
49
+ });
50
+ logger.error("a database query failed", {
51
+ model: label,
52
+ operation
53
+ }, cause);
54
+ return Promise.reject(cause);
55
+ });
56
+ } } } });
57
+ };
58
+ //#endregion
59
+ //#region src/tracing.ts
60
+ /** Injected so a spec can drive the arm where the optional peer is absent. */
61
+ const defaultLoader = async () => (await import("@prisma/instrumentation")).PrismaInstrumentation;
62
+ /**
63
+ * Prisma's own OpenTelemetry instrumentation, turned on if it is installed.
64
+ *
65
+ * **A dynamic import, and it has to be**: `@prisma/instrumentation` is an
66
+ * OPTIONAL peer, so a static import would make every consumer install it. The
67
+ * import is attempted once, when the instrumented client is built, and a
68
+ * failure to resolve is an ordinary answer rather than a fault โ€” an application
69
+ * that never wanted engine tracing simply does not have the package.
70
+ *
71
+ * **This can be a provider at all** because `@prisma/instrumentation` patches no
72
+ * modules: `enable()` sets a helper on `globalThis` under a versioned key, and
73
+ * a Prisma client reads it PER QUERY. Registration order is therefore free, and
74
+ * this works after `@prisma/client` is imported and after clients are built.
75
+ * The `--import` preload rule in `@btravstack/observability` governs
76
+ * instrumentations that patch, and does not reach this one.
77
+ *
78
+ * **The skip is logged, not silent.** Telemetry you believe you have and do not
79
+ * is worse than none, so the one path that quietly produces no spans says so at
80
+ * `debug` โ€” the level for something that is a choice rather than a fault.
81
+ */
82
+ const enableTracing = async (logger, load = defaultLoader) => {
83
+ try {
84
+ const instrumentation = new (await (load()))();
85
+ instrumentation.enable();
86
+ return instrumentation;
87
+ } catch {
88
+ logger.debug("engine-level tracing is off: @prisma/instrumentation is not installed. Queries are still counted and failures still logged.");
89
+ return;
90
+ }
91
+ };
92
+ //#endregion
93
+ //#region src/prisma.ts
94
+ /**
95
+ * A Prisma client whose pool is the application scope's.
96
+ *
97
+ * Returns the three pieces a composition root needs and nothing more: the
98
+ * `config` provider binding the connection string, the `port` the client is
99
+ * reached through, and the resourceful `provider` that opens it and closes it
100
+ * again. Put both providers in a module's `provides` and export the port.
101
+ *
102
+ * **The pool closes on every exit path**, including a boot that fails after
103
+ * this provider ran โ€” that is what makes it resourceful rather than a plain
104
+ * value. `$disconnect` ends the driver adapter's pool without killing the
105
+ * client; Prisma dials again lazily on the next statement, which is why no
106
+ * spec asserts that a released client refuses to query.
107
+ *
108
+ * **Migrations are not run here.** A deployment runs `prisma migrate deploy`
109
+ * against this same URL *before the process starts*; an application that
110
+ * migrates itself at boot races every other replica.
111
+ */
112
+ const prismaDatabase = (name) => ({ client, urlVar = "DATABASE_URL", instrumented }) => {
113
+ const config = _btravstack_config.Config.provider(`${name}Config`)(_btravstack_config.Config.object({ url: _btravstack_config.Config.string(urlVar) }));
114
+ const DatabasePort = (0, _btravstack_di.Port)(name);
115
+ const open = (url) => client(new _prisma_adapter_pg.PrismaPg({ connectionString: url }), url);
116
+ const port = DatabasePort;
117
+ const instrumentedProvider = (0, _btravstack_di.Provider)(port)({
118
+ settings: config.port,
119
+ logger: _btravstack_core.Logger,
120
+ tracer: _btravstack_core.Tracer,
121
+ meter: _btravstack_core.Meter
122
+ }, {
123
+ acquire: ({ settings, logger, tracer, meter }) => {
124
+ return (0, unthrown.fromSafePromise)(enableTracing(logger).then(() => instrument(open(settings.url), logger, meter)));
125
+ },
126
+ release: (db) => db.$disconnect()
127
+ });
128
+ const plainProvider = (0, _btravstack_di.Provider)(port)({ settings: config.port }, {
129
+ acquire: ({ settings }) => (0, unthrown.OkAsync)(open(settings.url)),
130
+ release: (db) => db.$disconnect()
131
+ });
132
+ const instrumentedModule = (0, _btravstack_di.Module)(name)({
133
+ needs: [
134
+ _btravstack_config.Env,
135
+ _btravstack_core.Logger,
136
+ _btravstack_core.Meter,
137
+ _btravstack_core.Tracer
138
+ ],
139
+ provides: [config, instrumentedProvider],
140
+ exports: [DatabasePort]
141
+ });
142
+ const plainModule = (0, _btravstack_di.Module)(name)({
143
+ needs: [_btravstack_config.Env],
144
+ provides: [config, plainProvider],
145
+ exports: [DatabasePort]
146
+ });
147
+ return Object.assign(instrumented !== false ? instrumentedModule : plainModule, { port: DatabasePort });
148
+ };
149
+ //#endregion
150
+ exports.prismaDatabase = prismaDatabase;
@@ -0,0 +1,76 @@
1
+ import { Env } from "@btravstack/config";
2
+ import { Logger, Meter, Tracer } from "@btravstack/core";
3
+ import { Module, PortClassOf } from "@btravstack/di";
4
+ import { PrismaPg } from "@prisma/adapter-pg";
5
+ //#region src/prisma.d.ts
6
+ /**
7
+ * All this starter needs of a client: a pool it can close. A generated Prisma
8
+ * client satisfies it structurally, and so does an extended one โ€” `$extends`
9
+ * preserves `$disconnect` โ€” which is why the application's own client type
10
+ * flows through untouched.
11
+ */
12
+ type PrismaLike = {
13
+ readonly $disconnect: () => Promise<void>;
14
+ };
15
+ /** What {@link prismaDatabase} is handed. */
16
+ type PrismaOptions<C extends PrismaLike, Instrumented extends boolean> = {
17
+ /**
18
+ * Builds the client. The driver adapter is already constructed from the
19
+ * environment's URL; the second argument is that URL, for a client that wants
20
+ * it directly.
21
+ *
22
+ * This is the one thing the starter cannot own: a Prisma client is
23
+ * **generated per application** from its own schema, so there is no client
24
+ * type to ship. Applying `@unthrown/prisma`'s extension belongs here too, so
25
+ * the returned type is exactly the one the application will hold.
26
+ */
27
+ readonly client: (adapter: PrismaPg, url: string) => C;
28
+ /** The variable the connection string is read from. `DATABASE_URL` by 12-factor convention. */
29
+ readonly urlVar?: string;
30
+ /**
31
+ * Span, count and log every query. **Default `true`**, `false` opts out โ€” the
32
+ * same shape as `cache`, `mailer` and `storage`, and as `StartOptions`'
33
+ * `signals` and `probes`.
34
+ *
35
+ * On by default because telemetry that is missing is discovered during an
36
+ * incident. The cost is stated rather than hidden: instrumenting puts
37
+ * `Logger`, `Meter` and `Tracer` in the provider's dependencies, so a root
38
+ * without `observability()` and `otel()` gets a compile error naming all
39
+ * three. It also turns on Prisma's own engine-level tracing when
40
+ * `@prisma/instrumentation` is installed โ€” no wiring at the root.
41
+ */
42
+ readonly instrumented?: Instrumented;
43
+ };
44
+ /**
45
+ * A Prisma client whose pool is the application scope's.
46
+ *
47
+ * Returns the three pieces a composition root needs and nothing more: the
48
+ * `config` provider binding the connection string, the `port` the client is
49
+ * reached through, and the resourceful `provider` that opens it and closes it
50
+ * again. Put both providers in a module's `provides` and export the port.
51
+ *
52
+ * **The pool closes on every exit path**, including a boot that fails after
53
+ * this provider ran โ€” that is what makes it resourceful rather than a plain
54
+ * value. `$disconnect` ends the driver adapter's pool without killing the
55
+ * client; Prisma dials again lazily on the next statement, which is why no
56
+ * spec asserts that a released client refuses to query.
57
+ *
58
+ * **Migrations are not run here.** A deployment runs `prisma migrate deploy`
59
+ * against this same URL *before the process starts*; an application that
60
+ * migrates itself at boot races every other replica.
61
+ */
62
+ declare const prismaDatabase: <const N extends string>(name: N) => <C extends PrismaLike, Instrumented extends boolean = true>({ client, urlVar, instrumented }: PrismaOptions<C, Instrumented>) => (Instrumented extends true ? Module<PortClassOf<N, C> extends (infer T) ? T extends PortClassOf<N, C> ? T extends import("@btravstack/di").AnyPort ? InstanceType<T> : T extends import("@btravstack/di").AnyProvider ? InstanceType<T["port"]> : never : never : never, import("@btravstack/config").ConfigInvalid, Exclude<Env, import("@btravstack/di").PortInstance<string, C> | import("@btravstack/di").PortInstance<`${N}Config`, {
63
+ readonly url: string;
64
+ }>> | Exclude<Logger, import("@btravstack/di").PortInstance<string, C> | import("@btravstack/di").PortInstance<`${N}Config`, {
65
+ readonly url: string;
66
+ }>> | Exclude<Meter, import("@btravstack/di").PortInstance<string, C> | import("@btravstack/di").PortInstance<`${N}Config`, {
67
+ readonly url: string;
68
+ }>> | Exclude<Tracer, import("@btravstack/di").PortInstance<string, C> | import("@btravstack/di").PortInstance<`${N}Config`, {
69
+ readonly url: string;
70
+ }>>> : Module<PortClassOf<N, C> extends (infer T) ? T extends PortClassOf<N, C> ? T extends import("@btravstack/di").AnyPort ? InstanceType<T> : T extends import("@btravstack/di").AnyProvider ? InstanceType<T["port"]> : never : never : never, import("@btravstack/config").ConfigInvalid, Exclude<Env, import("@btravstack/di").PortInstance<string, C> | import("@btravstack/di").PortInstance<`${N}Config`, {
71
+ readonly url: string;
72
+ }>>>) & {
73
+ readonly port: PortClassOf<N, C>;
74
+ };
75
+ //#endregion
76
+ export { type PrismaLike, type PrismaOptions, prismaDatabase };
@@ -0,0 +1,76 @@
1
+ import { Env } from "@btravstack/config";
2
+ import { Logger, Meter, Tracer } from "@btravstack/core";
3
+ import { Module, PortClassOf } from "@btravstack/di";
4
+ import { PrismaPg } from "@prisma/adapter-pg";
5
+ //#region src/prisma.d.ts
6
+ /**
7
+ * All this starter needs of a client: a pool it can close. A generated Prisma
8
+ * client satisfies it structurally, and so does an extended one โ€” `$extends`
9
+ * preserves `$disconnect` โ€” which is why the application's own client type
10
+ * flows through untouched.
11
+ */
12
+ type PrismaLike = {
13
+ readonly $disconnect: () => Promise<void>;
14
+ };
15
+ /** What {@link prismaDatabase} is handed. */
16
+ type PrismaOptions<C extends PrismaLike, Instrumented extends boolean> = {
17
+ /**
18
+ * Builds the client. The driver adapter is already constructed from the
19
+ * environment's URL; the second argument is that URL, for a client that wants
20
+ * it directly.
21
+ *
22
+ * This is the one thing the starter cannot own: a Prisma client is
23
+ * **generated per application** from its own schema, so there is no client
24
+ * type to ship. Applying `@unthrown/prisma`'s extension belongs here too, so
25
+ * the returned type is exactly the one the application will hold.
26
+ */
27
+ readonly client: (adapter: PrismaPg, url: string) => C;
28
+ /** The variable the connection string is read from. `DATABASE_URL` by 12-factor convention. */
29
+ readonly urlVar?: string;
30
+ /**
31
+ * Span, count and log every query. **Default `true`**, `false` opts out โ€” the
32
+ * same shape as `cache`, `mailer` and `storage`, and as `StartOptions`'
33
+ * `signals` and `probes`.
34
+ *
35
+ * On by default because telemetry that is missing is discovered during an
36
+ * incident. The cost is stated rather than hidden: instrumenting puts
37
+ * `Logger`, `Meter` and `Tracer` in the provider's dependencies, so a root
38
+ * without `observability()` and `otel()` gets a compile error naming all
39
+ * three. It also turns on Prisma's own engine-level tracing when
40
+ * `@prisma/instrumentation` is installed โ€” no wiring at the root.
41
+ */
42
+ readonly instrumented?: Instrumented;
43
+ };
44
+ /**
45
+ * A Prisma client whose pool is the application scope's.
46
+ *
47
+ * Returns the three pieces a composition root needs and nothing more: the
48
+ * `config` provider binding the connection string, the `port` the client is
49
+ * reached through, and the resourceful `provider` that opens it and closes it
50
+ * again. Put both providers in a module's `provides` and export the port.
51
+ *
52
+ * **The pool closes on every exit path**, including a boot that fails after
53
+ * this provider ran โ€” that is what makes it resourceful rather than a plain
54
+ * value. `$disconnect` ends the driver adapter's pool without killing the
55
+ * client; Prisma dials again lazily on the next statement, which is why no
56
+ * spec asserts that a released client refuses to query.
57
+ *
58
+ * **Migrations are not run here.** A deployment runs `prisma migrate deploy`
59
+ * against this same URL *before the process starts*; an application that
60
+ * migrates itself at boot races every other replica.
61
+ */
62
+ declare const prismaDatabase: <const N extends string>(name: N) => <C extends PrismaLike, Instrumented extends boolean = true>({ client, urlVar, instrumented }: PrismaOptions<C, Instrumented>) => (Instrumented extends true ? Module<PortClassOf<N, C> extends (infer T) ? T extends PortClassOf<N, C> ? T extends import("@btravstack/di").AnyPort ? InstanceType<T> : T extends import("@btravstack/di").AnyProvider ? InstanceType<T["port"]> : never : never : never, import("@btravstack/config").ConfigInvalid, Exclude<Env, import("@btravstack/di").PortInstance<string, C> | import("@btravstack/di").PortInstance<`${N}Config`, {
63
+ readonly url: string;
64
+ }>> | Exclude<Logger, import("@btravstack/di").PortInstance<string, C> | import("@btravstack/di").PortInstance<`${N}Config`, {
65
+ readonly url: string;
66
+ }>> | Exclude<Meter, import("@btravstack/di").PortInstance<string, C> | import("@btravstack/di").PortInstance<`${N}Config`, {
67
+ readonly url: string;
68
+ }>> | Exclude<Tracer, import("@btravstack/di").PortInstance<string, C> | import("@btravstack/di").PortInstance<`${N}Config`, {
69
+ readonly url: string;
70
+ }>>> : Module<PortClassOf<N, C> extends (infer T) ? T extends PortClassOf<N, C> ? T extends import("@btravstack/di").AnyPort ? InstanceType<T> : T extends import("@btravstack/di").AnyProvider ? InstanceType<T["port"]> : never : never : never, import("@btravstack/config").ConfigInvalid, Exclude<Env, import("@btravstack/di").PortInstance<string, C> | import("@btravstack/di").PortInstance<`${N}Config`, {
71
+ readonly url: string;
72
+ }>>>) & {
73
+ readonly port: PortClassOf<N, C>;
74
+ };
75
+ //#endregion
76
+ export { type PrismaLike, type PrismaOptions, prismaDatabase };
package/dist/index.mjs ADDED
@@ -0,0 +1,149 @@
1
+ import { Config, Env } from "@btravstack/config";
2
+ import { Logger, Meter, Tracer } from "@btravstack/core";
3
+ import { Module, Port, Provider } from "@btravstack/di";
4
+ import { PrismaPg } from "@prisma/adapter-pg";
5
+ import { OkAsync, fromSafePromise } from "unthrown";
6
+ //#region src/instrument.ts
7
+ /**
8
+ * Every query, wrapped: a span around it, a count of how it came out, and a log
9
+ * line if it failed.
10
+ *
11
+ * **This is why a generated client can be instrumented at all.** Prisma's
12
+ * `$extends` takes a `query` component, and `$allModels.$allOperations`
13
+ * intercepts every operation on every model โ€” so the wrapper never needs to
14
+ * know the schema, which is the thing this package cannot see. An earlier
15
+ * revision of this package claimed instrumentation was impossible for that
16
+ * reason; it was wrong, and this is the mechanism it missed.
17
+ *
18
+ * **It deliberately opens no span.** `@btravstack/prisma/otel` enables Prisma's
19
+ * own `@prisma/instrumentation`, which traces at the ENGINE level โ€” the real
20
+ * SQL, the connection acquisition, the serialisation โ€” all of it below what a
21
+ * client-level wrapper can see. Emitting a span here as well would put two
22
+ * spans on every query for strictly less information. What this wrapper keeps
23
+ * is the pair Prisma's instrumentation does not do at all: a metric, and an
24
+ * error line correlated with the ambient unit.
25
+ *
26
+ * The wrapper is transparent to the answer: whatever the query resolves or
27
+ * rejects with is what the caller receives, which is the kernel's own `RunUnit`
28
+ * rule one layer down. `Promise.reject` re-raises rather than `throw`, so the
29
+ * rejection propagates to `@unthrown/prisma`'s `try*` twin without this file
30
+ * needing a `no-throw` exemption.
31
+ */
32
+ const instrument = (client, logger, meter) => {
33
+ const operations = meter.createCounter("btravstack.database.operations", { description: "Database queries, by model, operation and outcome" });
34
+ return client.$extends({ query: { $allModels: { $allOperations: ({ model, operation, args, query }) => {
35
+ const label = model ?? "raw";
36
+ return query(args).then((value) => {
37
+ operations.add(1, {
38
+ model: label,
39
+ operation,
40
+ outcome: "ok"
41
+ });
42
+ return value;
43
+ }, (cause) => {
44
+ operations.add(1, {
45
+ model: label,
46
+ operation,
47
+ outcome: "error"
48
+ });
49
+ logger.error("a database query failed", {
50
+ model: label,
51
+ operation
52
+ }, cause);
53
+ return Promise.reject(cause);
54
+ });
55
+ } } } });
56
+ };
57
+ //#endregion
58
+ //#region src/tracing.ts
59
+ /** Injected so a spec can drive the arm where the optional peer is absent. */
60
+ const defaultLoader = async () => (await import("@prisma/instrumentation")).PrismaInstrumentation;
61
+ /**
62
+ * Prisma's own OpenTelemetry instrumentation, turned on if it is installed.
63
+ *
64
+ * **A dynamic import, and it has to be**: `@prisma/instrumentation` is an
65
+ * OPTIONAL peer, so a static import would make every consumer install it. The
66
+ * import is attempted once, when the instrumented client is built, and a
67
+ * failure to resolve is an ordinary answer rather than a fault โ€” an application
68
+ * that never wanted engine tracing simply does not have the package.
69
+ *
70
+ * **This can be a provider at all** because `@prisma/instrumentation` patches no
71
+ * modules: `enable()` sets a helper on `globalThis` under a versioned key, and
72
+ * a Prisma client reads it PER QUERY. Registration order is therefore free, and
73
+ * this works after `@prisma/client` is imported and after clients are built.
74
+ * The `--import` preload rule in `@btravstack/observability` governs
75
+ * instrumentations that patch, and does not reach this one.
76
+ *
77
+ * **The skip is logged, not silent.** Telemetry you believe you have and do not
78
+ * is worse than none, so the one path that quietly produces no spans says so at
79
+ * `debug` โ€” the level for something that is a choice rather than a fault.
80
+ */
81
+ const enableTracing = async (logger, load = defaultLoader) => {
82
+ try {
83
+ const instrumentation = new (await (load()))();
84
+ instrumentation.enable();
85
+ return instrumentation;
86
+ } catch {
87
+ logger.debug("engine-level tracing is off: @prisma/instrumentation is not installed. Queries are still counted and failures still logged.");
88
+ return;
89
+ }
90
+ };
91
+ //#endregion
92
+ //#region src/prisma.ts
93
+ /**
94
+ * A Prisma client whose pool is the application scope's.
95
+ *
96
+ * Returns the three pieces a composition root needs and nothing more: the
97
+ * `config` provider binding the connection string, the `port` the client is
98
+ * reached through, and the resourceful `provider` that opens it and closes it
99
+ * again. Put both providers in a module's `provides` and export the port.
100
+ *
101
+ * **The pool closes on every exit path**, including a boot that fails after
102
+ * this provider ran โ€” that is what makes it resourceful rather than a plain
103
+ * value. `$disconnect` ends the driver adapter's pool without killing the
104
+ * client; Prisma dials again lazily on the next statement, which is why no
105
+ * spec asserts that a released client refuses to query.
106
+ *
107
+ * **Migrations are not run here.** A deployment runs `prisma migrate deploy`
108
+ * against this same URL *before the process starts*; an application that
109
+ * migrates itself at boot races every other replica.
110
+ */
111
+ const prismaDatabase = (name) => ({ client, urlVar = "DATABASE_URL", instrumented }) => {
112
+ const config = Config.provider(`${name}Config`)(Config.object({ url: Config.string(urlVar) }));
113
+ const DatabasePort = Port(name);
114
+ const open = (url) => client(new PrismaPg({ connectionString: url }), url);
115
+ const port = DatabasePort;
116
+ const instrumentedProvider = Provider(port)({
117
+ settings: config.port,
118
+ logger: Logger,
119
+ tracer: Tracer,
120
+ meter: Meter
121
+ }, {
122
+ acquire: ({ settings, logger, tracer, meter }) => {
123
+ return fromSafePromise(enableTracing(logger).then(() => instrument(open(settings.url), logger, meter)));
124
+ },
125
+ release: (db) => db.$disconnect()
126
+ });
127
+ const plainProvider = Provider(port)({ settings: config.port }, {
128
+ acquire: ({ settings }) => OkAsync(open(settings.url)),
129
+ release: (db) => db.$disconnect()
130
+ });
131
+ const instrumentedModule = Module(name)({
132
+ needs: [
133
+ Env,
134
+ Logger,
135
+ Meter,
136
+ Tracer
137
+ ],
138
+ provides: [config, instrumentedProvider],
139
+ exports: [DatabasePort]
140
+ });
141
+ const plainModule = Module(name)({
142
+ needs: [Env],
143
+ provides: [config, plainProvider],
144
+ exports: [DatabasePort]
145
+ });
146
+ return Object.assign(instrumented !== false ? instrumentedModule : plainModule, { port: DatabasePort });
147
+ };
148
+ //#endregion
149
+ export { prismaDatabase };
package/package.json ADDED
@@ -0,0 +1,83 @@
1
+ {
2
+ "name": "@btravstack/prisma",
3
+ "version": "0.3.0",
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
+ "keywords": [
6
+ "database",
7
+ "dependency-injection",
8
+ "postgres",
9
+ "prisma",
10
+ "typescript",
11
+ "unthrown"
12
+ ],
13
+ "homepage": "https://github.com/btravstack/btravstack#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/btravstack/btravstack/issues"
16
+ },
17
+ "license": "MIT",
18
+ "author": "Benoit TRAVERS <benoit.travers.fr@gmail.com>",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/btravstack/btravstack.git",
22
+ "directory": "packages/prisma"
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "type": "module",
28
+ "sideEffects": false,
29
+ "main": "./dist/index.cjs",
30
+ "module": "./dist/index.mjs",
31
+ "types": "./dist/index.d.cts",
32
+ "exports": {
33
+ ".": {
34
+ "import": {
35
+ "types": "./dist/index.d.mts",
36
+ "default": "./dist/index.mjs"
37
+ },
38
+ "require": {
39
+ "types": "./dist/index.d.cts",
40
+ "default": "./dist/index.cjs"
41
+ }
42
+ },
43
+ "./package.json": "./package.json"
44
+ },
45
+ "devDependencies": {
46
+ "@btravstack/tsconfig": "0.2.0",
47
+ "@prisma/adapter-pg": "7.9.1",
48
+ "@prisma/instrumentation": "7.10.0",
49
+ "@types/node": "26.2.0",
50
+ "@unthrown/vitest": "5.7.0",
51
+ "@vitest/coverage-v8": "4.1.11",
52
+ "tsdown": "0.22.14",
53
+ "typescript": "7.0.2",
54
+ "unthrown": "5.7.0",
55
+ "vitest": "4.1.11",
56
+ "@btravstack/config": "0.3.0",
57
+ "@btravstack/di": "0.3.0",
58
+ "@btravstack/core": "0.3.0"
59
+ },
60
+ "peerDependencies": {
61
+ "@prisma/adapter-pg": "^7.0.0",
62
+ "@prisma/instrumentation": "^7.0.0",
63
+ "unthrown": "^5.0.0",
64
+ "@btravstack/config": "^0.3.0",
65
+ "@btravstack/core": "^0.3.0",
66
+ "@btravstack/di": "^0.3.0"
67
+ },
68
+ "peerDependenciesMeta": {
69
+ "@prisma/instrumentation": {
70
+ "optional": true
71
+ }
72
+ },
73
+ "engines": {
74
+ "node": ">=20"
75
+ },
76
+ "scripts": {
77
+ "build": "tsdown src/index.ts --format cjs,esm --dts --clean",
78
+ "dev": "tsdown src/index.ts --format cjs,esm --dts --watch",
79
+ "test": "vitest run --coverage",
80
+ "test:types": "tsc -p tsconfig.test-d.json --noEmit",
81
+ "typecheck": "tsc --noEmit"
82
+ }
83
+ }