@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.
- package/README.fr.md +713 -0
- package/README.md +147 -8
- 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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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` (
|
|
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
|
+

|
|
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`
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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.
|
|
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.
|
|
30
|
-
"@ddd-ts-kit/events": "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.
|
|
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",
|