@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 +21 -0
- package/README.md +98 -0
- package/dist/index.cjs +150 -0
- package/dist/index.d.cts +76 -0
- package/dist/index.d.mts +76 -0
- package/dist/index.mjs +149 -0
- package/package.json +83 -0
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;
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.mts
ADDED
|
@@ -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
|
+
}
|