@ddd-ts-kit/drizzle 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +476 -2
  2. package/dist/postgres/columns/audit-columns.d.ts +13 -0
  3. package/dist/postgres/columns/audit-columns.d.ts.map +1 -0
  4. package/dist/postgres/columns/audit-columns.js +17 -0
  5. package/dist/postgres/columns/audit-columns.js.map +1 -0
  6. package/dist/postgres/columns/instant.d.ts +3 -0
  7. package/dist/postgres/columns/instant.d.ts.map +1 -0
  8. package/dist/postgres/columns/instant.js +6 -0
  9. package/dist/postgres/columns/instant.js.map +1 -0
  10. package/dist/postgres/columns/tenant-column.d.ts +5 -0
  11. package/dist/postgres/columns/tenant-column.d.ts.map +1 -0
  12. package/dist/postgres/columns/tenant-column.js +6 -0
  13. package/dist/postgres/columns/tenant-column.js.map +1 -0
  14. package/dist/postgres/outbox/drizzle-outbox-store.d.ts +39 -0
  15. package/dist/postgres/outbox/drizzle-outbox-store.d.ts.map +1 -0
  16. package/dist/postgres/outbox/drizzle-outbox-store.js +139 -0
  17. package/dist/postgres/outbox/drizzle-outbox-store.js.map +1 -0
  18. package/dist/postgres/outbox/drizzle-processed-event-store.d.ts +21 -0
  19. package/dist/postgres/outbox/drizzle-processed-event-store.d.ts.map +1 -0
  20. package/dist/postgres/outbox/drizzle-processed-event-store.js +24 -0
  21. package/dist/postgres/outbox/drizzle-processed-event-store.js.map +1 -0
  22. package/dist/postgres/outbox/outbox-table.d.ts +254 -0
  23. package/dist/postgres/outbox/outbox-table.d.ts.map +1 -0
  24. package/dist/postgres/outbox/outbox-table.js +33 -0
  25. package/dist/postgres/outbox/outbox-table.js.map +1 -0
  26. package/dist/postgres/outbox/processed-events-table.d.ts +61 -0
  27. package/dist/postgres/outbox/processed-events-table.d.ts.map +1 -0
  28. package/dist/postgres/outbox/processed-events-table.js +14 -0
  29. package/dist/postgres/outbox/processed-events-table.js.map +1 -0
  30. package/dist/postgres/read-model/column-of.d.ts +4 -0
  31. package/dist/postgres/read-model/column-of.d.ts.map +1 -0
  32. package/dist/postgres/read-model/column-of.js +9 -0
  33. package/dist/postgres/read-model/column-of.js.map +1 -0
  34. package/dist/postgres/read-model/criteria-to-drizzle.d.ts +21 -0
  35. package/dist/postgres/read-model/criteria-to-drizzle.d.ts.map +1 -0
  36. package/dist/postgres/read-model/criteria-to-drizzle.js +26 -0
  37. package/dist/postgres/read-model/criteria-to-drizzle.js.map +1 -0
  38. package/dist/postgres/read-model/drizzle-read-model-options.d.ts +10 -0
  39. package/dist/postgres/read-model/drizzle-read-model-options.d.ts.map +1 -0
  40. package/dist/postgres/read-model/drizzle-read-model-options.js +2 -0
  41. package/dist/postgres/read-model/drizzle-read-model-options.js.map +1 -0
  42. package/dist/postgres/read-model/drizzle-read-model-store.d.ts +18 -0
  43. package/dist/postgres/read-model/drizzle-read-model-store.d.ts.map +1 -0
  44. package/dist/postgres/read-model/drizzle-read-model-store.js +13 -0
  45. package/dist/postgres/read-model/drizzle-read-model-store.js.map +1 -0
  46. package/dist/postgres/read-model/drizzle-read-model-table.d.ts +35 -0
  47. package/dist/postgres/read-model/drizzle-read-model-table.d.ts.map +1 -0
  48. package/dist/postgres/read-model/drizzle-read-model-table.js +92 -0
  49. package/dist/postgres/read-model/drizzle-read-model-table.js.map +1 -0
  50. package/dist/postgres/read-model/drizzle-stamped-rows.d.ts +29 -0
  51. package/dist/postgres/read-model/drizzle-stamped-rows.d.ts.map +1 -0
  52. package/dist/postgres/read-model/drizzle-stamped-rows.js +79 -0
  53. package/dist/postgres/read-model/drizzle-stamped-rows.js.map +1 -0
  54. package/dist/postgres/read-model/like-pattern.d.ts +4 -0
  55. package/dist/postgres/read-model/like-pattern.d.ts.map +1 -0
  56. package/dist/postgres/read-model/like-pattern.js +6 -0
  57. package/dist/postgres/read-model/like-pattern.js.map +1 -0
  58. package/dist/postgres/read-model/predicate-to-drizzle.d.ts +12 -0
  59. package/dist/postgres/read-model/predicate-to-drizzle.d.ts.map +1 -0
  60. package/dist/postgres/read-model/predicate-to-drizzle.js +43 -0
  61. package/dist/postgres/read-model/predicate-to-drizzle.js.map +1 -0
  62. package/dist/postgres/read-model/read-model-columns.d.ts +13 -0
  63. package/dist/postgres/read-model/read-model-columns.d.ts.map +1 -0
  64. package/dist/postgres/read-model/read-model-columns.js +17 -0
  65. package/dist/postgres/read-model/read-model-columns.js.map +1 -0
  66. package/dist/postgres/read-model/row.mapper.d.ts +15 -0
  67. package/dist/postgres/read-model/row.mapper.d.ts.map +1 -0
  68. package/dist/postgres/read-model/row.mapper.js +26 -0
  69. package/dist/postgres/read-model/row.mapper.js.map +1 -0
  70. package/dist/postgres/read-model/rows-of.d.ts +3 -0
  71. package/dist/postgres/read-model/rows-of.d.ts.map +1 -0
  72. package/dist/postgres/read-model/rows-of.js +10 -0
  73. package/dist/postgres/read-model/rows-of.js.map +1 -0
  74. package/dist/postgres/read-model/stamp-columns.d.ts +10 -0
  75. package/dist/postgres/read-model/stamp-columns.d.ts.map +1 -0
  76. package/dist/postgres/read-model/stamp-columns.js +14 -0
  77. package/dist/postgres/read-model/stamp-columns.js.map +1 -0
  78. package/dist/postgres/schema/context-schema.d.ts +4 -0
  79. package/dist/postgres/schema/context-schema.d.ts.map +1 -0
  80. package/dist/postgres/schema/context-schema.js +7 -0
  81. package/dist/postgres/schema/context-schema.js.map +1 -0
  82. package/dist/postgres/schema/table-in.d.ts +3 -0
  83. package/dist/postgres/schema/table-in.d.ts.map +1 -0
  84. package/dist/postgres/schema/table-in.js +5 -0
  85. package/dist/postgres/schema/table-in.js.map +1 -0
  86. package/dist/postgres/unit-of-work/create-drizzle-unit-of-work.d.ts +16 -0
  87. package/dist/postgres/unit-of-work/create-drizzle-unit-of-work.d.ts.map +1 -0
  88. package/dist/postgres/unit-of-work/create-drizzle-unit-of-work.js +25 -0
  89. package/dist/postgres/unit-of-work/create-drizzle-unit-of-work.js.map +1 -0
  90. package/dist/postgres/unit-of-work/drizzle-context.d.ts +19 -0
  91. package/dist/postgres/unit-of-work/drizzle-context.d.ts.map +1 -0
  92. package/dist/postgres/unit-of-work/drizzle-context.js +15 -0
  93. package/dist/postgres/unit-of-work/drizzle-context.js.map +1 -0
  94. package/dist/postgres.d.ts +17 -0
  95. package/dist/postgres.d.ts.map +1 -0
  96. package/dist/postgres.js +17 -0
  97. package/dist/postgres.js.map +1 -0
  98. package/package.json +40 -4
package/README.md CHANGED
@@ -1,3 +1,477 @@
1
- # Temporary Holding Version
1
+ # @ddd-ts-kit/drizzle
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Drizzle adapters of the kernel and events ports on PostgreSQL: a unit of work that
4
+ repositories never see, the transactional outbox store, the processed-event store, and one
5
+ database schema per bounded context. Drizzle is the default persistence of the kit, not a
6
+ requirement: everything here implements ports, and another ORM is another adapter passing the
7
+ same contract suites.
8
+
9
+ ```
10
+ npm i @ddd-ts-kit/drizzle drizzle-orm
11
+ ```
12
+
13
+ Works with every drizzle PostgreSQL driver (node-postgres, postgres-js, PGlite, Neon): the
14
+ functions are generic over the database type, so `executor()` is typed exactly like your `db`.
15
+
16
+ ## Schema
17
+
18
+ The tables are plain drizzle definitions: export them from the schema files drizzle-kit reads
19
+ and they are created by your own migrations.
20
+
21
+ ```ts
22
+ // platform/database/schema.ts
23
+ import { contextSchema, outboxTable, processedEventsTable } from '@ddd-ts-kit/drizzle/postgres';
24
+
25
+ export const outbox = outboxTable('messaging'); // messaging.outbox; no argument: public.outbox
26
+ export const processedEvents = processedEventsTable('messaging');
27
+
28
+ // contexts/order-management/infrastructure/persistence/order-management.schema.ts
29
+ export const orderManagement = contextSchema('order-management'); // schema order_management
30
+ export const orders = orderManagement.table('orders', {
31
+ id: text('id').primaryKey(),
32
+ status: text('status').notNull(),
33
+ version: integer('version').notNull(),
34
+ });
35
+ ```
36
+
37
+ `outbox` is indexed for the relay (pending records by next attempt, partial index);
38
+ `processed_events` has `(consumer, event_id)` as its primary key.
39
+
40
+ ## Composition root
41
+
42
+ `DrizzleContext` is the handle repositories and stores run their queries on. It is generic
43
+ over the drizzle database, so the project names its own database with a class, which is then
44
+ the injection token:
45
+
46
+ ```ts
47
+ // platform/database/database.ts
48
+ import type { PgQueryResultHKT } from 'drizzle-orm/pg-core';
49
+ import { DrizzleContext } from '@ddd-ts-kit/drizzle/postgres';
50
+
51
+ export abstract class Database extends DrizzleContext<PgQueryResultHKT> {}
52
+ ```
53
+
54
+ This is the class the CLI writes: typed on the driver-neutral base, so the same repositories
55
+ run on node-postgres in production and on PGlite in the tests, the connection being typed
56
+ `PgDatabase<PgQueryResultHKT>`. A project bound to one driver may name its kind instead
57
+ (`DrizzleContext<NodePgQueryResultHKT>`), which types the raw result of `execute` exactly.
58
+
59
+ `createDrizzleUnitOfWork(db)` gives one object that is both the kernel `UnitOfWork` and that
60
+ `Database`. Bind it to both:
61
+
62
+ ```ts
63
+ // platform/database/persistence.module.ts
64
+ import { drizzle } from 'drizzle-orm/node-postgres';
65
+ import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
66
+ import { defineContextModule } from '@ddd-ts-kit/di';
67
+ import { IntegrationEventPublisher, ProcessedEventStore, UnitOfWork } from '@ddd-ts-kit/kernel';
68
+ import { OutboxPublisher, OutboxStore } from '@ddd-ts-kit/events';
69
+ import {
70
+ createDrizzleUnitOfWork,
71
+ DrizzleOutboxStore,
72
+ DrizzleProcessedEventStore,
73
+ } from '@ddd-ts-kit/drizzle/postgres';
74
+ import { Database } from '@/platform/database/database.js';
75
+ import { outbox, processedEvents } from '@/platform/database/schema.js';
76
+
77
+ const db: PgDatabase<PgQueryResultHKT> = drizzle(process.env.DATABASE_URL);
78
+ const unitOfWork = createDrizzleUnitOfWork(db);
79
+
80
+ export const persistenceModule = defineContextModule({
81
+ name: 'persistence',
82
+ register(builder) {
83
+ builder.rebind(UnitOfWork).toValue(unitOfWork);
84
+ builder.bind(Database).toValue(unitOfWork);
85
+ builder
86
+ .bind(OutboxStore)
87
+ .toFactory((resolve) => new DrizzleOutboxStore(resolve(Database), { table: outbox }));
88
+ builder
89
+ .rebind(ProcessedEventStore)
90
+ .toFactory((resolve) => new DrizzleProcessedEventStore(resolve(Database), { table: processedEvents }));
91
+ builder.rebind(IntegrationEventPublisher).toClass(OutboxPublisher, [OutboxStore]);
92
+ },
93
+ });
94
+ ```
95
+
96
+ ```ts
97
+ // main.ts
98
+ const app = createApplication({ modules: [kernelModule(), persistenceModule, contextModule('order-management', orderManagementDeclarations)] });
99
+ ```
100
+
101
+ List the module after `kernelModule()`: `rebind` replaces its in-memory defaults. The command
102
+ bus of the kernel module runs every command in the bound `UnitOfWork`; handlers do not know it.
103
+ `contextModule` of `@ddd-ts-kit/di` turns the declared classes of a context into its module.
104
+
105
+ A project written by the CLI holds the same wiring in two files:
106
+ `platform/database/persistence-over.ts` builds the unit of work and the two stores once, and
107
+ `platform/platform.module.ts` binds them, the outbox publisher being given to
108
+ `kernelModule({ eventPublisher })`.
109
+
110
+ The factories pass the tables of the `messaging` schema. With the tables in `public`, the
111
+ stores need no option and the class is bound directly:
112
+ `builder.bind(OutboxStore).toClass(DrizzleOutboxStore, [Database])`,
113
+ `builder.rebind(ProcessedEventStore).toClass(DrizzleProcessedEventStore, [Database])`.
114
+
115
+ The relay reads the same store and hands the records to the broker:
116
+
117
+ ```ts
118
+ const relay = new OutboxRelay({ store: app.resolve(OutboxStore), publisher: broker, clock: app.resolve(Clock) });
119
+ relay.start({ intervalMs: 500 });
120
+ ```
121
+
122
+ `unitOfWork.run(work)` opens `db.transaction` and keeps the transaction in an
123
+ `AsyncLocalStorage`; a nested `run` joins the outer transaction. Because `append` uses the
124
+ current transaction, an event published by a handler that then fails is rolled back with the
125
+ business write: that is the whole point of the outbox.
126
+
127
+ `run` is built on `withinUnitOfWork` of the kernel. The domain events of the aggregates saved
128
+ inside it wait until `work` has returned, then are dispatched inside the transaction, before
129
+ the commit: their handlers see every save, and what they write commits or rolls back with it.
130
+ If `work`, a handler or the commit fails, nothing is left: no row, no outbox record, no handler
131
+ run after the commit. Handlers declared `after-commit` run once the transaction has committed.
132
+ A nested `run` joins the unit of work in progress: one dispatch, at the end of the outermost.
133
+ A `run` called directly (a consumer, a script) has the same timing as a command.
134
+ `unitOfWorkContract` of `@ddd-ts-kit/testing/contracts` proves it on PGlite, with a deferred
135
+ constraint for the commit that fails.
136
+
137
+ ## Repository
138
+
139
+ The port lives in the domain, the adapter in infrastructure. A repository receives the
140
+ database, never a transaction: `executor()` is the transaction of the unit of work when there
141
+ is one and `db` otherwise.
142
+
143
+ ```ts
144
+ // contexts/order-management/domain/repositories/order.repository.ts
145
+ import { Repository } from '@ddd-ts-kit/kernel';
146
+
147
+ export abstract class OrderRepository extends Repository<Order> {}
148
+ ```
149
+
150
+ ```ts
151
+ // contexts/order-management/infrastructure/persistence/drizzle-order.repository.ts
152
+ import { and, eq } from 'drizzle-orm';
153
+ import { Repository } from '@ddd-ts-kit/kernel/declarations';
154
+ import { optimisticUpdate } from '@ddd-ts-kit/drizzle/postgres';
155
+
156
+ @Repository(OrderRepository, [Database])
157
+ export class DrizzleOrderRepository implements OrderRepository {
158
+ constructor(private readonly database: Database) {}
159
+
160
+ async findById(id: string): Promise<Order | undefined> {
161
+ const [row] = await this.database.executor().select().from(orders).where(eq(orders.id, id));
162
+
163
+ return row === undefined ? undefined : OrderMapper.toDomain(row); // sets order.version
164
+ }
165
+
166
+ async save(order: Order): Promise<void> {
167
+ const db = this.database.executor();
168
+
169
+ await optimisticUpdate(order, async ({ expected, next }) => {
170
+ const row = { ...OrderMapper.toRow(order), version: next };
171
+ const written =
172
+ expected === 0
173
+ ? await db.insert(orders).values(row).onConflictDoNothing().returning({ id: orders.id })
174
+ : await db
175
+ .update(orders)
176
+ .set(row)
177
+ .where(and(eq(orders.id, order.id), eq(orders.version, expected)))
178
+ .returning({ id: orders.id });
179
+
180
+ return written.length;
181
+ });
182
+ }
183
+ }
184
+ ```
185
+
186
+ In the explicit style, without the decorator, the same binding is one line of
187
+ `<context>.module.ts`:
188
+ `builder.bind(OrderRepository).toClass(DrizzleOrderRepository, [Database])`.
189
+
190
+ `optimisticUpdate` gives the version to expect and the version to write, raises a
191
+ `ConcurrencyError` (a `DomainError` with code `CONCURRENCY_CONFLICT`) when the guarded
192
+ statement wrote no row, and moves `order.version` on otherwise. If your database was created
193
+ with a relational schema (`drizzle(url, { schema })`), declare `Database` as
194
+ `DrizzleContext<PgQueryResultHKT, typeof schema>` to keep `executor().query`.
195
+
196
+ ## Audit columns, tenant scoping and pages
197
+
198
+ The standard persistence shapes of the kernel, on drizzle. `auditColumns()` and `tenantColumn()`
199
+ are column helpers to spread into a table: their keys are those of the kernel `AuditStamp` and
200
+ `TenantScoped`, so a stamp spreads into an insert as it is, and the four columns have the same
201
+ names in every project (`created_at`, `created_by`, `updated_at`, `updated_by`, `tenant_id`),
202
+ which is what lets a dashboard, an export or the audit package be written once.
203
+
204
+ ```ts
205
+ import { auditColumns, contextSchema, tenantColumn } from '@ddd-ts-kit/drizzle/postgres';
206
+
207
+ export const invoices = contextSchema('billing-core').table(
208
+ 'invoices',
209
+ {
210
+ id: text('id').primaryKey(),
211
+ amount: integer('amount').notNull(),
212
+ status: text('status').$type<'draft' | 'issued'>().notNull(),
213
+ version: integer('version').notNull(),
214
+ ...tenantColumn(),
215
+ ...auditColumns(),
216
+ },
217
+ (table) => [index('invoices_tenant_amount_idx').on(table.tenantId, table.amount, table.id)],
218
+ );
219
+ ```
220
+
221
+ ```ts
222
+ import { Clock, ExecutionScope, stampCreation, stampUpdate, tenantScopeOf } from '@ddd-ts-kit/kernel';
223
+ import { Repository } from '@ddd-ts-kit/kernel/declarations';
224
+
225
+ @Repository(InvoiceRepository, [Database, ExecutionScope, Clock])
226
+ export class DrizzleInvoiceRepository implements InvoiceRepository {
227
+ constructor(
228
+ private readonly database: Database,
229
+ private readonly scope: ExecutionScope,
230
+ private readonly clock: Clock,
231
+ ) {}
232
+
233
+ async findById(id: string): Promise<Invoice | undefined> {
234
+ const [row] = await this.database
235
+ .executor()
236
+ .select()
237
+ .from(invoices)
238
+ .where(and(eq(invoices.id, id), ownTenant(this.scope)));
239
+
240
+ return row === undefined ? undefined : InvoiceMapper.toDomain(row);
241
+ }
242
+
243
+ async save(invoice: Invoice): Promise<void> {
244
+ await optimisticUpdate(invoice, async ({ expected, next }) => {
245
+ const state = { ...InvoiceMapper.toRow(invoice), version: next };
246
+ const written = expected === 0 ? await this.insert(state) : await this.update(state, expected);
247
+
248
+ return written.length;
249
+ });
250
+ }
251
+
252
+ private insert(state: VersionedInvoice) {
253
+ const row = { ...state, ...tenantScopeOf(this.scope), ...stampCreation(this.scope, this.clock) };
254
+
255
+ return this.database.executor().insert(invoices).values(row).onConflictDoNothing().returning({ id: invoices.id });
256
+ }
257
+
258
+ private update(state: VersionedInvoice, expected: number) {
259
+ return this.database
260
+ .executor()
261
+ .update(invoices)
262
+ .set({ ...state, ...stampUpdate(this.scope, this.clock) })
263
+ .where(and(eq(invoices.id, state.id), eq(invoices.version, expected), ownTenant(this.scope)))
264
+ .returning({ id: invoices.id });
265
+ }
266
+ }
267
+
268
+ const ownTenant = (scope: ExecutionScope) => eq(invoices.tenantId, tenantScopeOf(scope).tenantId);
269
+ ```
270
+
271
+ A search query takes the kernel `PageRequest` and answers a `Page`, in both variants:
272
+
273
+ ```ts
274
+ // contexts/billing-core/application/ports/invoice-queries.port.ts
275
+ export abstract class InvoiceQueries {
276
+ abstract search(request: PageRequest<'amount'>): Promise<Page<InvoiceSummary>>;
277
+ }
278
+ ```
279
+
280
+ ```ts
281
+ // contexts/billing-core/infrastructure/persistence/drizzle-invoice-queries.reader.ts
282
+ import { ExecutionScope, decodeCursor, encodeCursor, pageOf } from '@ddd-ts-kit/kernel';
283
+ import { Repository } from '@ddd-ts-kit/kernel/declarations';
284
+
285
+ @Repository(InvoiceQueries, [Database, ExecutionScope])
286
+ export class DrizzleInvoiceQueries implements InvoiceQueries {
287
+ constructor(
288
+ private readonly database: Database,
289
+ private readonly scope: ExecutionScope,
290
+ ) {}
291
+
292
+ search(request: PageRequest<'amount'>): Promise<Page<InvoiceSummary>> {
293
+ return request.kind === 'offset' ? this.byOffset(request) : this.byCursor(request);
294
+ }
295
+
296
+ private async byOffset(request: OffsetPageRequest<'amount'>): Promise<Page<InvoiceSummary>> {
297
+ const db = this.database.executor();
298
+ const rows = await db
299
+ .select()
300
+ .from(invoices)
301
+ .where(ownTenant(this.scope))
302
+ .orderBy(...totalOrder(directionOf(request)))
303
+ .limit(request.limit)
304
+ .offset(request.offset);
305
+
306
+ return pageOf(rows.map(toSummary), { total: await db.$count(invoices, ownTenant(this.scope)) });
307
+ }
308
+
309
+ private async byCursor(request: CursorPageRequest<'amount'>): Promise<Page<InvoiceSummary>> {
310
+ const direction = directionOf(request);
311
+ const after = request.cursor === undefined ? undefined : beyond(positionOf(request.cursor), direction);
312
+ const rows = await this.database
313
+ .executor()
314
+ .select()
315
+ .from(invoices)
316
+ .where(and(ownTenant(this.scope), after))
317
+ .orderBy(...totalOrder(direction))
318
+ .limit(request.limit + 1);
319
+ const items = rows.slice(0, request.limit);
320
+ const last = items.at(-1);
321
+ const more = rows.length > request.limit && last !== undefined;
322
+
323
+ return pageOf(items.map(toSummary), more ? { nextCursor: encodeCursor({ amount: last.amount, id: last.id }) } : {});
324
+ }
325
+ }
326
+
327
+ const directionOf = (request: PageRequest<'amount'>) => request.sort[0]?.direction ?? 'asc';
328
+
329
+ // The primary key follows the sort field, in the same direction: the order is total.
330
+ function totalOrder(direction: SortDirection): SQL[] {
331
+ const toward = direction === 'asc' ? asc : desc;
332
+
333
+ return [toward(invoices.amount), toward(invoices.id)];
334
+ }
335
+
336
+ // The rows after a position, as a tuple comparison: the tie-breaker decides among equals.
337
+ function beyond({ amount, id }: { amount: number; id: string }, direction: SortDirection) {
338
+ const past = direction === 'asc' ? gt : lt;
339
+
340
+ return or(past(invoices.amount, amount), and(eq(invoices.amount, amount), past(invoices.id, id)));
341
+ }
342
+
343
+ function positionOf(cursor: string): { amount: number; id: string } {
344
+ const { amount, id } = decodeCursor(cursor);
345
+
346
+ if (typeof amount !== 'number' || typeof id !== 'string') {
347
+ throw new InvalidPageRequestError('The cursor of a page is the nextCursor of a previous page');
348
+ }
349
+
350
+ return { amount, id };
351
+ }
352
+ ```
353
+
354
+ A read adapter over the database is declared with `@Repository`, as the ones the CLI
355
+ generates: its kind is `repository`. Declared with `@Adapter` it would be `external`, and a
356
+ generated platform puts a retry and a time limit around every `external` and `gateway`
357
+ adapter, which a statement of the unit of work must not get.
358
+
359
+ The rules these examples hold, and that the tests of this package check on a real database:
360
+
361
+ - **Audit columns** are filled by the adapter, never by the aggregate: `stampCreation(scope,
362
+ clock)` on insert, `stampUpdate(scope, clock)` on update, which leaves `created_at` and
363
+ `created_by` alone. The actor is the one of the execution scope: the user of the request, or
364
+ `system:<consumer>` for a consumed event.
365
+ - **The tenant is part of every statement**: written on insert, and in the condition of every
366
+ read and every update, the version guard included. A row of another tenant is not found, and
367
+ an update aimed at it writes nothing, which `optimisticUpdate` reports as a
368
+ `ConcurrencyError`. Work that names no tenant raises `NoCurrentTenantError` before any SQL.
369
+ - **A list has a total order.** The primary key follows the sort field, in the same direction,
370
+ as a tie-breaker: rows equal on the sort field otherwise have no defined order, and a page
371
+ could repeat or skip one of them. The index covers `(tenant_id, <sort field>, id)`.
372
+ - **A cursor is a position, not a page number**: the sort value and the id of the last row,
373
+ written by `encodeCursor`, compared as a tuple (`amount > :amount OR (amount = :amount AND id
374
+ > :id)`). One row more than `limit` is fetched to know whether a page follows, so no count is
375
+ needed, and a row inserted before the cursor does not shift the next page as it does with an
376
+ offset. A cursor comes back from a client: `decodeCursor` refuses what it did not write, and
377
+ the adapter checks the types of the position before they reach a statement.
378
+ - **An offset page answers `total`**, a cursor page `nextCursor`. Offer the offset form for
379
+ small, browsable lists and the cursor form for anything that grows.
380
+
381
+ ## Criteria
382
+
383
+ `criteriaToDrizzle(plan, columns)` turns a criteria plan of the kernel into the parts of a
384
+ query: `where` (the filters, the scope of the actor and the keyset of a cursor page),
385
+ `counted` (the filters and the scope alone, for a total), `orderBy` (the total order ended by
386
+ the tie-breaker), `limit` and `offset`. `predicateToDrizzle(predicate, columns)` gives one
387
+ condition. `columns` maps each field of the read model to its column, and is the only source
388
+ of identifiers: every value is a parameter encoded by its column, a text search is
389
+ `like $1 escape $2` with `%`, `_` and `\` escaped.
390
+
391
+ ```ts
392
+ // infrastructure/persistence/drizzle-appointment-queries.reader.ts
393
+ async search(criteria: ScopedCriteria<AppointmentRow>): Promise<Page<AppointmentRow>> {
394
+ const plan = planCriteria(appointmentCriteria, criteria);
395
+
396
+ if (plan.empty) {
397
+ return pageOfPlan(plan, []);
398
+ }
399
+
400
+ const { where, orderBy, limit, offset } = criteriaToDrizzle(plan, COLUMNS);
401
+ const rows = await this.database.executor().select().from(appointments).where(where).orderBy(...orderBy).limit(limit).offset(offset);
402
+
403
+ return pageOfPlan(plan, rows);
404
+ }
405
+ ```
406
+
407
+ A cursor page is fetched one row longer than asked, which is how `pageOfPlan` knows whether
408
+ another follows; an offset page takes its total from `db.$count(table, counted)`. A field
409
+ declared `nullable` is ordered `nulls last` ascending and `nulls first` descending.
410
+
411
+ **Strings.** A text column is ordered and compared by range in the collation of the column;
412
+ criteria order strings by code point. Declare the text columns that are sorted or compared
413
+ with `gt`, `lt` or `between` with `COLLATE "C"` (a `customType` whose `dataType` is `text collate "C"`, or the migration): the index then serves the sort, and the
414
+ order is the same as in the fake. **Decimals** are a `numeric` column and a string in the
415
+ row; they read back in the scale of the column. **Instants** are `timestamptz` and a `Date`.
416
+ An integer is `integer`: a `bigint` comes back as a string from the driver.
417
+
418
+ ## Read models
419
+
420
+ ```ts
421
+ // infrastructure/persistence/appointment-list.schema.ts
422
+ export const appointmentList = contextSchema('scheduling').table('appointment_list', {
423
+ id: text('id').primaryKey(),
424
+ status: text('status').notNull(),
425
+ startsAt: timestamp('starts_at', { withTimezone: true, mode: 'date' }).notNull(),
426
+ ...readModelColumns(), // last_event_id, last_event_at, last_event_version, deleted
427
+ });
428
+
429
+ // infrastructure/persistence/drizzle-appointment-list.repository.ts
430
+ @Repository(AppointmentList, [Database])
431
+ export class DrizzleAppointmentList extends DrizzleReadModelStore<AppointmentRow, PgQueryResultHKT> implements AppointmentList {
432
+ constructor(database: Database) {
433
+ super(database, {
434
+ table: appointmentList,
435
+ key: appointmentList.id,
436
+ columns: { id: appointmentList.id, status: appointmentList.status, startsAt: appointmentList.startsAt },
437
+ definition: appointmentCriteria,
438
+ });
439
+ }
440
+ }
441
+ ```
442
+
443
+ Every write goes through `TableReadModelStore` of the kernel: a row is written only if its
444
+ event is newer than the one it holds, on the executor of the unit of work, so the row and the
445
+ processed-event mark commit together. A removed row stays in the table with `deleted` set:
446
+ a query of your own on the table leaves those rows out, and so does a unique index
447
+ (`WHERE NOT deleted`). `store.table` is what `ProjectionRebuild` takes; the rebuild is
448
+ PostgreSQL only (a copy `<table>__rebuild` made `LIKE ... INCLUDING ALL`, the keys written
449
+ meanwhile in `<table>__touched`, the swap by rename in one transaction) and its guarantees
450
+ and limits are in the README of `@ddd-ts-kit/kernel` ("Rebuild"). The store passes
451
+ `criteriaContract` and `readModelStoreContract` of `@ddd-ts-kit/testing/contracts`, on PGlite
452
+ and on PostgreSQL 18.
453
+
454
+ ## Outbox and idempotency
455
+
456
+ - `DrizzleOutboxStore.claimBatch` selects the due rows `FOR UPDATE SKIP LOCKED` and leases
457
+ them in the same short transaction by pushing `next_attempt_at` forward (`leaseMs`, default
458
+ 30 s), so several relays share the work without waiting for each other, and the records of a
459
+ relay that died come back when the lease expires. Delivery is at least once, unordered.
460
+ - `DrizzleProcessedEventStore.markProcessed` is an `INSERT ... ON CONFLICT DO NOTHING`. Run
461
+ the consumer inside the unit of work: the mark then commits with the effects of the handler,
462
+ and disappears with them if it fails.
463
+
464
+ ## Testing an adapter
465
+
466
+ The unit of work of this package passes `unitOfWorkContract`, and its stores
467
+ `outboxStoreContract` and `processedEventStoreContract`, of `@ddd-ts-kit/testing/contracts` on
468
+ PGlite (PostgreSQL in process, no container). Hold your repositories to `repositoryContract`
469
+ the same way, next to their in-memory fake.
470
+
471
+ The criteria translator and the read model store also run on a real server when
472
+ `DDD_TS_KIT_TEST_DATABASE_URL` is set, through `drizzle-orm/node-postgres`, each spec file in a
473
+ database of its own that it drops afterwards (last run: PostgreSQL 18):
474
+
475
+ ```
476
+ DDD_TS_KIT_TEST_DATABASE_URL=postgresql://user:password@localhost:5432/postgres npx vitest run packages/drizzle
477
+ ```
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The four audit columns, to spread into a table definition. Their keys are those of the
3
+ * kernel `AuditStamp`, so `stampCreation(scope, clock)` spreads into an insert as it is, and
4
+ * their names (`created_at`, `created_by`, `updated_at`, `updated_by`) are the same in every
5
+ * project.
6
+ */
7
+ export declare function auditColumns(): {
8
+ createdAt: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTimestampBuilderInitial<string>>;
9
+ createdBy: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTextBuilderInitial<"created_by", [string, ...string[]]>>;
10
+ updatedAt: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTimestampBuilderInitial<string>>;
11
+ updatedBy: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTextBuilderInitial<"updated_by", [string, ...string[]]>>;
12
+ };
13
+ //# sourceMappingURL=audit-columns.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit-columns.d.ts","sourceRoot":"","sources":["../../../src/postgres/columns/audit-columns.ts"],"names":[],"mappings":"AAGA;;;;;GAKG;AACH,wBAAgB,YAAY;IAExB,SAAS;IACT,SAAS;IACT,SAAS;IACT,SAAS;EAEZ"}
@@ -0,0 +1,17 @@
1
+ import { text } from 'drizzle-orm/pg-core';
2
+ import { instant } from './instant.js';
3
+ /**
4
+ * The four audit columns, to spread into a table definition. Their keys are those of the
5
+ * kernel `AuditStamp`, so `stampCreation(scope, clock)` spreads into an insert as it is, and
6
+ * their names (`created_at`, `created_by`, `updated_at`, `updated_by`) are the same in every
7
+ * project.
8
+ */
9
+ export function auditColumns() {
10
+ return {
11
+ createdAt: instant('created_at').notNull(),
12
+ createdBy: text('created_by').notNull(),
13
+ updatedAt: instant('updated_at').notNull(),
14
+ updatedBy: text('updated_by').notNull(),
15
+ };
16
+ }
17
+ //# sourceMappingURL=audit-columns.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit-columns.js","sourceRoot":"","sources":["../../../src/postgres/columns/audit-columns.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;AAC3C,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAEvC;;;;;GAKG;AACH,MAAM,UAAU,YAAY;IAC1B,OAAO;QACL,SAAS,EAAE,OAAO,CAAC,YAAY,CAAC,CAAC,OAAO,EAAE;QAC1C,SAAS,EAAE,IAAI,CAAC,YAAY,CAAC,CAAC,OAAO,EAAE;QACvC,SAAS,EAAE,OAAO,CAAC,YAAY,CAAC,CAAC,OAAO,EAAE;QAC1C,SAAS,EAAE,IAAI,CAAC,YAAY,CAAC,CAAC,OAAO,EAAE;KACxC,CAAC;AACJ,CAAC"}
@@ -0,0 +1,3 @@
1
+ /** How every instant is stored: with its time zone, read back as a date. */
2
+ export declare function instant(name: string): import("drizzle-orm/pg-core").PgTimestampBuilderInitial<string>;
3
+ //# sourceMappingURL=instant.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"instant.d.ts","sourceRoot":"","sources":["../../../src/postgres/columns/instant.ts"],"names":[],"mappings":"AAEA,4EAA4E;AAC5E,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,mEAEnC"}
@@ -0,0 +1,6 @@
1
+ import { timestamp } from 'drizzle-orm/pg-core';
2
+ /** How every instant is stored: with its time zone, read back as a date. */
3
+ export function instant(name) {
4
+ return timestamp(name, { withTimezone: true, mode: 'date' });
5
+ }
6
+ //# sourceMappingURL=instant.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"instant.js","sourceRoot":"","sources":["../../../src/postgres/columns/instant.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAEhD,4EAA4E;AAC5E,MAAM,UAAU,OAAO,CAAC,IAAY;IAClC,OAAO,SAAS,CAAC,IAAI,EAAE,EAAE,YAAY,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;AAC/D,CAAC"}
@@ -0,0 +1,5 @@
1
+ /** The tenant column, `tenant_id`, under the key of the kernel `TenantScoped`. */
2
+ export declare function tenantColumn(): {
3
+ tenantId: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTextBuilderInitial<"tenant_id", [string, ...string[]]>>;
4
+ };
5
+ //# sourceMappingURL=tenant-column.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tenant-column.d.ts","sourceRoot":"","sources":["../../../src/postgres/columns/tenant-column.ts"],"names":[],"mappings":"AAEA,kFAAkF;AAClF,wBAAgB,YAAY;IACjB,QAAQ;EAClB"}
@@ -0,0 +1,6 @@
1
+ import { text } from 'drizzle-orm/pg-core';
2
+ /** The tenant column, `tenant_id`, under the key of the kernel `TenantScoped`. */
3
+ export function tenantColumn() {
4
+ return { tenantId: text('tenant_id').notNull() };
5
+ }
6
+ //# sourceMappingURL=tenant-column.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tenant-column.js","sourceRoot":"","sources":["../../../src/postgres/columns/tenant-column.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;AAE3C,kFAAkF;AAClF,MAAM,UAAU,YAAY;IAC1B,OAAO,EAAE,QAAQ,EAAE,IAAI,CAAC,WAAW,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC;AACnD,CAAC"}
@@ -0,0 +1,39 @@
1
+ import { type TablesRelationalConfig } from 'drizzle-orm';
2
+ import type { PgQueryResultHKT } from 'drizzle-orm/pg-core';
3
+ import type { IntegrationEvent } from '@ddd-ts-kit/kernel';
4
+ import type { OutboxRecord, OutboxStats, OutboxStore } from '@ddd-ts-kit/events';
5
+ import type { DrizzleContext } from '../unit-of-work/drizzle-context.js';
6
+ import { type OutboxTable } from './outbox-table.js';
7
+ export interface DrizzleOutboxStoreOptions {
8
+ /** Default `outboxTable()`, in the `public` schema. */
9
+ readonly table?: OutboxTable;
10
+ /** How long a claimed record stays invisible to other claims. Default 30000. */
11
+ readonly leaseMs?: number;
12
+ }
13
+ /**
14
+ * The outbox in PostgreSQL. `append` runs on the executor of the context: called inside a
15
+ * unit of work, the events are written by the transaction of the command and share its fate,
16
+ * which is the whole point of an outbox.
17
+ */
18
+ export declare class DrizzleOutboxStore<Q extends PgQueryResultHKT, F extends Record<string, unknown>, S extends TablesRelationalConfig> implements OutboxStore {
19
+ private readonly context;
20
+ private readonly outbox;
21
+ private readonly leaseDurationMs;
22
+ constructor(context: DrizzleContext<Q, F, S>, options?: DrizzleOutboxStoreOptions);
23
+ append(events: readonly IntegrationEvent[]): Promise<void>;
24
+ /**
25
+ * Selects the due rows `FOR UPDATE SKIP LOCKED`, so concurrent relays pass over each other's
26
+ * rows instead of waiting, then leases them in the same transaction by pushing
27
+ * `next_attempt_at` forward. The row locks last a few milliseconds; the lease is what covers
28
+ * the publication.
29
+ */
30
+ claimBatch(limit: number, now: Date): Promise<OutboxRecord[]>;
31
+ markPublished(ids: readonly string[], at: Date): Promise<void>;
32
+ markFailed(id: string, error: {
33
+ readonly code: string;
34
+ }, nextAttemptAt: Date | null): Promise<void>;
35
+ retryDead(ids: readonly string[], now: Date): Promise<number>;
36
+ purgePublished(before: Date): Promise<number>;
37
+ stats(): Promise<OutboxStats>;
38
+ }
39
+ //# sourceMappingURL=drizzle-outbox-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"drizzle-outbox-store.d.ts","sourceRoot":"","sources":["../../../src/postgres/outbox/drizzle-outbox-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsC,KAAK,sBAAsB,EAAE,MAAM,aAAa,CAAC;AAC9F,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAC3D,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACjF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oCAAoC,CAAC;AACzE,OAAO,EAAe,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAElE,MAAM,WAAW,yBAAyB;IACxC,uDAAuD;IACvD,QAAQ,CAAC,KAAK,CAAC,EAAE,WAAW,CAAC;IAC7B,gFAAgF;IAChF,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAOD;;;;GAIG;AACH,qBAAa,kBAAkB,CAC7B,CAAC,SAAS,gBAAgB,EAC1B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,CAAC,SAAS,sBAAsB,CAChC,YAAW,WAAW;IAEtB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAElD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;IAErC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;IAEzC,YAAY,OAAO,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,GAAE,yBAA8B,EAIpF;IAEK,MAAM,CAAC,MAAM,EAAE,SAAS,gBAAgB,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAoB/D;IAED;;;;;OAKG;IACG,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CA4BlE;IAEK,aAAa,CAAC,GAAG,EAAE,SAAS,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAUnE;IAEK,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE;QAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,aAAa,EAAE,IAAI,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAUxG;IAEK,SAAS,CAAC,GAAG,EAAE,SAAS,MAAM,EAAE,EAAE,GAAG,EAAE,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,CAalE;IAEK,cAAc,CAAC,MAAM,EAAE,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,CAQlD;IAEK,KAAK,IAAI,OAAO,CAAC,WAAW,CAAC,CAiBlC;CACF"}