@ddd-ts-kit/prisma 0.0.0-stage → 0.1.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.
Files changed (118) hide show
  1. package/README.md +629 -2
  2. package/dist/index.d.ts +20 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +22 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/outbox/locked-id-of.d.ts +2 -0
  7. package/dist/outbox/locked-id-of.d.ts.map +1 -0
  8. package/dist/outbox/locked-id-of.js +7 -0
  9. package/dist/outbox/locked-id-of.js.map +1 -0
  10. package/dist/outbox/locking-select.d.ts +4 -0
  11. package/dist/outbox/locking-select.d.ts.map +1 -0
  12. package/dist/outbox/locking-select.js +20 -0
  13. package/dist/outbox/locking-select.js.map +1 -0
  14. package/dist/outbox/payload-field.d.ts +9 -0
  15. package/dist/outbox/payload-field.d.ts.map +1 -0
  16. package/dist/outbox/payload-field.js +12 -0
  17. package/dist/outbox/payload-field.js.map +1 -0
  18. package/dist/outbox/prisma-json-input.d.ts +5 -0
  19. package/dist/outbox/prisma-json-input.d.ts.map +1 -0
  20. package/dist/outbox/prisma-json-input.js +2 -0
  21. package/dist/outbox/prisma-json-input.js.map +1 -0
  22. package/dist/outbox/prisma-outbox-claim.d.ts +25 -0
  23. package/dist/outbox/prisma-outbox-claim.d.ts.map +1 -0
  24. package/dist/outbox/prisma-outbox-claim.js +2 -0
  25. package/dist/outbox/prisma-outbox-claim.js.map +1 -0
  26. package/dist/outbox/prisma-outbox-delegate.d.ts +87 -0
  27. package/dist/outbox/prisma-outbox-delegate.d.ts.map +1 -0
  28. package/dist/outbox/prisma-outbox-delegate.js +2 -0
  29. package/dist/outbox/prisma-outbox-delegate.js.map +1 -0
  30. package/dist/outbox/prisma-outbox-store.d.ts +54 -0
  31. package/dist/outbox/prisma-outbox-store.d.ts.map +1 -0
  32. package/dist/outbox/prisma-outbox-store.js +150 -0
  33. package/dist/outbox/prisma-outbox-store.js.map +1 -0
  34. package/dist/outbox/prisma-processed-event-delegate.d.ts +17 -0
  35. package/dist/outbox/prisma-processed-event-delegate.d.ts.map +1 -0
  36. package/dist/outbox/prisma-processed-event-delegate.js +2 -0
  37. package/dist/outbox/prisma-processed-event-delegate.js.map +1 -0
  38. package/dist/outbox/prisma-processed-event-store.d.ts +19 -0
  39. package/dist/outbox/prisma-processed-event-store.d.ts.map +1 -0
  40. package/dist/outbox/prisma-processed-event-store.js +23 -0
  41. package/dist/outbox/prisma-processed-event-store.js.map +1 -0
  42. package/dist/outbox/prisma-raw-queries.d.ts +5 -0
  43. package/dist/outbox/prisma-raw-queries.d.ts.map +1 -0
  44. package/dist/outbox/prisma-raw-queries.js +2 -0
  45. package/dist/outbox/prisma-raw-queries.js.map +1 -0
  46. package/dist/outbox/to-outbox-record.d.ts +4 -0
  47. package/dist/outbox/to-outbox-record.d.ts.map +1 -0
  48. package/dist/outbox/to-outbox-record.js +25 -0
  49. package/dist/outbox/to-outbox-record.js.map +1 -0
  50. package/dist/read-model/criteria-to-prisma.d.ts +10 -0
  51. package/dist/read-model/criteria-to-prisma.d.ts.map +1 -0
  52. package/dist/read-model/criteria-to-prisma.js +23 -0
  53. package/dist/read-model/criteria-to-prisma.js.map +1 -0
  54. package/dist/read-model/predicate-to-prisma.d.ts +11 -0
  55. package/dist/read-model/predicate-to-prisma.d.ts.map +1 -0
  56. package/dist/read-model/predicate-to-prisma.js +41 -0
  57. package/dist/read-model/predicate-to-prisma.js.map +1 -0
  58. package/dist/read-model/prisma-criteria.d.ts +13 -0
  59. package/dist/read-model/prisma-criteria.d.ts.map +1 -0
  60. package/dist/read-model/prisma-criteria.js +2 -0
  61. package/dist/read-model/prisma-criteria.js.map +1 -0
  62. package/dist/read-model/prisma-model-rows.d.ts +17 -0
  63. package/dist/read-model/prisma-model-rows.d.ts.map +1 -0
  64. package/dist/read-model/prisma-model-rows.js +50 -0
  65. package/dist/read-model/prisma-model-rows.js.map +1 -0
  66. package/dist/read-model/prisma-read-model-delegate.d.ts +21 -0
  67. package/dist/read-model/prisma-read-model-delegate.d.ts.map +1 -0
  68. package/dist/read-model/prisma-read-model-delegate.js +2 -0
  69. package/dist/read-model/prisma-read-model-delegate.js.map +1 -0
  70. package/dist/read-model/prisma-read-model-options.d.ts +13 -0
  71. package/dist/read-model/prisma-read-model-options.d.ts.map +1 -0
  72. package/dist/read-model/prisma-read-model-options.js +2 -0
  73. package/dist/read-model/prisma-read-model-options.js.map +1 -0
  74. package/dist/read-model/prisma-read-model-store.d.ts +17 -0
  75. package/dist/read-model/prisma-read-model-store.d.ts.map +1 -0
  76. package/dist/read-model/prisma-read-model-store.js +13 -0
  77. package/dist/read-model/prisma-read-model-store.js.map +1 -0
  78. package/dist/read-model/prisma-read-model-table.d.ts +32 -0
  79. package/dist/read-model/prisma-read-model-table.d.ts.map +1 -0
  80. package/dist/read-model/prisma-read-model-table.js +84 -0
  81. package/dist/read-model/prisma-read-model-table.js.map +1 -0
  82. package/dist/read-model/prisma-table-rows.d.ts +27 -0
  83. package/dist/read-model/prisma-table-rows.d.ts.map +1 -0
  84. package/dist/read-model/prisma-table-rows.js +59 -0
  85. package/dist/read-model/prisma-table-rows.js.map +1 -0
  86. package/dist/read-model/row.mapper.d.ts +17 -0
  87. package/dist/read-model/row.mapper.d.ts.map +1 -0
  88. package/dist/read-model/row.mapper.js +58 -0
  89. package/dist/read-model/row.mapper.js.map +1 -0
  90. package/dist/read-model/stamped-row-of.d.ts +11 -0
  91. package/dist/read-model/stamped-row-of.d.ts.map +1 -0
  92. package/dist/read-model/stamped-row-of.js +13 -0
  93. package/dist/read-model/stamped-row-of.js.map +1 -0
  94. package/dist/read-model/written-of.d.ts +4 -0
  95. package/dist/read-model/written-of.d.ts.map +1 -0
  96. package/dist/read-model/written-of.js +12 -0
  97. package/dist/read-model/written-of.js.map +1 -0
  98. package/dist/unit-of-work/create-prisma-unit-of-work.d.ts +18 -0
  99. package/dist/unit-of-work/create-prisma-unit-of-work.d.ts.map +1 -0
  100. package/dist/unit-of-work/create-prisma-unit-of-work.js +30 -0
  101. package/dist/unit-of-work/create-prisma-unit-of-work.js.map +1 -0
  102. package/dist/unit-of-work/prisma-context.d.ts +15 -0
  103. package/dist/unit-of-work/prisma-context.d.ts.map +1 -0
  104. package/dist/unit-of-work/prisma-context.js +14 -0
  105. package/dist/unit-of-work/prisma-context.js.map +1 -0
  106. package/dist/unit-of-work/prisma-transaction-client.d.ts +8 -0
  107. package/dist/unit-of-work/prisma-transaction-client.d.ts.map +1 -0
  108. package/dist/unit-of-work/prisma-transaction-client.js +2 -0
  109. package/dist/unit-of-work/prisma-transaction-client.js.map +1 -0
  110. package/dist/unit-of-work/prisma-transaction-host.d.ts +9 -0
  111. package/dist/unit-of-work/prisma-transaction-host.d.ts.map +1 -0
  112. package/dist/unit-of-work/prisma-transaction-host.js +2 -0
  113. package/dist/unit-of-work/prisma-transaction-host.js.map +1 -0
  114. package/package.json +44 -4
  115. package/prisma/outbox.mysql.prisma +40 -0
  116. package/prisma/outbox.postgresql.prisma +43 -0
  117. package/prisma/outbox.sqlite.prisma +42 -0
  118. package/prisma/read-model.postgresql.prisma +26 -0
package/README.md CHANGED
@@ -1,3 +1,630 @@
1
- # Temporary Holding Version
1
+ # @ddd-ts-kit/prisma
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Prisma adapters of the kernel and events ports: a unit of work that repositories never see,
4
+ the transactional outbox store, the processed-event store, and the models of their tables as
5
+ a schema fragment to copy. Same pieces as `@ddd-ts-kit/drizzle`, for projects on Prisma.
6
+
7
+ ```
8
+ npm i @ddd-ts-kit/prisma @prisma/client @prisma/adapter-pg
9
+ npm i -D prisma
10
+ ```
11
+
12
+ The package imports no Prisma code: it is typed structurally against the few methods it calls,
13
+ so it works with your generated client, wherever you generate it, and with an extended client
14
+ (`$extends`). PostgreSQL first; see [Dialect coverage](#dialect-coverage) for MySQL and SQLite.
15
+
16
+ ## Schema
17
+
18
+ Prisma has one schema, yours. The package ships the models to copy into it, one file per
19
+ provider:
20
+
21
+ ```
22
+ node_modules/@ddd-ts-kit/prisma/prisma/outbox.postgresql.prisma
23
+ node_modules/@ddd-ts-kit/prisma/prisma/outbox.mysql.prisma
24
+ node_modules/@ddd-ts-kit/prisma/prisma/outbox.sqlite.prisma
25
+ ```
26
+
27
+ ```prisma
28
+ // prisma/schema.prisma (PostgreSQL fragment)
29
+ model OutboxRecord {
30
+ id String @id
31
+ sequence BigInt @unique @default(autoincrement())
32
+ type String
33
+ version Int
34
+ occurredAt String @map("occurred_at")
35
+ payload Json?
36
+ traceparent String?
37
+ correlationId String? @map("correlation_id")
38
+ status String @default("pending")
39
+ attempts Int @default(0)
40
+ nextAttemptAt DateTime @map("next_attempt_at") @db.Timestamptz(3)
41
+ lastErrorCode String? @map("last_error_code")
42
+ publishedAt DateTime? @map("published_at") @db.Timestamptz(3)
43
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
44
+
45
+ @@index([status, nextAttemptAt, sequence])
46
+ @@map("outbox")
47
+ }
48
+
49
+ model ProcessedEvent {
50
+ consumer String
51
+ eventId String @map("event_id")
52
+ processedAt DateTime @default(now()) @map("processed_at") @db.Timestamptz(3)
53
+
54
+ @@id([consumer, eventId])
55
+ @@map("processed_events")
56
+ }
57
+ ```
58
+
59
+ `prisma migrate dev` then creates the tables in a migration of your project. Keep the field
60
+ names, the stores are typed against them; model names, table names and column names are yours.
61
+
62
+ One schema per bounded context: list the schemas in the datasource, put each model in its own
63
+ with `@@schema`, and give a context that keeps its own outbox a model name of its own.
64
+ `schemaNameOf('billing-core')` gives the name to write, `billing_core`.
65
+
66
+ ```prisma
67
+ datasource db {
68
+ provider = "postgresql"
69
+ schemas = ["messaging", "billing_core"]
70
+ }
71
+
72
+ model MessagingOutboxRecord {
73
+ // ...the fields above
74
+ @@map("outbox")
75
+ @@schema("messaging")
76
+ }
77
+
78
+ model Invoice {
79
+ id String @id
80
+ amount Int
81
+ status String
82
+ version Int
83
+
84
+ @@map("invoices")
85
+ @@schema("billing_core")
86
+ }
87
+ ```
88
+
89
+ Contexts never relate to each other's models: no `@relation` across schemas.
90
+
91
+ ## Composition root
92
+
93
+ `PrismaContext` is the client repositories and stores run their queries on. It is generic over
94
+ the transaction client of your generated client, so the project names its own database with a
95
+ class, which is then the injection token:
96
+
97
+ ```ts
98
+ // platform/database/database.ts
99
+ import { PrismaContext } from '@ddd-ts-kit/prisma';
100
+ import type { Prisma } from '$generated/prisma/client.js';
101
+
102
+ export abstract class Database extends PrismaContext<Prisma.TransactionClient> {}
103
+ ```
104
+
105
+ `createPrismaUnitOfWork(prisma)` gives one object that is both the kernel `UnitOfWork` and
106
+ that `Database`. Bind it to both:
107
+
108
+ ```ts
109
+ // platform/database/persistence.module.ts
110
+ import { PrismaPg } from '@prisma/adapter-pg';
111
+ import { defineContextModule } from '@ddd-ts-kit/di';
112
+ import { IntegrationEventPublisher, ProcessedEventStore, UnitOfWork } from '@ddd-ts-kit/kernel';
113
+ import { OutboxPublisher, OutboxStore } from '@ddd-ts-kit/events';
114
+ import {
115
+ createPrismaUnitOfWork,
116
+ PrismaOutboxStore,
117
+ PrismaProcessedEventStore,
118
+ } from '@ddd-ts-kit/prisma';
119
+ import { PrismaClient } from '$generated/prisma/client.js';
120
+ import { Database } from '@/platform/database/database.js';
121
+
122
+ const prisma = new PrismaClient({
123
+ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
124
+ });
125
+ const unitOfWork = createPrismaUnitOfWork(prisma);
126
+ const outboxStore = new PrismaOutboxStore(unitOfWork, {
127
+ delegate: (client) => client.messagingOutboxRecord,
128
+ claim: { strategy: 'skip-locked', dialect: 'postgresql', schema: 'messaging', raw: (client) => client },
129
+ });
130
+
131
+ export const persistenceModule = defineContextModule({
132
+ name: 'persistence',
133
+ register(builder) {
134
+ builder.rebind(UnitOfWork).toValue(unitOfWork);
135
+ builder.bind(Database).toValue(unitOfWork);
136
+ builder.bind(OutboxStore).toValue(outboxStore);
137
+ builder.rebind(ProcessedEventStore).toFactory(
138
+ (resolve) =>
139
+ new PrismaProcessedEventStore(resolve(Database), { delegate: (client) => client.messagingProcessedEvent }),
140
+ );
141
+ builder.rebind(IntegrationEventPublisher).toClass(OutboxPublisher, [OutboxStore]);
142
+ },
143
+ });
144
+ ```
145
+
146
+ ```ts
147
+ // main.ts
148
+ const app = createApplication({ modules: [kernelModule(), persistenceModule, contextModule('billing', billingDeclarations)] });
149
+
150
+ const relay = new OutboxRelay({ store: app.resolve(OutboxStore), publisher: broker, clock: app.resolve(Clock) });
151
+ relay.start({ intervalMs: 500 });
152
+ ```
153
+
154
+ List the module after `kernelModule()`: `rebind` replaces its in-memory defaults. The command
155
+ bus of the kernel module runs every command in the bound `UnitOfWork`; handlers do not know it.
156
+ `contextModule` of `@ddd-ts-kit/di` turns the declared classes of a context into its module.
157
+ The stores are built here and not by `toClass`, because both need to be told how to reach
158
+ their model. The outbox store takes the unit of work itself, not only the context: its
159
+ `skip-locked` claim opens a short transaction of its own.
160
+
161
+ A project written by the CLI holds the same wiring in two files:
162
+ `platform/database/persistence-over.ts` builds the unit of work and the two stores once, and
163
+ `platform/platform.module.ts` binds them, the outbox publisher being given to
164
+ `kernelModule({ eventPublisher })`.
165
+
166
+ `unitOfWork.run(work)` opens the interactive `prisma.$transaction` and keeps the transaction
167
+ client in an `AsyncLocalStorage`; a nested `run` joins the outer transaction. `delegate` is a
168
+ function of the client because the model must be read on the transaction client inside a unit
169
+ of work and on the root client outside. Because `append` uses the current client, an event
170
+ published by a handler that then fails is rolled back with the business write: that is the
171
+ whole point of the outbox.
172
+
173
+ `createPrismaUnitOfWork(prisma, { timeout: 15_000, isolationLevel: 'RepeatableRead' })`
174
+ forwards the options of `$transaction`.
175
+
176
+ `run` is built on `withinUnitOfWork` of the kernel. The domain events of the aggregates saved
177
+ inside it wait until `work` has returned, then are dispatched inside the transaction, before
178
+ the commit: their handlers see every save, and what they write commits or rolls back with it.
179
+ If `work`, a handler or the commit fails, nothing is left: no row, no outbox record, no handler
180
+ run after the commit. Handlers declared `after-commit` run once the transaction has committed.
181
+ A nested `run` joins the unit of work in progress: one dispatch, at the end of the outermost.
182
+ A `run` called directly (a consumer, a script) has the same timing as a command.
183
+ `unitOfWorkContract` of `@ddd-ts-kit/testing/contracts` proves it, with a deferred constraint
184
+ for the commit that fails; on a real PostgreSQL (`DDD_TS_KIT_TEST_DATABASE_URL`) it also proves
185
+ that the rows of a `before-commit` handler are invisible from another connection and visible
186
+ to the `after-commit` handlers.
187
+ The `before-commit` handlers run inside the time Prisma allows the transaction (`timeout`).
188
+
189
+ ## Repository
190
+
191
+ The port lives in the domain, the adapter in infrastructure. A repository receives the
192
+ database, never a transaction: `client()` is the transaction client of the unit of work when
193
+ there is one and the root client otherwise.
194
+
195
+ ```ts
196
+ // contexts/billing-core/domain/repositories/invoice.repository.ts
197
+ import { Repository } from '@ddd-ts-kit/kernel';
198
+
199
+ export abstract class InvoiceRepository extends Repository<Invoice> {}
200
+ ```
201
+
202
+ ```ts
203
+ // contexts/billing-core/infrastructure/persistence/prisma-invoice.repository.ts
204
+ import { Repository } from '@ddd-ts-kit/kernel/declarations';
205
+ import { optimisticUpdate } from '@ddd-ts-kit/prisma';
206
+
207
+ @Repository(InvoiceRepository, [Database])
208
+ export class PrismaInvoiceRepository implements InvoiceRepository {
209
+ constructor(private readonly database: Database) {}
210
+
211
+ async findById(id: string): Promise<Invoice | undefined> {
212
+ const row = await this.database.client().invoice.findUnique({ where: { id } });
213
+
214
+ if (row === null) {
215
+ return undefined;
216
+ }
217
+
218
+ const invoice = InvoiceMapper.toDomain(row);
219
+ invoice.version = row.version;
220
+
221
+ return invoice;
222
+ }
223
+
224
+ async save(invoice: Invoice): Promise<void> {
225
+ const invoices = this.database.client().invoice;
226
+
227
+ await optimisticUpdate(invoice, async ({ expected, next }) => {
228
+ const row = { ...invoice.state, version: next };
229
+ const written =
230
+ expected === 0
231
+ ? await invoices.createMany({ data: [row], skipDuplicates: true })
232
+ : await invoices.updateMany({ where: { id: invoice.id, version: expected }, data: row });
233
+
234
+ return written.count;
235
+ });
236
+ }
237
+ }
238
+ ```
239
+
240
+ In the explicit style, without the decorator, the same binding is one line of
241
+ `<context>.module.ts`:
242
+ `builder.bind(InvoiceRepository).toClass(PrismaInvoiceRepository, [Database])`.
243
+
244
+ `Prisma.TransactionClient` is the type of `client()` for a plain client. An extended client
245
+ (`prisma.$extends(...)`) has a transaction client type of its own, which Prisma does not make
246
+ assignable to it: declare `Database` as `PrismaContext<PrismaTransactionClient<typeof prisma>>`
247
+ instead.
248
+
249
+ `optimisticUpdate` gives the version to expect and the version to write, raises a
250
+ `ConcurrencyError` (a `DomainError` with code `CONCURRENCY_CONFLICT`) when the guarded
251
+ statement wrote no row, and moves `invoice.version` on otherwise. Use `createMany` and
252
+ `updateMany` for the guarded write: they return a count, where `create` and `update` throw,
253
+ and a thrown unique violation aborts the surrounding PostgreSQL transaction. `upsert` cannot
254
+ carry the version guard.
255
+
256
+ ## Audit columns, tenant scoping and pages
257
+
258
+ The standard persistence shapes of the kernel, on Prisma. A Prisma schema has no way to share
259
+ fields between models, so there is no helper to import here: copy the five fields. Keep their
260
+ names, which are those of the kernel `AuditStamp` and `TenantScoped`, so a stamp spreads into
261
+ `data` as it is; and keep the mapped column names (`created_at`, `created_by`, `updated_at`,
262
+ `updated_by`, `tenant_id`), the same in every project, which is what lets a dashboard, an
263
+ export or the audit package be written once.
264
+
265
+ ```prisma
266
+ model Invoice {
267
+ id String @id
268
+ amount Int
269
+ status String
270
+ version Int
271
+ tenantId String @map("tenant_id")
272
+ createdAt DateTime @map("created_at") @db.Timestamptz(3)
273
+ createdBy String @map("created_by")
274
+ updatedAt DateTime @map("updated_at") @db.Timestamptz(3)
275
+ updatedBy String @map("updated_by")
276
+
277
+ @@index([tenantId, amount, id], map: "invoices_tenant_amount_idx")
278
+ @@map("invoices")
279
+ @@schema("billing_core")
280
+ }
281
+ ```
282
+
283
+ No `@default(now())` and no `@updatedAt` on them: the instant is the one of the `Clock`, and
284
+ Prisma's own attributes know nothing of who acts.
285
+
286
+ ```ts
287
+ import { Clock, ExecutionScope, stampCreation, stampUpdate, tenantScopeOf } from '@ddd-ts-kit/kernel';
288
+ import { Repository } from '@ddd-ts-kit/kernel/declarations';
289
+
290
+ @Repository(InvoiceRepository, [Database, ExecutionScope, Clock])
291
+ export class PrismaInvoiceRepository implements InvoiceRepository {
292
+ constructor(
293
+ private readonly database: Database,
294
+ private readonly scope: ExecutionScope,
295
+ private readonly clock: Clock,
296
+ ) {}
297
+
298
+ async findById(id: string): Promise<Invoice | undefined> {
299
+ // findFirst, not findUnique: the tenant is part of every condition, and it is not part of the key.
300
+ const row = await this.database.client().invoice.findFirst({ where: { id, ...tenantScopeOf(this.scope) } });
301
+
302
+ return row === null ? undefined : InvoiceMapper.toDomain(row);
303
+ }
304
+
305
+ async save(invoice: Invoice): Promise<void> {
306
+ await optimisticUpdate(invoice, async ({ expected, next }) => {
307
+ const state = { ...InvoiceMapper.toRow(invoice), version: next };
308
+ const written = expected === 0 ? await this.insert(state) : await this.update(state, expected);
309
+
310
+ return written.count;
311
+ });
312
+ }
313
+
314
+ private insert(state: VersionedInvoice): Promise<Prisma.BatchPayload> {
315
+ const row = { ...state, ...tenantScopeOf(this.scope), ...stampCreation(this.scope, this.clock) };
316
+
317
+ return this.database.client().invoice.createMany({ data: [row], skipDuplicates: true });
318
+ }
319
+
320
+ private update(state: VersionedInvoice, expected: number): Promise<Prisma.BatchPayload> {
321
+ return this.database.client().invoice.updateMany({
322
+ where: { id: state.id, version: expected, ...tenantScopeOf(this.scope) },
323
+ data: { ...state, ...stampUpdate(this.scope, this.clock) },
324
+ });
325
+ }
326
+ }
327
+ ```
328
+
329
+ A search query takes the kernel `PageRequest` and answers a `Page`, in both variants:
330
+
331
+ ```ts
332
+ // contexts/billing-core/application/ports/invoice-queries.port.ts
333
+ export abstract class InvoiceQueries {
334
+ abstract search(request: PageRequest<'amount'>): Promise<Page<InvoiceSummary>>;
335
+ }
336
+ ```
337
+
338
+ ```ts
339
+ // contexts/billing-core/infrastructure/persistence/prisma-invoice-queries.reader.ts
340
+ import { ExecutionScope, encodeCursor, pageOf, tenantScopeOf } from '@ddd-ts-kit/kernel';
341
+ import { Repository } from '@ddd-ts-kit/kernel/declarations';
342
+
343
+ @Repository(InvoiceQueries, [Database, ExecutionScope])
344
+ export class PrismaInvoiceQueries implements InvoiceQueries {
345
+ constructor(
346
+ private readonly database: Database,
347
+ private readonly scope: ExecutionScope,
348
+ ) {}
349
+
350
+ async search(request: PageRequest<'amount'>): Promise<Page<InvoiceSummary>> {
351
+ return request.kind === 'offset' ? this.byOffset(request) : this.byCursor(request);
352
+ }
353
+
354
+ private async byOffset(request: OffsetPageRequest<'amount'>): Promise<Page<InvoiceSummary>> {
355
+ const { invoice } = this.database.client();
356
+ const where = tenantScopeOf(this.scope);
357
+ const rows = await invoice.findMany({
358
+ where,
359
+ orderBy: totalOrder(directionOf(request)),
360
+ take: request.limit,
361
+ skip: request.offset,
362
+ });
363
+
364
+ return pageOf(rows.map(toSummary), { total: await invoice.count({ where }) });
365
+ }
366
+
367
+ private async byCursor(request: CursorPageRequest<'amount'>): Promise<Page<InvoiceSummary>> {
368
+ const direction = directionOf(request);
369
+ const tenant = tenantScopeOf(this.scope);
370
+ const rows = await this.database.client().invoice.findMany({
371
+ where: request.cursor === undefined ? tenant : beyond(positionOf(request.cursor), direction, tenant),
372
+ orderBy: totalOrder(direction),
373
+ take: request.limit + 1,
374
+ });
375
+ const items = rows.slice(0, request.limit);
376
+ const last = items.at(-1);
377
+ const more = rows.length > request.limit && last !== undefined;
378
+
379
+ return pageOf(items.map(toSummary), more ? { nextCursor: encodeCursor({ amount: last.amount, id: last.id }) } : {});
380
+ }
381
+ }
382
+
383
+ const directionOf = (request: PageRequest<'amount'>) => request.sort[0]?.direction ?? 'asc';
384
+
385
+ // The primary key follows the sort field, in the same direction: the order is total.
386
+ const totalOrder = (direction: SortDirection): Prisma.InvoiceOrderByWithRelationInput[] => [
387
+ { amount: direction },
388
+ { id: direction },
389
+ ];
390
+
391
+ // The rows after a position, as a tuple comparison: the tie-breaker decides among equals.
392
+ function beyond(
393
+ { amount, id }: { amount: number; id: string },
394
+ direction: SortDirection,
395
+ tenant: TenantScoped,
396
+ ): Prisma.InvoiceWhereInput {
397
+ const past = direction === 'asc' ? 'gt' : 'lt';
398
+
399
+ return { ...tenant, OR: [{ amount: { [past]: amount } }, { amount, id: { [past]: id } }] };
400
+ }
401
+ ```
402
+
403
+ `positionOf(cursor)` decodes with `decodeCursor` and checks that `amount` is a number and `id`
404
+ a string, raising `InvalidPageRequestError` otherwise. Prisma's own `cursor` option is not used:
405
+ it names a row by a unique field, where a position compares values and needs no row to still
406
+ exist.
407
+
408
+ A read adapter over the database is declared with `@Repository`, as the ones the CLI
409
+ generates: its kind is `repository`. Declared with `@Adapter` it would be `external`, and a
410
+ generated platform puts a retry and a time limit around every `external` and `gateway`
411
+ adapter, which a statement of the unit of work must not get.
412
+
413
+ The rules these examples hold, and that the tests of this package check on a real database:
414
+
415
+ - **Audit columns** are filled by the adapter, never by the aggregate: `stampCreation(scope,
416
+ clock)` on insert, `stampUpdate(scope, clock)` on update, which leaves `created_at` and
417
+ `created_by` alone. The actor is the one of the execution scope: the user of the request, or
418
+ `system:<consumer>` for a consumed event.
419
+ - **The tenant is part of every statement**: written on insert, and in the condition of every
420
+ read and every update, the version guard included. A row of another tenant is not found, and
421
+ an update aimed at it writes nothing, which `optimisticUpdate` reports as a
422
+ `ConcurrencyError`. Work that names no tenant raises `NoCurrentTenantError` before any SQL.
423
+ - **A list has a total order.** The primary key follows the sort field, in the same direction,
424
+ as a tie-breaker: rows equal on the sort field otherwise have no defined order, and a page
425
+ could repeat or skip one of them. The index covers `(tenant_id, <sort field>, id)`.
426
+ - **A cursor is a position, not a page number**: the sort value and the id of the last row,
427
+ written by `encodeCursor`, compared as a tuple (`amount > :amount OR (amount = :amount AND id
428
+ > :id)`). One row more than `limit` is fetched to know whether a page follows, so no count is
429
+ needed, and a row inserted before the cursor does not shift the next page as it does with an
430
+ offset. A cursor comes back from a client: `decodeCursor` refuses what it did not write, and
431
+ the adapter checks the types of the position before they reach a statement.
432
+ - **An offset page answers `total`**, a cursor page `nextCursor`. Offer the offset form for
433
+ small, browsable lists and the cursor form for anything that grows.
434
+
435
+ ## Criteria
436
+
437
+ `criteriaToPrisma(plan, fields?)` turns a criteria plan of the kernel into the arguments of a
438
+ `findMany`: `where` (the filters, the scope of the actor and the keyset of a cursor page),
439
+ `counted` (the filters and the scope alone, for a `count`), `orderBy` (the total order ended
440
+ by the tie-breaker), `take` and `skip`. `predicateToPrisma(predicate, fields?)` gives one
441
+ `where`. They are data, never text: Prisma binds every value. The fields of the read model
442
+ are the fields of the model; `fields` renames those that differ. The text of `contains` and
443
+ `startsWith` has its `%`, `_` and `\` escaped, which Prisma does not do.
444
+
445
+ ```ts
446
+ // infrastructure/persistence/prisma-appointment-queries.reader.ts
447
+ async search(criteria: ScopedCriteria<AppointmentRow>): Promise<Page<AppointmentRow>> {
448
+ const plan = planCriteria(appointmentCriteria, criteria);
449
+
450
+ if (plan.empty) {
451
+ return pageOfPlan(plan, []);
452
+ }
453
+
454
+ const { where, orderBy, take, skip } = criteriaToPrisma(plan);
455
+
456
+ return pageOfPlan(plan, await this.database.client().appointmentRow.findMany({ where, orderBy, take, skip }));
457
+ }
458
+ ```
459
+
460
+ A cursor page is fetched one row longer than asked, which is how `pageOfPlan` knows whether
461
+ another follows; an offset page takes its total from `count({ where: counted })`. A field
462
+ declared `nullable` is ordered `{ sort, nulls }`: last ascending, first descending.
463
+
464
+ **Strings.** A text column is ordered and compared by range in the collation of the column;
465
+ criteria order strings by code point. Declare the text columns that are sorted or compared
466
+ with `gt`, `lt` or `between` with `COLLATE "C"`, in the migration since the Prisma schema cannot say it (`ALTER TABLE ... ALTER COLUMN room TYPE text COLLATE "C"`): the index then serves the sort, and the
467
+ order is the same as in the fake. **Decimals** are a `Decimal` field and a string in the row: the store reads them back as
468
+ plain digits, without trailing zeros or exponent. **Instants** are `timestamptz` and a `Date`.
469
+ A `BigInt` field reads back as a number.
470
+
471
+ ## Read models
472
+
473
+ The model of a read model is yours; `prisma/read-model.postgresql.prisma` of this package is
474
+ the template, with the four fields that order the events of a projection.
475
+
476
+ ```prisma
477
+ model AppointmentRow {
478
+ id String @id
479
+ status String
480
+ startsAt DateTime @map("starts_at") @db.Timestamptz(3)
481
+ lastEventId String @map("last_event_id")
482
+ lastEventAt DateTime @map("last_event_at") @db.Timestamptz(3)
483
+ lastEventVersion BigInt? @map("last_event_version")
484
+ deleted Boolean @default(false)
485
+
486
+ @@map("appointment_list")
487
+ @@schema("scheduling")
488
+ }
489
+ ```
490
+
491
+ ```ts
492
+ // infrastructure/persistence/prisma-appointment-list.repository.ts
493
+ @Repository(AppointmentList, [Database])
494
+ export class PrismaAppointmentList extends PrismaReadModelStore<AppointmentRow, Prisma.TransactionClient> implements AppointmentList {
495
+ constructor(database: Database) {
496
+ super(database, {
497
+ delegate: (client) => client.appointmentRow,
498
+ schema: 'scheduling',
499
+ table: 'appointment_list',
500
+ columns: { id: 'id', status: 'status', startsAt: 'starts_at' },
501
+ definition: appointmentCriteria,
502
+ });
503
+ }
504
+ }
505
+ ```
506
+
507
+ Every write goes through `TableReadModelStore` of the kernel: a row is written only if its
508
+ event is newer than the one it holds, on the executor of the unit of work, so the row and the
509
+ processed-event mark commit together. A removed row stays in the table with `deleted` set:
510
+ a query of your own on the table leaves those rows out, and so does a unique index
511
+ (`WHERE NOT deleted`). `store.table` is what `ProjectionRebuild` takes; the rebuild is
512
+ PostgreSQL only (a copy `<table>__rebuild` made `LIKE ... INCLUDING ALL`, the keys written
513
+ meanwhile in `<table>__touched`, the swap by rename in one transaction) and its guarantees
514
+ and limits are in the README of `@ddd-ts-kit/kernel` ("Rebuild"). The store passes
515
+ `criteriaContract` and `readModelStoreContract` of `@ddd-ts-kit/testing/contracts`, on PGlite
516
+ and on PostgreSQL 18.
517
+ Criteria and read models were never run on MySQL or SQLite.
518
+
519
+ The table is reached through its model, and the copy of a rebuild, which has no model,
520
+ through `$queryRawUnsafe` with quoted identifiers and `$n` parameters: that is why the table,
521
+ the schema and the column of each field are named. The arguments given to the model are built
522
+ from the fields of the row at run time, so `PrismaReadModelDelegate` types them as objects and
523
+ it is the generated client that validates them.
524
+
525
+ ## Outbox and idempotency
526
+
527
+ `claim` is required, because a Prisma client does not tell which database it talks to:
528
+
529
+ - `{ strategy: 'skip-locked', dialect, raw, schema?, table? }` selects the due rows
530
+ `FOR UPDATE SKIP LOCKED` and leases them in the same short transaction by pushing
531
+ `nextAttemptAt` forward (`leaseMs`, default 30 s), so several relays share the work without
532
+ waiting for each other, and the records of a relay that died come back when the lease
533
+ expires. Needs PostgreSQL or MySQL 8. Prisma Client has no row-locking API, so this one
534
+ select is raw SQL sent with `$queryRawUnsafe` (`raw: (client) => client` hands the store
535
+ that method); `schema` and `table` are the real names of the table and must be plain
536
+ identifiers. Everything else, the update included, is Prisma Client.
537
+ - `'compare-and-set'` reads the due rows and takes each one with an `updateMany` guarded by the
538
+ attempt count it read: same guarantee, no raw SQL, any database, one statement per record.
539
+
540
+ `PrismaProcessedEventStore.markProcessed` is `createMany({ skipDuplicates: true })`. Run the
541
+ consumer inside the unit of work: the mark then commits with the effects of the handler, and
542
+ disappears with them if it fails.
543
+
544
+ Delivery is at least once, unordered.
545
+
546
+ ## Dialect coverage
547
+
548
+ What follows is about this package. The CLI generates for PostgreSQL only: `init --orm prisma`
549
+ refuses `--database mysql` and `--database sqlite`.
550
+
551
+ | Database | Status |
552
+ |---|---|
553
+ | PostgreSQL | Covered by the automated suite, through `@prisma/adapter-pg`: contract suites of `@ddd-ts-kit/testing`, repository contract, rollback, nested unit of work, concurrent claims, both claim strategies. It runs on PGlite served over the PostgreSQL wire protocol by default, and on a real server when `DDD_TS_KIT_TEST_DATABASE_URL` is set (last run: PostgreSQL 18). |
554
+ | MySQL 8 | Not in the automated suite. The fragment is validated by `prisma validate`; the outbox and processed-event contract suites and the rollback of `append` were run once by hand against MySQL 8.4 through `@prisma/adapter-mariadb`, with `skip-locked` and `compare-and-set`. |
555
+ | SQLite | Not in the automated suite. The fragment is validated by `prisma validate`; the outbox contract suite was run once by hand through `@prisma/adapter-better-sqlite3` with `compare-and-set`, the only claim available there. The processed-event store needs the delegate shown under Limits. |
556
+ | CockroachDB, SQL Server, MongoDB | Never run. |
557
+
558
+ ## Limits
559
+
560
+ What Prisma does not allow, and what the package does about it:
561
+
562
+ - **No row locking in Prisma Client.** `FOR UPDATE SKIP LOCKED` exists only as raw SQL, hence
563
+ the `skip-locked` option that names the dialect and the table, and the `compare-and-set`
564
+ alternative that stays inside Prisma Client.
565
+ - **Nesting is not portable.** A Prisma 7 transaction client can open a nested `$transaction`
566
+ (a savepoint), a Prisma 6 one cannot. The package uses neither: a nested `run` joins the
567
+ outer transaction, and `claimBatch` called inside a unit of work claims in that unit of
568
+ work.
569
+ - **Interactive transactions are bounded in time**: 5 seconds by default, then Prisma rolls
570
+ back. Pass `timeout` to `createPrismaUnitOfWork` for long commands. They also hold a
571
+ connection for their whole duration.
572
+ - **A bare `null` is refused in a `Json` field**, and the sentinels that express it live in the
573
+ generated client. The stores leave a `null` payload out, so it is stored as SQL `NULL` and
574
+ read back as `null`; the field is therefore `Json?`.
575
+ - **No partial index in the schema language.** The fragment declares a plain index on
576
+ `(status, nextAttemptAt, sequence)`. On PostgreSQL you may replace it by hand in the
577
+ migration with `(next_attempt_at, sequence) WHERE status = 'pending'`.
578
+ - **No `createMany({ skipDuplicates })` on SQLite, SQL Server and MongoDB.** There the model
579
+ does not fit `PrismaProcessedEventDelegate`; give the store a delegate that maps the unique
580
+ violation, which SQLite tolerates inside a transaction:
581
+
582
+ ```ts
583
+ new PrismaProcessedEventStore(unitOfWork, {
584
+ delegate: (client) => ({
585
+ createMany: async ({ data }) => {
586
+ let count = 0;
587
+ for (const row of data) {
588
+ try {
589
+ await client.processedEvent.create({ data: row });
590
+ count += 1;
591
+ } catch (error) {
592
+ if (!(error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002')) {
593
+ throw error;
594
+ }
595
+ }
596
+ }
597
+ return { count };
598
+ },
599
+ }),
600
+ });
601
+ ```
602
+
603
+ - **The store cannot see the database provider**, so nothing is chosen for you: `claim` is
604
+ required.
605
+ - Tested with Prisma 7.10 and its driver adapters. The peer range also admits Prisma 6, on
606
+ which nothing was run.
607
+ - `stats()` is three `count` queries, not one statement: the numbers of a busy outbox may not
608
+ add up to the same instant.
609
+
610
+ ## Testing an adapter
611
+
612
+ The unit of work of this package passes `unitOfWorkContract`, and its stores
613
+ `outboxStoreContract` and `processedEventStoreContract`, of `@ddd-ts-kit/testing/contracts`. Hold your repositories to `repositoryContract` the same way,
614
+ next to their in-memory fake. The tests of this package need no container and no manual step:
615
+
616
+ ```
617
+ npx vitest run packages/prisma
618
+ DDD_TS_KIT_TEST_DATABASE_URL=postgresql://user:password@localhost:5432/postgres npx vitest run packages/prisma
619
+ ```
620
+
621
+ The tests need a generated client. Each spec file first awaits
622
+ `test/support/generate.ts`, which builds a schema from the shipped PostgreSQL fragment (the
623
+ models as they are, plus a renamed copy in a `messaging` schema and the example `Invoice`),
624
+ runs `prisma generate` and `prisma migrate diff` into `test/generated/` (git-ignored), and
625
+ skips all of it when that directory is up to date. The suites themselves live in
626
+ `test/suites/` and import the generated client like application code would.
627
+
628
+ With the variable set, each spec file creates a database of its own on that server and drops
629
+ it afterwards, and the tests that need two real connections (a relay passing over rows locked
630
+ by another one) run instead of being skipped.
@@ -0,0 +1,20 @@
1
+ export { CONCURRENCY_CONFLICT, ConcurrencyError, optimisticUpdate, schemaNameOf, type VersionStep, } from '@ddd-ts-kit/kernel';
2
+ export * from './unit-of-work/prisma-transaction-host.js';
3
+ export * from './unit-of-work/prisma-transaction-client.js';
4
+ export * from './unit-of-work/prisma-context.js';
5
+ export * from './unit-of-work/create-prisma-unit-of-work.js';
6
+ export * from './outbox/prisma-json-input.js';
7
+ export * from './outbox/prisma-outbox-delegate.js';
8
+ export * from './outbox/prisma-raw-queries.js';
9
+ export * from './outbox/prisma-outbox-claim.js';
10
+ export * from './outbox/prisma-outbox-store.js';
11
+ export * from './outbox/prisma-processed-event-delegate.js';
12
+ export * from './outbox/prisma-processed-event-store.js';
13
+ export * from './read-model/prisma-criteria.js';
14
+ export * from './read-model/predicate-to-prisma.js';
15
+ export * from './read-model/criteria-to-prisma.js';
16
+ export * from './read-model/prisma-read-model-delegate.js';
17
+ export * from './read-model/prisma-read-model-options.js';
18
+ export * from './read-model/prisma-read-model-table.js';
19
+ export * from './read-model/prisma-read-model-store.js';
20
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,oBAAoB,EACpB,gBAAgB,EAChB,gBAAgB,EAEhB,YAAY,EACZ,KAAK,WAAW,GACjB,MAAM,oBAAoB,CAAC;AAC5B,cAAc,2CAA2C,CAAC;AAC1D,cAAc,6CAA6C,CAAC;AAC5D,cAAc,kCAAkC,CAAC;AACjD,cAAc,8CAA8C,CAAC;AAC7D,cAAc,+BAA+B,CAAC;AAC9C,cAAc,oCAAoC,CAAC;AACnD,cAAc,gCAAgC,CAAC;AAC/C,cAAc,iCAAiC,CAAC;AAChD,cAAc,iCAAiC,CAAC;AAChD,cAAc,6CAA6C,CAAC;AAC5D,cAAc,0CAA0C,CAAC;AACzD,cAAc,iCAAiC,CAAC;AAChD,cAAc,qCAAqC,CAAC;AACpD,cAAc,oCAAoC,CAAC;AACnD,cAAc,4CAA4C,CAAC;AAC3D,cAAc,2CAA2C,CAAC;AAC1D,cAAc,yCAAyC,CAAC;AACxD,cAAc,yCAAyC,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,22 @@
1
+ export { CONCURRENCY_CONFLICT, ConcurrencyError, optimisticUpdate,
2
+ // The name to use in `@@schema("...")` and in the `schemas` list of the datasource.
3
+ schemaNameOf, } from '@ddd-ts-kit/kernel';
4
+ export * from './unit-of-work/prisma-transaction-host.js';
5
+ export * from './unit-of-work/prisma-transaction-client.js';
6
+ export * from './unit-of-work/prisma-context.js';
7
+ export * from './unit-of-work/create-prisma-unit-of-work.js';
8
+ export * from './outbox/prisma-json-input.js';
9
+ export * from './outbox/prisma-outbox-delegate.js';
10
+ export * from './outbox/prisma-raw-queries.js';
11
+ export * from './outbox/prisma-outbox-claim.js';
12
+ export * from './outbox/prisma-outbox-store.js';
13
+ export * from './outbox/prisma-processed-event-delegate.js';
14
+ export * from './outbox/prisma-processed-event-store.js';
15
+ export * from './read-model/prisma-criteria.js';
16
+ export * from './read-model/predicate-to-prisma.js';
17
+ export * from './read-model/criteria-to-prisma.js';
18
+ export * from './read-model/prisma-read-model-delegate.js';
19
+ export * from './read-model/prisma-read-model-options.js';
20
+ export * from './read-model/prisma-read-model-table.js';
21
+ export * from './read-model/prisma-read-model-store.js';
22
+ //# sourceMappingURL=index.js.map