@ddd-ts-kit/typeorm 0.16.0 → 0.17.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 (3) hide show
  1. package/README.fr.md +713 -0
  2. package/README.md +147 -8
  3. package/package.json +4 -4
package/README.fr.md ADDED
@@ -0,0 +1,713 @@
1
+ [English](README.md) · **Français**
2
+ <!-- translation-of: README.md sha256:102e8a726437a11d -->
3
+
4
+ # @ddd-ts-kit/typeorm
5
+
6
+ Les adaptateurs TypeORM des ports du noyau et des événements : une unité de travail que les
7
+ *repositories* ne voient jamais, le store de l'outbox transactionnelle, le store des événements
8
+ traités, leurs tables sous forme d'objets `EntitySchema`, et un schéma de base de données par
9
+ contexte délimité (bounded context). Les mêmes pièces que `@ddd-ts-kit/drizzle`, pour les
10
+ projets sur TypeORM.
11
+
12
+ ```
13
+ npm i @ddd-ts-kit/typeorm typeorm pg
14
+ ```
15
+
16
+ Tout ici utilise `EntitySchema`, pas des classes décorées : aucun import de `reflect-metadata`,
17
+ pas d'`experimentalDecorators`, et les lignes restent des objets simples qu'un *mapper*
18
+ transforme en agrégats. PostgreSQL d'abord ; voir
19
+ [Couverture des dialectes](#couverture-des-dialectes) pour MySQL et SQLite.
20
+
21
+ ## Sa place dans une application
22
+
23
+ Rien d'ici n'est importé depuis `domain/` ni `application/`. Un gestionnaire (handler) dépend du
24
+ port de *repository* de son domaine ; l'adaptateur de ce port, dans `infrastructure/`, exécute
25
+ ses instructions sur le `TypeOrmContext` ; la plateforme construit l'unité de travail une fois et
26
+ la lie.
27
+
28
+ ![Un contexte délimité contient un gestionnaire, le port de repository du domaine et le repository TypeORM qui l'implémente ; createTypeOrmUnitOfWork donne un seul objet qui est à la fois le UnitOfWork dans lequel le bus de commandes exécute chaque commande et le TypeOrmContext dont le manager() sert aux requêtes du repository et des stores ; il ouvre dataSource.transaction() sur la DataSource TypeORM, qui écrit dans PostgreSQL les tables du contexte, l'outbox et les événements traités](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/typeorm-place-in-an-application.fr.svg)
29
+
30
+ L'idée à retenir : **toute instruction passe par `manager()`**. Dans une unité de travail,
31
+ c'est l'`EntityManager` transactionnel de cette unité de travail : la ligne de l'agrégat,
32
+ l'enregistrement d'outbox de l'événement qu'il a publié et la marque d'événement traité d'un
33
+ consommateur sont validés ou annulés ensemble. Hors d'une unité de travail (une requête, un
34
+ script), c'est `dataSource.manager`.
35
+
36
+ ## Schéma
37
+
38
+ Les tables sont des objets `EntitySchema` : ajoutez-les aux `entities` de la source de données
39
+ et `typeorm migration:generate` écrit leur DDL dans une migration de votre projet.
40
+
41
+ ```ts
42
+ // platform/database/schema.ts
43
+ import { outboxEntity, processedEventEntity, schemaNameOf } from '@ddd-ts-kit/typeorm';
44
+
45
+ export const outbox = outboxEntity({ schema: 'messaging' }); // messaging.outbox
46
+ export const processedEvents = processedEventEntity({ schema: 'messaging' });
47
+
48
+ // contexts/billing-core/infrastructure/persistence/invoice-row.schema.ts
49
+ import { EntitySchema } from 'typeorm';
50
+
51
+ export interface InvoiceRow {
52
+ id: string;
53
+ amount: number;
54
+ status: 'draft' | 'issued';
55
+ version: number;
56
+ }
57
+
58
+ export const invoices = new EntitySchema<InvoiceRow>({
59
+ name: 'Invoice',
60
+ tableName: 'invoices',
61
+ schema: schemaNameOf('billing-core'), // billing_core
62
+ columns: {
63
+ id: { type: 'text', primary: true },
64
+ amount: { type: 'int' },
65
+ status: { type: 'text' },
66
+ version: { type: 'int' },
67
+ },
68
+ });
69
+ ```
70
+
71
+ Les options des deux fabriques : `schema` (aucune : le schéma par défaut de la connexion),
72
+ `dialect` (`postgres` par défaut, `mysql`, `sqlite` : il choisit les types des colonnes JSON et
73
+ horodatage), `tableName`, et `name`, le nom de l'entité. Le nom de l'entité vaut par défaut
74
+ `OutboxRecord` ou `ProcessedEvent`, suffixé du schéma (`OutboxRecord_messaging`), si bien que
75
+ chaque contexte peut enregistrer sa propre outbox dans la même source de données.
76
+
77
+ `outbox` est indexée pour le relais (les enregistrements en attente par prochaine tentative,
78
+ index partiel sur PostgreSQL et SQLite) ; `processed_events` a `(consumer, event_id)` pour clé
79
+ primaire. TypeORM ne crée pas les schémas : créez-les dans la migration
80
+ (`CREATE SCHEMA IF NOT EXISTS`).
81
+
82
+ ## Racine de composition
83
+
84
+ `createTypeOrmUnitOfWork(dataSource)` donne un seul objet qui est deux ports : le `UnitOfWork`
85
+ du noyau, et `TypeOrmContext`, la poignée sur laquelle les *repositories* et les stores
86
+ exécutent leurs requêtes. Liez-le aux deux :
87
+
88
+ ```ts
89
+ // platform/database/persistence.module.ts
90
+ import { DataSource } from 'typeorm';
91
+ import { defineContextModule } from '@ddd-ts-kit/di';
92
+ import { IntegrationEventPublisher, ProcessedEventStore, UnitOfWork } from '@ddd-ts-kit/kernel';
93
+ import { OutboxPublisher, OutboxStore } from '@ddd-ts-kit/events';
94
+ import {
95
+ createTypeOrmUnitOfWork,
96
+ TypeOrmContext,
97
+ TypeOrmOutboxStore,
98
+ TypeOrmProcessedEventStore,
99
+ } from '@ddd-ts-kit/typeorm';
100
+
101
+ export const dataSource = new DataSource({
102
+ type: 'postgres',
103
+ url: process.env.DATABASE_URL,
104
+ entities: [outbox, processedEvents, invoices],
105
+ });
106
+ const unitOfWork = createTypeOrmUnitOfWork(dataSource);
107
+
108
+ export const persistenceModule = defineContextModule({
109
+ name: 'persistence',
110
+ register(builder) {
111
+ builder.rebind(UnitOfWork).toValue(unitOfWork);
112
+ builder.bind(TypeOrmContext).toValue(unitOfWork);
113
+ builder
114
+ .bind(OutboxStore)
115
+ .toFactory((resolve) => new TypeOrmOutboxStore(resolve(TypeOrmContext), { entity: outbox }));
116
+ builder
117
+ .rebind(ProcessedEventStore)
118
+ .toFactory((resolve) => new TypeOrmProcessedEventStore(resolve(TypeOrmContext), { entity: processedEvents }));
119
+ builder.rebind(IntegrationEventPublisher).toClass(OutboxPublisher, [OutboxStore]);
120
+ },
121
+ onStart: async () => {
122
+ await dataSource.initialize();
123
+ },
124
+ onStop: () => dataSource.destroy(),
125
+ });
126
+ ```
127
+
128
+ ```ts
129
+ // main.ts
130
+ const app = createApplication({ modules: [kernelModule(), persistenceModule, contextModule('billing', billingDeclarations)] });
131
+
132
+ const relay = new OutboxRelay({ store: app.resolve(OutboxStore), publisher: broker, clock: app.resolve(Clock) });
133
+ relay.start({ intervalMs: 500 });
134
+ ```
135
+
136
+ Placez le module après `kernelModule()` : `rebind` remplace ses valeurs par défaut en mémoire.
137
+ Le bus de commandes du module du noyau exécute chaque commande dans le `UnitOfWork` lié ; les
138
+ gestionnaires n'en savent rien. `contextModule` de `@ddd-ts-kit/di` transforme les classes
139
+ déclarées d'un contexte en son module.
140
+
141
+ Un projet écrit par la CLI contient le même câblage en deux fichiers :
142
+ `platform/database/persistence-over.ts` construit l'unité de travail et les deux stores une
143
+ fois, et `platform/platform.module.ts` les lie, le *publisher* de l'outbox étant passé à
144
+ `kernelModule({ eventPublisher })`.
145
+
146
+ Passez à un store l'entité même que la source de données enregistre. Sans `entity`, un store
147
+ utilise `outboxEntity()` ou `processedEventEntity()`, que TypeORM fait correspondre par son nom à
148
+ l'entité par défaut dans le schéma par défaut ; la classe se lie alors directement :
149
+ `builder.bind(OutboxStore).toClass(TypeOrmOutboxStore, [TypeOrmContext])`,
150
+ `builder.rebind(ProcessedEventStore).toClass(TypeOrmProcessedEventStore, [TypeOrmContext])`.
151
+
152
+ `unitOfWork.run(work)` ouvre `dataSource.transaction` et garde l'`EntityManager` transactionnel
153
+ dans un `AsyncLocalStorage` ; un `run` imbriqué rejoint la transaction englobante (sans point de
154
+ sauvegarde). Comme `append` utilise le *manager* courant, un événement publié par un
155
+ gestionnaire qui échoue ensuite est annulé avec l'écriture métier : c'est toute la raison
156
+ d'être de l'outbox. `createTypeOrmUnitOfWork(dataSource, 'REPEATABLE READ')` fixe le niveau
157
+ d'isolation.
158
+
159
+ `run` repose sur `withinUnitOfWork` du noyau. Les événements du domaine des agrégats enregistrés
160
+ à l'intérieur attendent que `work` ait rendu la main, puis sont distribués dans la transaction,
161
+ avant la validation (commit) : leurs gestionnaires voient chaque enregistrement, et ce qu'ils
162
+ écrivent est validé ou annulé avec. Si `work`, un gestionnaire ou la validation échoue, il ne
163
+ reste rien : aucune ligne, aucun enregistrement d'outbox, aucun gestionnaire exécuté après la
164
+ validation. Les gestionnaires déclarés `after-commit` s'exécutent une fois la transaction
165
+ validée. Un `run` imbriqué rejoint l'unité de travail en cours : une seule distribution, à la fin
166
+ de la plus externe. Un `run` appelé directement (un consommateur, un script) suit le même
167
+ déroulé qu'une commande. `unitOfWorkContract` de `@ddd-ts-kit/testing/contracts` le prouve, avec
168
+ une contrainte différée pour la validation qui échoue ; sur un vrai PostgreSQL
169
+ (`DDD_TS_KIT_TEST_DATABASE_URL`), il prouve aussi que les lignes d'un gestionnaire
170
+ `before-commit` sont invisibles depuis une autre connexion et visibles des gestionnaires
171
+ `after-commit`. Le README de `@ddd-ts-kit/kernel` dessine ce déroulé
172
+ ([Événements du domaine et unité de travail](../kernel/README.fr.md#événements-du-domaine-et-unité-de-travail)).
173
+
174
+ `TypeOrmContext` est une classe abstraite : elle est donc son propre jeton d'injection. Un projet
175
+ à deux sources de données donne à chacune sa propre classe
176
+ (`export abstract class Ledger extends TypeOrmContext {}` dans `platform/database/`) et lie
177
+ chacune à son unité de travail.
178
+
179
+ ## Repository
180
+
181
+ Le port vit dans le domaine, l'adaptateur dans l'infrastructure. Un *repository* reçoit le
182
+ contexte, jamais une transaction : `manager()` est le *manager* transactionnel de l'unité de
183
+ travail s'il y en a une, et `dataSource.manager` sinon.
184
+
185
+ ```ts
186
+ // contexts/billing-core/domain/repositories/invoice.repository.ts
187
+ import { Repository } from '@ddd-ts-kit/kernel';
188
+
189
+ export abstract class InvoiceRepository extends Repository<Invoice> {}
190
+ ```
191
+
192
+ ```ts
193
+ // contexts/billing-core/infrastructure/persistence/type-orm-invoice.repository.ts
194
+ import { Repository } from '@ddd-ts-kit/kernel/declarations';
195
+ import { insertIgnoringConflict, optimisticUpdate, TypeOrmContext } from '@ddd-ts-kit/typeorm';
196
+
197
+ @Repository(InvoiceRepository)
198
+ export class TypeOrmInvoiceRepository implements InvoiceRepository {
199
+ constructor(private readonly context: TypeOrmContext) {}
200
+
201
+ async findById(id: string): Promise<Invoice | undefined> {
202
+ const row = await this.context.manager().findOneBy(invoices, { id });
203
+
204
+ if (row === null) {
205
+ return undefined;
206
+ }
207
+
208
+ const invoice = Invoice.rehydrate(row);
209
+ invoice.version = row.version;
210
+
211
+ return invoice;
212
+ }
213
+
214
+ async save(invoice: Invoice): Promise<void> {
215
+ const manager = this.context.manager();
216
+
217
+ await optimisticUpdate(invoice, async ({ expected, next }) => {
218
+ const row = { ...invoice.state, version: next };
219
+
220
+ if (expected === 0) {
221
+ return (await insertIgnoringConflict(manager, invoices, row)) ? 1 : 0;
222
+ }
223
+
224
+ const updated = await manager.update(invoices, { id: invoice.id, version: expected }, row);
225
+
226
+ return updated.affected ?? 0;
227
+ });
228
+ }
229
+ }
230
+ ```
231
+
232
+ Dans le style explicite, sans le décorateur, la même liaison tient en une ligne de
233
+ `<context>.module.ts` :
234
+ `builder.bind(InvoiceRepository).toClass(TypeOrmInvoiceRepository, [TypeOrmContext])`.
235
+
236
+ `optimisticUpdate` donne la version attendue et la version à écrire, lève une
237
+ `ConcurrencyError` (une `DomainError` de code `CONCURRENCY_CONFLICT`) quand l'instruction gardée
238
+ n'a écrit aucune ligne, et fait avancer `invoice.version` sinon. `insertIgnoringConflict` insère
239
+ sauf si la clé existe et dit s'il l'a fait ; contrairement à une violation d'unicité
240
+ interceptée, il laisse utilisable la transaction PostgreSQL qui l'entoure. N'utilisez pas
241
+ `manager.save` pour un agrégat : il lit avant d'écrire et choisit seul entre insertion et mise à
242
+ jour, ce qui défait la garde de version. Le `@VersionColumn` de TypeORM n'est pas utilisé pour la
243
+ même raison : la version appartient à l'agrégat.
244
+
245
+ ```ts
246
+ // Au lieu de : une lecture, puis une insertion ou une mise à jour choisie par TypeORM, sans garde de version
247
+ await manager.save(invoices, { ...invoice.state, version: invoice.version + 1 });
248
+
249
+ // Écrivez : l'instruction gardée, qui n'écrit aucune ligne quand une autre transaction a enregistré avant
250
+ await optimisticUpdate(invoice, async ({ expected, next }) => {
251
+ // un premier enregistrement (expected 0) insère plutôt avec insertIgnoringConflict, comme dans le repository ci-dessus
252
+ const updated = await manager.update(invoices, { id: invoice.id, version: expected }, { ...invoice.state, version: next });
253
+
254
+ return updated.affected ?? 0;
255
+ });
256
+ ```
257
+
258
+ ![optimisticUpdate lit la version à laquelle l'agrégat a été chargé et calcule la suivante ; un premier enregistrement insère avec insertIgnoringConflict, un enregistrement suivant exécute une mise à jour gardée par la version attendue ; une ligne écrite fait avancer la version de l'agrégat, aucune ligne écrite lève ConcurrencyError ; à côté, deux appels concurrents à manager.save réussissent tous les deux et le second efface le premier](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/typeorm-save-an-aggregate.fr.svg)
259
+
260
+ Ce que fait `optimisticUpdate`, seul, du nombre de lignes que renvoie l'écriture :
261
+
262
+ <!-- checked sample -->
263
+ ```ts
264
+ import { ConcurrencyError, optimisticUpdate } from '@ddd-ts-kit/typeorm';
265
+
266
+ // Ce qu'un repository passe à optimisticUpdate : l'agrégat, et une écriture qui répond combien de lignes elle a écrites.
267
+ const invoice = { id: 'inv-1', version: 3 }; // lu en version 3
268
+
269
+ await optimisticUpdate(invoice, async ({ expected, next }) => {
270
+ // expected = 3 : la garde de l'UPDATE (WHERE id = 'inv-1' AND version = 3)
271
+ // next = 4 : la version avec laquelle la ligne est écrite
272
+ return expected === 3 && next === 4 ? 1 : 0;
273
+ });
274
+ // invoice.version vaut maintenant 4 : le même agrégat peut être enregistré de nouveau
275
+
276
+ try {
277
+ // Une autre transaction a enregistré inv-1 entre-temps : l'UPDATE gardé ne trouve aucune ligne.
278
+ await optimisticUpdate(invoice, async () => 0);
279
+ } catch (error) {
280
+ if (error instanceof ConcurrencyError) {
281
+ console.log(error.code); // CONCURRENCY_CONFLICT ; invoice.version vaut toujours 4
282
+ }
283
+ }
284
+ ```
285
+
286
+ La commande qui enregistrait échoue avec cette erreur et son unité de travail est annulée : rien
287
+ de ce qu'elle a écrit ne reste, et l'appelant relit l'agrégat avant de réessayer.
288
+
289
+ ## Colonnes d'audit, restriction au tenant et pages
290
+
291
+ Les formes de persistance standard du noyau, sur TypeORM. `auditColumns(dialect?)` et
292
+ `tenantColumn()` sont des options de colonnes à étaler dans les `columns` d'un `EntitySchema` :
293
+ leurs propriétés sont celles des `AuditStamp` et `TenantScoped` du noyau, si bien qu'un tampon
294
+ s'étale tel quel dans une insertion, et les colonnes portent les mêmes noms dans tous les projets
295
+ (`created_at`, `created_by`, `updated_at`, `updated_by`, `tenant_id`) : c'est ce qui permet
296
+ d'écrire une seule fois un tableau de bord, un export ou le *package* d'audit.
297
+
298
+ ```ts
299
+ import type { AuditStamp, TenantScoped } from '@ddd-ts-kit/kernel';
300
+ import { auditColumns, schemaNameOf, tenantColumn } from '@ddd-ts-kit/typeorm';
301
+
302
+ interface InvoiceRow extends VersionedInvoice, TenantScoped, AuditStamp {}
303
+
304
+ export const invoices = new EntitySchema<InvoiceRow>({
305
+ name: 'Invoice',
306
+ tableName: 'invoices',
307
+ schema: schemaNameOf('billing-core'),
308
+ columns: {
309
+ id: { type: 'text', primary: true },
310
+ amount: { type: 'int' },
311
+ status: { type: 'text' },
312
+ version: { type: 'int' },
313
+ ...tenantColumn(),
314
+ ...auditColumns(),
315
+ },
316
+ indices: [{ name: 'invoices_tenant_amount_idx', columns: ['tenantId', 'amount', 'id'] }],
317
+ });
318
+ ```
319
+
320
+ ```ts
321
+ import { Clock, ExecutionScope, stampCreation, stampUpdate, tenantScopeOf } from '@ddd-ts-kit/kernel';
322
+ import { Repository } from '@ddd-ts-kit/kernel/declarations';
323
+
324
+ @Repository(InvoiceRepository)
325
+ export class TypeOrmInvoiceRepository implements InvoiceRepository {
326
+ constructor(
327
+ private readonly context: TypeOrmContext,
328
+ private readonly scope: ExecutionScope,
329
+ private readonly clock: Clock,
330
+ ) {}
331
+
332
+ async findById(id: string): Promise<Invoice | undefined> {
333
+ const row = await this.context.manager().findOneBy(invoices, { id, ...tenantScopeOf(this.scope) });
334
+
335
+ return row === null ? undefined : InvoiceMapper.toDomain(row);
336
+ }
337
+
338
+ async save(invoice: Invoice): Promise<void> {
339
+ await optimisticUpdate(invoice, async ({ expected, next }) => {
340
+ const state = { ...InvoiceMapper.toRow(invoice), version: next };
341
+
342
+ return expected === 0 ? this.insert(state) : this.update(state, expected);
343
+ });
344
+ }
345
+
346
+ private async insert(state: VersionedInvoice): Promise<number> {
347
+ const row = { ...state, ...tenantScopeOf(this.scope), ...stampCreation(this.scope, this.clock) };
348
+
349
+ return (await insertIgnoringConflict(this.context.manager(), invoices, row)) ? 1 : 0;
350
+ }
351
+
352
+ private async update(state: VersionedInvoice, expected: number): Promise<number> {
353
+ const guarded = { id: state.id, version: expected, ...tenantScopeOf(this.scope) };
354
+ const row = { ...state, ...stampUpdate(this.scope, this.clock) };
355
+
356
+ return (await this.context.manager().update(invoices, guarded, row)).affected ?? 0;
357
+ }
358
+ }
359
+ ```
360
+
361
+ Les `@CreateDateColumn` et `@UpdateDateColumn` de TypeORM ne sont pas utilisés : ils prennent
362
+ l'heure du serveur de base de données, pas celle de la `Clock`, et ne savent rien de qui agit.
363
+
364
+ Une requête de recherche prend la `PageRequest` du noyau et renvoie une `Page`, dans ses deux
365
+ variantes :
366
+
367
+ ```ts
368
+ // contexts/billing-core/application/ports/invoice-queries.port.ts
369
+ export abstract class InvoiceQueries {
370
+ abstract search(request: PageRequest<'amount'>): Promise<Page<InvoiceSummary>>;
371
+ }
372
+ ```
373
+
374
+ ```ts
375
+ // contexts/billing-core/infrastructure/persistence/type-orm-invoice-queries.reader.ts
376
+ import { LessThan, MoreThan, type FindOptionsWhere } from 'typeorm';
377
+ import { ExecutionScope, encodeCursor, pageOf, tenantScopeOf } from '@ddd-ts-kit/kernel';
378
+ import { Repository } from '@ddd-ts-kit/kernel/declarations';
379
+
380
+ @Repository(InvoiceQueries)
381
+ export class TypeOrmInvoiceQueries implements InvoiceQueries {
382
+ constructor(
383
+ private readonly context: TypeOrmContext,
384
+ private readonly scope: ExecutionScope,
385
+ ) {}
386
+
387
+ async search(request: PageRequest<'amount'>): Promise<Page<InvoiceSummary>> {
388
+ return request.kind === 'offset' ? this.byOffset(request) : this.byCursor(request);
389
+ }
390
+
391
+ private async byOffset(request: OffsetPageRequest<'amount'>): Promise<Page<InvoiceSummary>> {
392
+ const [rows, total] = await this.context.manager().findAndCount(invoices, {
393
+ where: tenantScopeOf(this.scope),
394
+ order: totalOrder(directionOf(request)),
395
+ take: request.limit,
396
+ skip: request.offset,
397
+ });
398
+
399
+ return pageOf(rows.map(toSummary), { total });
400
+ }
401
+
402
+ private async byCursor(request: CursorPageRequest<'amount'>): Promise<Page<InvoiceSummary>> {
403
+ const direction = directionOf(request);
404
+ const tenant = tenantScopeOf(this.scope);
405
+ const rows = await this.context.manager().find(invoices, {
406
+ where: request.cursor === undefined ? tenant : beyond(positionOf(request.cursor), direction, tenant),
407
+ order: totalOrder(direction),
408
+ take: request.limit + 1,
409
+ });
410
+ const items = rows.slice(0, request.limit);
411
+ const last = items.at(-1);
412
+ const more = rows.length > request.limit && last !== undefined;
413
+
414
+ return pageOf(items.map(toSummary), more ? { nextCursor: encodeCursor({ amount: last.amount, id: last.id }) } : {});
415
+ }
416
+ }
417
+
418
+ const directionOf = (request: PageRequest<'amount'>) => request.sort[0]?.direction ?? 'asc';
419
+
420
+ // La clé primaire suit le champ de tri, dans le même sens : l'ordre est total.
421
+ const totalOrder = (direction: SortDirection) => ({ amount: direction, id: direction });
422
+
423
+ // Les lignes après une position, en comparaison de tuples. Une liste de conditions est un OR en TypeORM,
424
+ // donc chaque alternative répète le tenant.
425
+ function beyond(
426
+ { amount, id }: { amount: number; id: string },
427
+ direction: SortDirection,
428
+ tenant: TenantScoped,
429
+ ): FindOptionsWhere<InvoiceRow>[] {
430
+ const past = direction === 'asc' ? MoreThan : LessThan;
431
+
432
+ return [
433
+ { ...tenant, amount: past(amount) },
434
+ { ...tenant, amount, id: past(id) },
435
+ ];
436
+ }
437
+ ```
438
+
439
+ `positionOf(cursor)` décode avec `decodeCursor` et vérifie que `amount` est un nombre et `id`
440
+ une chaîne, en levant `InvalidPageRequestError` sinon.
441
+
442
+ Un adaptateur de lecture sur la base de données se déclare avec `@Repository`, comme ceux que
443
+ génère la CLI : sa nature est `repository`. Déclaré avec `@Adapter`, il serait `external`, et
444
+ une plateforme générée entoure chaque adaptateur `external` et `gateway` d'une nouvelle
445
+ tentative (retry) et d'une limite de temps, ce qu'une instruction de l'unité de travail ne doit
446
+ pas recevoir.
447
+
448
+ Les règles que tiennent ces exemples, et que les tests de ce *package* vérifient sur une vraie
449
+ base de données :
450
+
451
+ - **Les colonnes d'audit** sont remplies par l'adaptateur, jamais par l'agrégat :
452
+ `stampCreation(scope, clock)` à l'insertion, `stampUpdate(scope, clock)` à la mise à jour, qui
453
+ laisse `created_at` et `created_by` intacts. L'acteur est celui de la portée d'exécution :
454
+ l'utilisateur de la requête HTTP, ou `system:<consumer>` pour un événement consommé.
455
+ - **Le tenant fait partie de chaque instruction** : écrit à l'insertion, et dans la condition de
456
+ chaque lecture et de chaque mise à jour, garde de version comprise. Une ligne d'un autre
457
+ *tenant* n'est pas trouvée, et une mise à jour qui la vise n'écrit rien, ce que
458
+ `optimisticUpdate` signale par une `ConcurrencyError`. Un travail qui ne nomme aucun *tenant*
459
+ lève `NoCurrentTenantError` avant tout SQL.
460
+ - **Une liste a un ordre total.** La clé primaire suit le champ de tri, dans le même sens, pour
461
+ départager : sans elle, des lignes égales sur le champ de tri n'ont pas d'ordre défini, et une
462
+ page pourrait en répéter ou en sauter une. L'index couvre `(tenant_id, <sort field>, id)`.
463
+ - **Un curseur est une position, pas un numéro de page** : la valeur de tri et l'id de la
464
+ dernière ligne, écrits par `encodeCursor`, comparés comme un tuple (`amount > :amount OR
465
+ (amount = :amount AND id > :id)`). Une ligne de plus que `limit` est lue pour savoir si une
466
+ page suit, donc aucun comptage n'est nécessaire, et une ligne insérée avant le curseur ne
467
+ décale pas la page suivante comme elle le fait avec un offset. Un curseur revient d'un client :
468
+ `decodeCursor` refuse ce qu'il n'a pas écrit, et l'adaptateur vérifie les types de la position
469
+ avant qu'ils n'atteignent une instruction.
470
+ - **Une page par offset répond `total`**, une page par curseur `nextCursor`. Proposez la forme
471
+ par offset pour de petites listes que l'on parcourt, et la forme par curseur pour tout ce qui
472
+ grandit.
473
+
474
+ ## Critères
475
+
476
+ `criteriaToTypeOrm(builder, plan, { alias })` ajoute un plan de critères du noyau à un
477
+ `SelectQueryBuilder` sur l'entité d'un modèle de lecture : les filtres et le périmètre de
478
+ l'acteur, le *keyset* d'une page par curseur, l'ordre total terminé par le champ qui départage,
479
+ la fenêtre. `predicateToTypeOrm(builder, predicate, { alias })` ajoute les filtres seuls, pour
480
+ un comptage. Les champs du modèle de lecture sont les propriétés de l'entité (`properties`
481
+ renomme celles qui diffèrent). Chaque valeur est un paramètre nommé ; le seul texte composé est
482
+ `alias.property` avec un opérateur d'une liste fermée, tous deux venant de l'adaptateur. Une
483
+ recherche textuelle est `LIKE :p ESCAPE :e`, avec `%`, `_` et `\` échappés.
484
+
485
+ ```ts
486
+ // infrastructure/persistence/type-orm-appointment-queries.reader.ts
487
+ async search(criteria: ScopedCriteria<AppointmentRow>): Promise<Page<AppointmentRow>> {
488
+ const plan = planCriteria(appointmentCriteria, criteria);
489
+
490
+ if (plan.empty) {
491
+ return pageOfPlan(plan, []);
492
+ }
493
+
494
+ const builder = this.context.manager().createQueryBuilder(appointmentList, 'row');
495
+
496
+ return pageOfPlan(plan, await criteriaToTypeOrm(builder, plan, { alias: 'row' }).getMany());
497
+ }
498
+ ```
499
+
500
+ Une page par curseur est lue avec une ligne de plus que demandé : c'est ainsi que `pageOfPlan`
501
+ sait si une autre suit ; une page par offset prend son total de
502
+ `predicateToTypeOrm(...).getCount()`. Un champ déclaré `nullable` est trié `NULLS LAST` en
503
+ ordre croissant et `NULLS FIRST` en ordre décroissant. Deux critères dans une même requête
504
+ prennent des noms de paramètres distincts avec l'option `parameters`.
505
+
506
+ **Chaînes.** Une colonne de texte est triée et comparée par intervalle selon la collation de la
507
+ colonne ; les critères trient les chaînes par point de code. Déclarez avec `collation: 'C'` les
508
+ colonnes de texte triées ou comparées avec `gt`, `lt` ou `between` : l'index sert alors le tri,
509
+ et l'ordre est le même que dans le *fake*. **Les décimaux** sont une colonne `numeric` et une
510
+ chaîne dans la ligne ; ils reviennent à l'échelle de la colonne. **Les instants** sont
511
+ `timestamptz` et une `Date`. Un entier est `integer` : un `bigint` revient du pilote sous forme
512
+ de chaîne.
513
+
514
+ ## Modèles de lecture
515
+
516
+ ```ts
517
+ // infrastructure/persistence/appointment-list.schema.ts
518
+ export const appointmentList = new EntitySchema<AppointmentRow & ReadModelStamps>({
519
+ name: 'AppointmentRow',
520
+ tableName: 'appointment_list',
521
+ schema: 'scheduling',
522
+ columns: {
523
+ id: { type: 'text', primary: true },
524
+ status: { type: 'text' },
525
+ startsAt: { name: 'starts_at', type: 'timestamp with time zone', precision: 3 },
526
+ ...readModelColumns(), // last_event_id, last_event_at, last_event_version, deleted
527
+ },
528
+ });
529
+
530
+ // infrastructure/persistence/type-orm-appointment-list.repository.ts
531
+ @Repository(AppointmentList)
532
+ export class TypeOrmAppointmentList extends TypeOrmReadModelStore<AppointmentRow> implements AppointmentList {
533
+ constructor(context: TypeOrmContext) {
534
+ super(context, { entity: appointmentList, definition: appointmentCriteria });
535
+ }
536
+ }
537
+ ```
538
+
539
+ Chaque écriture passe par `TableReadModelStore` du noyau : une ligne n'est écrite que si son
540
+ événement est plus récent que celui qu'elle porte, sur l'exécuteur de l'unité de travail, si
541
+ bien que la ligne et la marque d'événement traité sont validées ensemble. Une ligne supprimée
542
+ reste dans la table avec `deleted` posé : une requête à vous sur la table écarte ces lignes, et
543
+ un index unique aussi (`WHERE NOT deleted`). `store.table` est ce que prend
544
+ `ProjectionRebuild` ; la reconstruction est réservée à PostgreSQL (une copie `<table>__rebuild`
545
+ faite `LIKE ... INCLUDING ALL`, les clés écrites entre-temps dans `<table>__touched`, l'échange
546
+ par renommage en une transaction) et ses garanties et limites sont dans le README de
547
+ `@ddd-ts-kit/kernel` ([Reconstruction](../kernel/README.fr.md#reconstruction)). Le store passe
548
+ `criteriaContract` et `readModelStoreContract` de `@ddd-ts-kit/testing/contracts`, sur PGlite et
549
+ sur PostgreSQL 18.
550
+ Les critères et les modèles de lecture n'ont jamais été exécutés sur MySQL ni SQLite.
551
+
552
+ La table s'écrit par son entité ; la copie d'une reconstruction, qui n'en a pas, s'écrit colonne
553
+ par colonne avec le même *query builder*.
554
+
555
+ ## Outbox et idempotence
556
+
557
+ ![Deux relais se partagent l'outbox : avec skip-locked, chacun sélectionne les lignes dues FOR UPDATE SKIP LOCKED dans une courte transaction et le relais B saute les lignes que le relais A a verrouillées ; avec compare-and-set, les deux lisent la ligne 7 et tentent une mise à jour gardée par le nombre de tentatives lu, le premier la prend et le second n'écrit aucune ligne et relit ; dans les deux cas, une ligne prise reste cachée jusqu'à la fin de son bail](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/typeorm-outbox-claim.fr.svg)
558
+
559
+ - `TypeOrmOutboxStore.claimBatch` sélectionne les lignes dues `FOR UPDATE SKIP LOCKED`
560
+ (`setLock('pessimistic_write').setOnLocked('skip_locked')`) et les prend à bail dans la même
561
+ courte transaction en repoussant `next_attempt_at` (`leaseMs`, 30 s par défaut) : plusieurs
562
+ relais se partagent ainsi le travail sans s'attendre, et les enregistrements d'un relais mort
563
+ reviennent quand le bail expire. La livraison est au moins une fois et, par défaut, sans
564
+ ordre : `ordering: 'per-key'` de `OutboxRelay`, muni d'un `keyOf` (en général l'identifiant
565
+ de l'agrégat), garde dans l'ordre les enregistrements d'une même clé, avec un seul relais ([L'ordre par clé](../events/README.fr.md#lordre-par-clé)).
566
+ - Là où la base n'a pas de `SKIP LOCKED` (SQLite), ou quand vous passez
567
+ `claim: 'compare-and-set'`, la prise lit les lignes dues et prend chacune par une mise à jour
568
+ gardée par le nombre de tentatives lu : même garantie, aucune clause de verrou, une
569
+ instruction par enregistrement. La valeur par défaut est `skip-locked` pour les pilotes
570
+ `postgres`, `aurora-postgres`, `cockroachdb`, `mysql` et `aurora-mysql`, `compare-and-set`
571
+ pour tous les autres.
572
+ - `TypeOrmProcessedEventStore.markProcessed` est une insertion qui ignore le conflit sur
573
+ `(consumer, event_id)`. Exécutez le consommateur dans l'unité de travail : la marque est alors
574
+ validée avec les effets du gestionnaire, et disparaît avec eux s'il échoue.
575
+
576
+ Les options du store de l'outbox, et le relais qui le lit :
577
+
578
+ <!-- checked sample -->
579
+ ```ts
580
+ import { OutboxRelay } from '@ddd-ts-kit/events';
581
+ import type { Clock, IntegrationEventPublisher } from '@ddd-ts-kit/kernel';
582
+ import { outboxEntity, TypeOrmOutboxStore, type TypeOrmUnitOfWork } from '@ddd-ts-kit/typeorm';
583
+
584
+ // Depuis la racine de composition : createTypeOrmUnitOfWork(dataSource), l'adaptateur du broker, l'horloge.
585
+ declare const unitOfWork: TypeOrmUnitOfWork;
586
+ declare const broker: IntegrationEventPublisher;
587
+ declare const clock: Clock;
588
+
589
+ export const outbox = outboxEntity({ schema: 'messaging' }); // l'entité que la source de données enregistre
590
+
591
+ const store = new TypeOrmOutboxStore(unitOfWork, {
592
+ entity: outbox,
593
+ leaseMs: 60_000, // un enregistrement pris par un relais mort redevient dû au bout d'une minute
594
+ claim: 'compare-and-set', // au lieu de la valeur par défaut du pilote, par exemple sur MySQL 5.7
595
+ });
596
+
597
+ const relay = new OutboxRelay({ store, publisher: broker, clock });
598
+
599
+ relay.start({ intervalMs: 500 });
600
+ ```
601
+
602
+ Un consommateur sur le store des événements traités : `consume` de `@ddd-ts-kit/events` exécute
603
+ la marque et le gestionnaire dans une seule unité de travail
604
+ ([Consommer de façon idempotente](../events/README.fr.md#consommer-de-façon-idempotente)).
605
+
606
+ <!-- checked sample -->
607
+ ```ts
608
+ import { z } from 'zod';
609
+ import { consume, defineIntegrationEvent } from '@ddd-ts-kit/events';
610
+ import { zodSchema } from '@ddd-ts-kit/validation/zod';
611
+ import { processedEventEntity, TypeOrmProcessedEventStore, type TypeOrmUnitOfWork } from '@ddd-ts-kit/typeorm';
612
+
613
+ // Depuis la racine de composition et le contexte : l'unité de travail, et ce que fait le consommateur.
614
+ declare const unitOfWork: TypeOrmUnitOfWork;
615
+ declare function issueInvoice(orderId: string): Promise<void>;
616
+
617
+ // La déclaration du producteur, dans son langage publié.
618
+ const orderPlaced = defineIntegrationEvent({
619
+ type: 'ordering.order.placed',
620
+ version: 1,
621
+ description: 'An order was placed by a customer.',
622
+ payload: zodSchema(z.object({ orderId: z.string() })),
623
+ });
624
+
625
+ const processed = new TypeOrmProcessedEventStore(unitOfWork, {
626
+ entity: processedEventEntity({ schema: 'messaging' }),
627
+ });
628
+
629
+ // La marque dans messaging.processed_events et les écritures d'issueInvoice sont validées en une transaction :
630
+ // une nouvelle livraison est ignorée, et un événement dont le gestionnaire a levé une erreur est retraité quand il revient.
631
+ export const invoiceOnOrderPlaced = consume(
632
+ orderPlaced,
633
+ { consumer: 'billing.invoice-on-order-placed', processed, unitOfWork },
634
+ async (event) => {
635
+ await issueInvoice(event.payload.orderId);
636
+ },
637
+ );
638
+ ```
639
+
640
+ ## Event store
641
+
642
+ ```ts
643
+ const dataSource = new DataSource({ type: 'postgres', entities: [...eventStoreEntities({ schema: 'scheduling' })] });
644
+
645
+ const { events, snapshots, checkpoints, subjectKeys } = createTypeOrmEventSourcing(unitOfWork, { schema: 'scheduling' });
646
+ ```
647
+
648
+ `eventStoreEntities` déclare les tables sous forme d'`EntitySchema`, si bien que
649
+ `typeorm migration:generate` les crée avec les colonnes d'`eventStoreTablesSql` du noyau. Les
650
+ stores ne les chargent jamais comme entités. Les stores de `createTypeOrmEventSourcing` refusent
651
+ une source de données qui n'est pas PostgreSQL : leur première instruction lève une `TypeError`
652
+ qui nomme son type.
653
+
654
+ Chaque instruction s'exécute sur l'exécuteur du contexte : un ajout rejoint la transaction de
655
+ l'unité de travail, si bien que le flux et les enregistrements d'outbox que publient ses
656
+ événements sont validés ensemble (`eventStoreUnitOfWorkContract`). Les ajouts prennent la tête
657
+ du store, une ligne verrouillée jusqu'à la fin de leur transaction : les positions sont données
658
+ dans l'ordre des validations, sans trou, et deux ajouts ne s'exécutent jamais côte à côte, quels
659
+ que soient leurs flux. PostgreSQL seulement. Les stores sont ceux du noyau (`PostgresEventStore`
660
+ et ses compagnons), tenus ici à `eventStoreContract` ; comment un agrégat en *event sourcing*
661
+ les utilise : le README de `@ddd-ts-kit/kernel`, section
662
+ [Event sourcing](../kernel/README.fr.md#event-sourcing).
663
+
664
+ ## Couverture des dialectes
665
+
666
+ Ce qui suit concerne ce *package*. La CLI ne génère que pour PostgreSQL : `init --orm typeorm`
667
+ refuse `--database mysql` et `--database sqlite`.
668
+
669
+ | Base de données | État |
670
+ |---|---|
671
+ | PostgreSQL | Couvert par la suite automatique : suites de contrat de `@ddd-ts-kit/testing`, contrat de *repository*, annulation, unité de travail imbriquée, prises concurrentes. Elle tourne par défaut sur PGlite servi par le protocole réseau de PostgreSQL, et sur un vrai serveur quand `DDD_TS_KIT_TEST_DATABASE_URL` est définie (dernière exécution : PostgreSQL 18). |
672
+ | MySQL 8 | Hors de la suite automatique. Les suites de contrat de l'outbox et des événements traités et l'annulation d'`append` ont été exécutées une fois à la main contre MySQL 8.4 avec `mysql2`, entités construites avec `dialect: 'mysql'`, avec `skip-locked` et `compare-and-set`. |
673
+ | SQLite | Hors de la suite automatique. Les mêmes suites ont été exécutées une fois à la main avec le pilote `better-sqlite3`, entités construites avec `dialect: 'sqlite'`. Pas de `SKIP LOCKED` : la prise est `compare-and-set`. Pas de schémas : omettez `schema` et nommez les tables par contexte. |
674
+ | MariaDB, CockroachDB, SQL Server, Oracle, MongoDB | Jamais exécuté. |
675
+
676
+ ## Limites
677
+
678
+ - Testé avec TypeORM 1.1. La plage de la *peer dependency* est `^1.0.0` : TypeORM 0.3 n'est pas
679
+ accepté.
680
+ - Un `EntitySchema` est statique : les types de colonnes dépendent du `dialect` que vous passez,
681
+ pas de la source de données dans laquelle il est enregistré.
682
+ - Une charge utile JSON `null` est stockée en `NULL` SQL et relue comme `null`.
683
+ - Sur MySQL 5.7, il n'y a pas de `SKIP LOCKED` : passez `claim: 'compare-and-set'`.
684
+ - `synchronize: true` est réservé aux tests. En production, les tables viennent de vos
685
+ migrations.
686
+ - Par défaut, l'ordre n'est pas garanti, pas même par agrégat : un événement en échec est
687
+ retenté plus tard pendant que ses successeurs partent. `ordering: 'per-key'` de `OutboxRelay`,
688
+ muni d'un `keyOf`, ordonne les enregistrements d'une même clé, avec un seul relais en marche.
689
+ Sans `keyOf`, rien n'est ordonné.
690
+
691
+ ## Tester un adaptateur
692
+
693
+ L'unité de travail de ce *package* passe `unitOfWorkContract`, et ses stores
694
+ `outboxStoreContract` et `processedEventStoreContract`, de `@ddd-ts-kit/testing/contracts`.
695
+ Tenez vos *repositories* à `repositoryContract` de la même façon, à côté de leur *fake* en
696
+ mémoire. Les tests de ce *package* n'ont besoin d'aucun conteneur :
697
+
698
+ ```
699
+ npx vitest run packages/typeorm
700
+ DDD_TS_KIT_TEST_DATABASE_URL=postgresql://user:password@localhost:5432/postgres npx vitest run packages/typeorm
701
+ ```
702
+
703
+ Avec la variable définie, chaque fichier de *spec* crée sa propre base de données sur ce serveur
704
+ et la supprime ensuite, et les tests qui ont besoin de deux vraies connexions (un relais qui
705
+ saute les lignes verrouillées par un autre) s'exécutent au lieu d'être ignorés.
706
+
707
+ ## Voir aussi
708
+
709
+ - [Persistance](../../docs/tools/persistence.fr.md) : choisir un ORM, et ce que chaque *package* d'adaptateurs a vérifié
710
+ - [Agrégats](../../docs/concepts/aggregates.fr.md#concurrence-optimiste) : la concurrence optimiste, vue depuis le domaine
711
+ - [L'outbox transactionnelle](../../docs/concepts/transactional-outbox.fr.md) : pourquoi l'enregistrement d'outbox est validé avec l'écriture métier
712
+ - [Livraison et idempotence](../../docs/concepts/delivery-and-idempotency.fr.md) : au moins une fois, et ce qu'un consommateur en fait
713
+ - [`@ddd-ts-kit/drizzle`](../drizzle/README.fr.md) : les mêmes pièces sur Drizzle, la persistance par défaut du kit
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ **English** · [Français](README.fr.md)
2
+
1
3
  # @ddd-ts-kit/typeorm
2
4
 
3
5
  TypeORM adapters of the kernel and events ports: a unit of work that repositories never see,
@@ -13,6 +15,19 @@ Everything here uses `EntitySchema`, not decorated classes: no `reflect-metadata
13
15
  `experimentalDecorators`, and rows stay plain objects that a mapper turns into aggregates.
14
16
  PostgreSQL first; see [Dialect coverage](#dialect-coverage) for MySQL and SQLite.
15
17
 
18
+ ## Where it sits in an application
19
+
20
+ Nothing here is imported from `domain/` or `application/`. A handler depends on the repository
21
+ port of its domain; the adapter of that port, in `infrastructure/`, runs its statements on the
22
+ `TypeOrmContext`; the platform builds the unit of work once and binds it.
23
+
24
+ ![A bounded context holds a handler, the repository port of the domain and the TypeORM repository that implements it; createTypeOrmUnitOfWork gives one object that is both the UnitOfWork the command bus runs each command in and the TypeOrmContext whose manager() the repository and the stores query through; it opens dataSource.transaction() on the TypeORM DataSource, which writes the tables of the context, the outbox and the processed events in PostgreSQL](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/typeorm-place-in-an-application.svg)
25
+
26
+ The one idea to keep: **every statement goes through `manager()`**. Inside a unit of work it
27
+ is the transactional `EntityManager` of that unit of work, so the row of the aggregate, the
28
+ outbox record of the event it published and the processed-event mark of a consumer commit or
29
+ roll back together. Outside one (a query, a script) it is `dataSource.manager`.
30
+
16
31
  ## Schema
17
32
 
18
33
  The tables are `EntitySchema` objects: add them to the `entities` of the data source and
@@ -143,7 +158,8 @@ A `run` called directly (a consumer, a script) has the same timing as a command.
143
158
  `unitOfWorkContract` of `@ddd-ts-kit/testing/contracts` proves it, with a deferred constraint
144
159
  for the commit that fails; on a real PostgreSQL (`DDD_TS_KIT_TEST_DATABASE_URL`) it also proves
145
160
  that the rows of a `before-commit` handler are invisible from another connection and visible
146
- to the `after-commit` handlers.
161
+ to the `after-commit` handlers. The README of `@ddd-ts-kit/kernel` draws this timeline
162
+ ([Domain events and the unit of work](../kernel/README.md#domain-events-and-the-unit-of-work)).
147
163
 
148
164
  `TypeOrmContext` is an abstract class, so it is its own injection token. A project with two
149
165
  data sources gives each a class of its own (`export abstract class Ledger extends TypeOrmContext {}`
@@ -215,6 +231,50 @@ aggregate: it reads before writing and decides by itself between insert and upda
215
231
  defeats the version guard. TypeORM's own `@VersionColumn` is not used for the same reason: the
216
232
  version belongs to the aggregate.
217
233
 
234
+ ```ts
235
+ // Instead of: a read, then an insert or an update chosen by TypeORM, with no version guard
236
+ await manager.save(invoices, { ...invoice.state, version: invoice.version + 1 });
237
+
238
+ // Write: the guarded statement, which writes no row when another transaction saved first
239
+ await optimisticUpdate(invoice, async ({ expected, next }) => {
240
+ // a first save (expected 0) inserts with insertIgnoringConflict instead, as in the repository above
241
+ const updated = await manager.update(invoices, { id: invoice.id, version: expected }, { ...invoice.state, version: next });
242
+
243
+ return updated.affected ?? 0;
244
+ });
245
+ ```
246
+
247
+ ![optimisticUpdate reads the version the aggregate was loaded at and computes the next one; a first save inserts with insertIgnoringConflict, a later save runs an update guarded by the expected version; one row written moves the version of the aggregate on, no row written raises ConcurrencyError; beside it, two concurrent manager.save calls both succeed and the second erases the first](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/typeorm-save-an-aggregate.svg)
248
+
249
+ What `optimisticUpdate` does with the count the write answers, on its own:
250
+
251
+ <!-- checked sample -->
252
+ ```ts
253
+ import { ConcurrencyError, optimisticUpdate } from '@ddd-ts-kit/typeorm';
254
+
255
+ // What a repository hands to optimisticUpdate: the aggregate, and a write that answers how many rows it wrote.
256
+ const invoice = { id: 'inv-1', version: 3 }; // read at version 3
257
+
258
+ await optimisticUpdate(invoice, async ({ expected, next }) => {
259
+ // expected = 3: the guard of the UPDATE (WHERE id = 'inv-1' AND version = 3)
260
+ // next = 4: the version the row is written with
261
+ return expected === 3 && next === 4 ? 1 : 0;
262
+ });
263
+ // invoice.version is now 4: the same aggregate can be saved again
264
+
265
+ try {
266
+ // Another transaction saved inv-1 in between: the guarded UPDATE matches no row.
267
+ await optimisticUpdate(invoice, async () => 0);
268
+ } catch (error) {
269
+ if (error instanceof ConcurrencyError) {
270
+ console.log(error.code); // CONCURRENCY_CONFLICT; invoice.version is still 4
271
+ }
272
+ }
273
+ ```
274
+
275
+ The command that saved fails with that error and its unit of work rolls back: nothing it
276
+ wrote stays, and the caller reads the aggregate again before it retries.
277
+
218
278
  ## Audit columns, tenant scoping and pages
219
279
 
220
280
  The standard persistence shapes of the kernel, on TypeORM. `auditColumns(dialect?)` and
@@ -464,7 +524,7 @@ a query of your own on the table leaves those rows out, and so does a unique ind
464
524
  (`WHERE NOT deleted`). `store.table` is what `ProjectionRebuild` takes; the rebuild is
465
525
  PostgreSQL only (a copy `<table>__rebuild` made `LIKE ... INCLUDING ALL`, the keys written
466
526
  meanwhile in `<table>__touched`, the swap by rename in one transaction) and its guarantees
467
- and limits are in the README of `@ddd-ts-kit/kernel` ("Rebuild"). The store passes
527
+ and limits are in the README of `@ddd-ts-kit/kernel` ([Rebuild](../kernel/README.md#rebuild)). The store passes
468
528
  `criteriaContract` and `readModelStoreContract` of `@ddd-ts-kit/testing/contracts`, on PGlite
469
529
  and on PostgreSQL 18.
470
530
  Criteria and read models were never run on MySQL or SQLite.
@@ -474,11 +534,15 @@ column through the same query builder.
474
534
 
475
535
  ## Outbox and idempotency
476
536
 
537
+ ![Two relays share the outbox: with skip-locked, each selects the due rows FOR UPDATE SKIP LOCKED in a short transaction and relay B passes over the rows relay A has locked; with compare-and-set, both read row 7 and try an update guarded by the attempt count they read, the first takes it and the second writes no row and reads again; either way a claimed row stays hidden until its lease ends](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/typeorm-outbox-claim.svg)
538
+
477
539
  - `TypeOrmOutboxStore.claimBatch` selects the due rows `FOR UPDATE SKIP LOCKED`
478
540
  (`setLock('pessimistic_write').setOnLocked('skip_locked')`) and leases them in the same short
479
541
  transaction by pushing `next_attempt_at` forward (`leaseMs`, default 30 s), so several relays
480
542
  share the work without waiting for each other, and the records of a relay that died come
481
- back when the lease expires. Delivery is at least once, unordered.
543
+ back when the lease expires. Delivery is at least once and, by default, unordered:
544
+ `ordering: 'per-key'` of `OutboxRelay`, given a `keyOf` (typically the aggregate id), keeps
545
+ the records of one key in order, with a single relay ([Ordering per key](../events/README.md#ordering-per-key)).
482
546
  - Where the database has no `SKIP LOCKED` (SQLite), or when you pass
483
547
  `claim: 'compare-and-set'`, the claim reads the due rows and takes each one with an update
484
548
  guarded by the attempt count it read: same guarantee, no lock clause, one statement per
@@ -488,6 +552,69 @@ column through the same query builder.
488
552
  `(consumer, event_id)`. Run the consumer inside the unit of work: the mark then commits with
489
553
  the effects of the handler, and disappears with them if it fails.
490
554
 
555
+ The options of the outbox store, and the relay that reads it:
556
+
557
+ <!-- checked sample -->
558
+ ```ts
559
+ import { OutboxRelay } from '@ddd-ts-kit/events';
560
+ import type { Clock, IntegrationEventPublisher } from '@ddd-ts-kit/kernel';
561
+ import { outboxEntity, TypeOrmOutboxStore, type TypeOrmUnitOfWork } from '@ddd-ts-kit/typeorm';
562
+
563
+ // From the composition root: createTypeOrmUnitOfWork(dataSource), the broker adapter, the clock.
564
+ declare const unitOfWork: TypeOrmUnitOfWork;
565
+ declare const broker: IntegrationEventPublisher;
566
+ declare const clock: Clock;
567
+
568
+ export const outbox = outboxEntity({ schema: 'messaging' }); // the entity the data source registers
569
+
570
+ const store = new TypeOrmOutboxStore(unitOfWork, {
571
+ entity: outbox,
572
+ leaseMs: 60_000, // a record claimed by a relay that died is due again after one minute
573
+ claim: 'compare-and-set', // instead of the default of the driver, e.g. on MySQL 5.7
574
+ });
575
+
576
+ const relay = new OutboxRelay({ store, publisher: broker, clock });
577
+
578
+ relay.start({ intervalMs: 500 });
579
+ ```
580
+
581
+ A consumer on the processed-event store: `consume` of `@ddd-ts-kit/events` runs the mark and
582
+ the handler in one unit of work ([Consume idempotently](../events/README.md#consume-idempotently)).
583
+
584
+ <!-- checked sample -->
585
+ ```ts
586
+ import { z } from 'zod';
587
+ import { consume, defineIntegrationEvent } from '@ddd-ts-kit/events';
588
+ import { zodSchema } from '@ddd-ts-kit/validation/zod';
589
+ import { processedEventEntity, TypeOrmProcessedEventStore, type TypeOrmUnitOfWork } from '@ddd-ts-kit/typeorm';
590
+
591
+ // From the composition root and the context: the unit of work, and what the consumer does.
592
+ declare const unitOfWork: TypeOrmUnitOfWork;
593
+ declare function issueInvoice(orderId: string): Promise<void>;
594
+
595
+ // The declaration of the producer, in its published language.
596
+ const orderPlaced = defineIntegrationEvent({
597
+ type: 'ordering.order.placed',
598
+ version: 1,
599
+ description: 'An order was placed by a customer.',
600
+ payload: zodSchema(z.object({ orderId: z.string() })),
601
+ });
602
+
603
+ const processed = new TypeOrmProcessedEventStore(unitOfWork, {
604
+ entity: processedEventEntity({ schema: 'messaging' }),
605
+ });
606
+
607
+ // The mark in messaging.processed_events and the writes of issueInvoice commit in one transaction:
608
+ // a redelivery is skipped, and an event whose handler threw is handled again when it comes back.
609
+ export const invoiceOnOrderPlaced = consume(
610
+ orderPlaced,
611
+ { consumer: 'billing.invoice-on-order-placed', processed, unitOfWork },
612
+ async (event) => {
613
+ await issueInvoice(event.payload.orderId);
614
+ },
615
+ );
616
+ ```
617
+
491
618
  ## Event store
492
619
 
493
620
  ```ts
@@ -498,7 +625,8 @@ const { events, snapshots, checkpoints, subjectKeys } = createTypeOrmEventSourci
498
625
 
499
626
  `eventStoreEntities` declares the tables as `EntitySchema`s, so `typeorm migration:generate`
500
627
  creates them with the columns of `eventStoreTablesSql` of the kernel. The stores never load them
501
- as entities. `createTypeOrmEventSourcing` refuses a data source that is not PostgreSQL.
628
+ as entities. The stores of `createTypeOrmEventSourcing` refuse a data source that is not
629
+ PostgreSQL: their first statement throws a `TypeError` that names its type.
502
630
 
503
631
  Each statement runs on the executor of the context: an append joins the transaction of the
504
632
  unit of work, so the stream and the outbox records its events publish commit together
@@ -506,7 +634,8 @@ unit of work, so the stream and the outbox records its events publish commit tog
506
634
  transaction ends: positions are given in commit order, without gaps, and two appends never run
507
635
  side by side, whatever their streams. PostgreSQL only. The stores are the ones of the kernel
508
636
  (`PostgresEventStore` and its companions), held to `eventStoreContract` here; how an
509
- event-sourced aggregate uses them: the README of `@ddd-ts-kit/kernel`, section "Event sourcing".
637
+ event-sourced aggregate uses them: the README of `@ddd-ts-kit/kernel`, section
638
+ [Event sourcing](../kernel/README.md#event-sourcing).
510
639
 
511
640
  ## Dialect coverage
512
641
 
@@ -522,14 +651,16 @@ refuses `--database mysql` and `--database sqlite`.
522
651
 
523
652
  ## Limits
524
653
 
525
- - Tested with TypeORM 1.1. The peer range also admits 0.3, on which nothing was run.
654
+ - Tested with TypeORM 1.1. The peer range is `^1.0.0`: TypeORM 0.3 is not accepted.
526
655
  - An `EntitySchema` is static: the column types depend on the `dialect` you pass, not on the
527
656
  data source it is registered in.
528
657
  - A JSON `null` payload is stored as SQL `NULL` and read back as `null`.
529
658
  - On MySQL 5.7 there is no `SKIP LOCKED`: pass `claim: 'compare-and-set'`.
530
659
  - `synchronize: true` is for tests. In production the tables come from your migrations.
531
- - Ordering is not guaranteed, not even per aggregate: a failed event is retried later while its
532
- successors go out.
660
+ - By default ordering is not guaranteed, not even per aggregate: a failed event is retried
661
+ later while its successors go out. `ordering: 'per-key'` of `OutboxRelay`, given a `keyOf`,
662
+ orders the records of one key, with a single relay running. Without `keyOf` nothing is
663
+ ordered.
533
664
 
534
665
  ## Testing an adapter
535
666
 
@@ -545,3 +676,11 @@ DDD_TS_KIT_TEST_DATABASE_URL=postgresql://user:password@localhost:5432/postgres
545
676
  With the variable set, each spec file creates a database of its own on that server and drops
546
677
  it afterwards, and the tests that need two real connections (a relay passing over rows locked
547
678
  by another one) run instead of being skipped.
679
+
680
+ ## See also
681
+
682
+ - [Persistence](../../docs/tools/persistence.md): choosing an ORM, and what each adapter package verified
683
+ - [Aggregates](../../docs/concepts/aggregates.md#optimistic-concurrency): optimistic concurrency, seen from the domain
684
+ - [The transactional outbox](../../docs/concepts/transactional-outbox.md): why the outbox record commits with the business write
685
+ - [Delivery and idempotency](../../docs/concepts/delivery-and-idempotency.md): at least once, and what a consumer does about it
686
+ - [`@ddd-ts-kit/drizzle`](../drizzle/README.md): the same pieces on Drizzle, the default persistence of the kit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ddd-ts-kit/typeorm",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "TypeORM adapters for the kernel ports: unit of work, transactional outbox store, processed-event store, PostgreSQL event store.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,15 +26,15 @@
26
26
  "dist"
27
27
  ],
28
28
  "dependencies": {
29
- "@ddd-ts-kit/kernel": "0.16.0",
30
- "@ddd-ts-kit/events": "0.16.0"
29
+ "@ddd-ts-kit/kernel": "0.17.0",
30
+ "@ddd-ts-kit/events": "0.17.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "typeorm": "^1.0.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "typeorm": "^1.1.2",
37
- "@ddd-ts-kit/testing": "0.16.0",
37
+ "@ddd-ts-kit/testing": "0.17.0",
38
38
  "pg": "^8.23.1",
39
39
  "@types/pg": "^8.23.1",
40
40
  "@electric-sql/pglite": "0.5.9",