@ddd-ts-kit/knex 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 (122) hide show
  1. package/README.md +545 -2
  2. package/dist/columns/audit-columns.d.ts +7 -0
  3. package/dist/columns/audit-columns.d.ts.map +1 -0
  4. package/dist/columns/audit-columns.js +13 -0
  5. package/dist/columns/audit-columns.js.map +1 -0
  6. package/dist/columns/audit-row.d.ts +16 -0
  7. package/dist/columns/audit-row.d.ts.map +1 -0
  8. package/dist/columns/audit-row.js +5 -0
  9. package/dist/columns/audit-row.js.map +1 -0
  10. package/dist/columns/indexable-length.d.ts +3 -0
  11. package/dist/columns/indexable-length.d.ts.map +1 -0
  12. package/dist/columns/indexable-length.js +3 -0
  13. package/dist/columns/indexable-length.js.map +1 -0
  14. package/dist/columns/instant.d.ts +6 -0
  15. package/dist/columns/instant.d.ts.map +1 -0
  16. package/dist/columns/instant.js +3 -0
  17. package/dist/columns/instant.js.map +1 -0
  18. package/dist/columns/tenant-column.d.ts +4 -0
  19. package/dist/columns/tenant-column.d.ts.map +1 -0
  20. package/dist/columns/tenant-column.js +6 -0
  21. package/dist/columns/tenant-column.js.map +1 -0
  22. package/dist/columns/tenant-row.d.ts +7 -0
  23. package/dist/columns/tenant-row.d.ts.map +1 -0
  24. package/dist/columns/tenant-row.js +5 -0
  25. package/dist/columns/tenant-row.js.map +1 -0
  26. package/dist/index.d.ts +21 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +21 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/outbox/builder-in.d.ts +3 -0
  31. package/dist/outbox/builder-in.d.ts.map +1 -0
  32. package/dist/outbox/builder-in.js +4 -0
  33. package/dist/outbox/builder-in.js.map +1 -0
  34. package/dist/outbox/create-outbox-tables.d.ts +9 -0
  35. package/dist/outbox/create-outbox-tables.d.ts.map +1 -0
  36. package/dist/outbox/create-outbox-tables.js +97 -0
  37. package/dist/outbox/create-outbox-tables.js.map +1 -0
  38. package/dist/outbox/drop-outbox-tables.d.ts +5 -0
  39. package/dist/outbox/drop-outbox-tables.d.ts.map +1 -0
  40. package/dist/outbox/drop-outbox-tables.js +8 -0
  41. package/dist/outbox/drop-outbox-tables.js.map +1 -0
  42. package/dist/outbox/knex-outbox-store.d.ts +61 -0
  43. package/dist/outbox/knex-outbox-store.d.ts.map +1 -0
  44. package/dist/outbox/knex-outbox-store.js +184 -0
  45. package/dist/outbox/knex-outbox-store.js.map +1 -0
  46. package/dist/outbox/knex-processed-event-store.d.ts +21 -0
  47. package/dist/outbox/knex-processed-event-store.d.ts.map +1 -0
  48. package/dist/outbox/knex-processed-event-store.js +26 -0
  49. package/dist/outbox/knex-processed-event-store.js.map +1 -0
  50. package/dist/outbox/outbox-tables-options.d.ts +12 -0
  51. package/dist/outbox/outbox-tables-options.d.ts.map +1 -0
  52. package/dist/outbox/outbox-tables-options.js +2 -0
  53. package/dist/outbox/outbox-tables-options.js.map +1 -0
  54. package/dist/outbox/table-names.d.ts +3 -0
  55. package/dist/outbox/table-names.d.ts.map +1 -0
  56. package/dist/outbox/table-names.js +3 -0
  57. package/dist/outbox/table-names.js.map +1 -0
  58. package/dist/read-model/column-of.d.ts +3 -0
  59. package/dist/read-model/column-of.d.ts.map +1 -0
  60. package/dist/read-model/column-of.js +9 -0
  61. package/dist/read-model/column-of.js.map +1 -0
  62. package/dist/read-model/criteria-to-knex.d.ts +11 -0
  63. package/dist/read-model/criteria-to-knex.d.ts.map +1 -0
  64. package/dist/read-model/criteria-to-knex.js +28 -0
  65. package/dist/read-model/criteria-to-knex.js.map +1 -0
  66. package/dist/read-model/knex-read-model-options.d.ts +10 -0
  67. package/dist/read-model/knex-read-model-options.d.ts.map +1 -0
  68. package/dist/read-model/knex-read-model-options.js +2 -0
  69. package/dist/read-model/knex-read-model-options.js.map +1 -0
  70. package/dist/read-model/knex-read-model-store.d.ts +16 -0
  71. package/dist/read-model/knex-read-model-store.d.ts.map +1 -0
  72. package/dist/read-model/knex-read-model-store.js +13 -0
  73. package/dist/read-model/knex-read-model-store.js.map +1 -0
  74. package/dist/read-model/knex-read-model-table.d.ts +32 -0
  75. package/dist/read-model/knex-read-model-table.d.ts.map +1 -0
  76. package/dist/read-model/knex-read-model-table.js +83 -0
  77. package/dist/read-model/knex-read-model-table.js.map +1 -0
  78. package/dist/read-model/knex-stamped-rows.d.ts +19 -0
  79. package/dist/read-model/knex-stamped-rows.d.ts.map +1 -0
  80. package/dist/read-model/knex-stamped-rows.js +68 -0
  81. package/dist/read-model/knex-stamped-rows.js.map +1 -0
  82. package/dist/read-model/like-pattern.d.ts +4 -0
  83. package/dist/read-model/like-pattern.d.ts.map +1 -0
  84. package/dist/read-model/like-pattern.js +6 -0
  85. package/dist/read-model/like-pattern.js.map +1 -0
  86. package/dist/read-model/predicate-to-knex.d.ts +11 -0
  87. package/dist/read-model/predicate-to-knex.d.ts.map +1 -0
  88. package/dist/read-model/predicate-to-knex.js +63 -0
  89. package/dist/read-model/predicate-to-knex.js.map +1 -0
  90. package/dist/read-model/read-model-columns.d.ts +8 -0
  91. package/dist/read-model/read-model-columns.d.ts.map +1 -0
  92. package/dist/read-model/read-model-columns.js +14 -0
  93. package/dist/read-model/read-model-columns.js.map +1 -0
  94. package/dist/read-model/records-of.d.ts +3 -0
  95. package/dist/read-model/records-of.d.ts.map +1 -0
  96. package/dist/read-model/records-of.js +9 -0
  97. package/dist/read-model/records-of.js.map +1 -0
  98. package/dist/read-model/row.mapper.d.ts +8 -0
  99. package/dist/read-model/row.mapper.d.ts.map +1 -0
  100. package/dist/read-model/row.mapper.js +17 -0
  101. package/dist/read-model/row.mapper.js.map +1 -0
  102. package/dist/sql/dialect-of.d.ts +4 -0
  103. package/dist/sql/dialect-of.d.ts.map +1 -0
  104. package/dist/sql/dialect-of.js +17 -0
  105. package/dist/sql/dialect-of.js.map +1 -0
  106. package/dist/sql/insert-ignoring-conflict.d.ts +16 -0
  107. package/dist/sql/insert-ignoring-conflict.d.ts.map +1 -0
  108. package/dist/sql/insert-ignoring-conflict.js +28 -0
  109. package/dist/sql/insert-ignoring-conflict.js.map +1 -0
  110. package/dist/sql/sql-dialect.d.ts +3 -0
  111. package/dist/sql/sql-dialect.d.ts.map +1 -0
  112. package/dist/sql/sql-dialect.js +2 -0
  113. package/dist/sql/sql-dialect.js.map +1 -0
  114. package/dist/unit-of-work/create-knex-unit-of-work.d.ts +14 -0
  115. package/dist/unit-of-work/create-knex-unit-of-work.d.ts.map +1 -0
  116. package/dist/unit-of-work/create-knex-unit-of-work.js +24 -0
  117. package/dist/unit-of-work/create-knex-unit-of-work.js.map +1 -0
  118. package/dist/unit-of-work/knex-context.d.ts +13 -0
  119. package/dist/unit-of-work/knex-context.d.ts.map +1 -0
  120. package/dist/unit-of-work/knex-context.js +10 -0
  121. package/dist/unit-of-work/knex-context.js.map +1 -0
  122. package/package.json +41 -4
package/README.md CHANGED
@@ -1,3 +1,546 @@
1
- # Temporary Holding Version
1
+ # @ddd-ts-kit/knex
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
+ Knex adapters of the kernel and events ports: a unit of work that repositories never see, the
4
+ transactional outbox store, the processed-event store, a migration helper for their tables,
5
+ and one database schema per bounded context. Same pieces as `@ddd-ts-kit/drizzle`, for projects
6
+ that build their SQL with knex.
7
+
8
+ ```
9
+ npm i @ddd-ts-kit/knex knex pg
10
+ ```
11
+
12
+ PostgreSQL first. MySQL 8 and SQLite work through the same code, see
13
+ [Dialect coverage](#dialect-coverage) for what is tested and how.
14
+
15
+ ## Tables
16
+
17
+ The consuming project owns its migrations. `createOutboxTables` is a helper to call from one of
18
+ them:
19
+
20
+ ```ts
21
+ // migrations/20260301100000_outbox.ts
22
+ import type { Knex } from 'knex';
23
+ import { createOutboxTables, dropOutboxTables } from '@ddd-ts-kit/knex';
24
+
25
+ export const up = (knex: Knex) => createOutboxTables(knex, { schema: 'messaging' });
26
+ export const down = (knex: Knex) => dropOutboxTables(knex, { schema: 'messaging' });
27
+ ```
28
+
29
+ Without `schema` the tables live in the default schema of the connection. `outboxTable` and
30
+ `processedEventsTable` rename them. If you would rather not call a library from a migration,
31
+ this is the equivalent on PostgreSQL:
32
+
33
+ ```ts
34
+ export async function up(knex: Knex): Promise<void> {
35
+ await knex.raw(`
36
+ create schema if not exists "messaging";
37
+
38
+ create table "messaging"."outbox" (
39
+ "id" varchar(191) primary key,
40
+ "sequence" bigserial,
41
+ "type" text not null,
42
+ "version" integer not null,
43
+ "occurred_at" text not null,
44
+ "payload" jsonb,
45
+ "traceparent" text,
46
+ "correlation_id" text,
47
+ "status" varchar(16) not null default 'pending',
48
+ "attempts" integer not null default 0,
49
+ "next_attempt_at" timestamptz(3) not null,
50
+ "last_error_code" text,
51
+ "published_at" timestamptz(3),
52
+ "created_at" timestamptz(3) not null default current_timestamp(3)
53
+ );
54
+ create index "outbox_pending_idx" on "messaging"."outbox" ("next_attempt_at", "sequence")
55
+ where status = 'pending';
56
+
57
+ create table "messaging"."processed_events" (
58
+ "consumer" varchar(191) not null,
59
+ "event_id" varchar(191) not null,
60
+ "processed_at" timestamptz(3) not null default current_timestamp(3),
61
+ primary key ("consumer", "event_id")
62
+ );
63
+ `);
64
+ }
65
+ ```
66
+
67
+ `outbox` is indexed for the relay (pending records by next attempt, partial index);
68
+ `processed_events` has `(consumer, event_id)` as its primary key. `occurred_at` is text on
69
+ purpose: the envelope comes back exactly as published, offset included.
70
+
71
+ One schema per bounded context: `schemaNameOf('order-management')` gives `order_management`,
72
+ to pass to `knex.schema.createSchemaIfNotExists`, `knex.schema.withSchema` and
73
+ `.withSchema(...)` on queries. Contexts never join each other's tables.
74
+
75
+ ## Composition root
76
+
77
+ `createKnexUnitOfWork(db)` gives one object that is two ports: the kernel `UnitOfWork`, and
78
+ `KnexContext`, the handle repositories and stores run their queries on. Bind it to both:
79
+
80
+ ```ts
81
+ // platform/database/persistence.module.ts
82
+ import knex from 'knex';
83
+ import { defineContextModule } from '@ddd-ts-kit/di';
84
+ import { IntegrationEventPublisher, ProcessedEventStore, UnitOfWork } from '@ddd-ts-kit/kernel';
85
+ import { OutboxPublisher, OutboxStore } from '@ddd-ts-kit/events';
86
+ import {
87
+ createKnexUnitOfWork,
88
+ KnexContext,
89
+ KnexOutboxStore,
90
+ KnexProcessedEventStore,
91
+ } from '@ddd-ts-kit/knex';
92
+
93
+ const db = knex({ client: 'pg', connection: process.env.DATABASE_URL });
94
+ const unitOfWork = createKnexUnitOfWork(db);
95
+
96
+ export const persistenceModule = defineContextModule({
97
+ name: 'persistence',
98
+ register(builder) {
99
+ builder.rebind(UnitOfWork).toValue(unitOfWork);
100
+ builder.bind(KnexContext).toValue(unitOfWork);
101
+ builder.bind(OutboxStore).toClass(KnexOutboxStore, [KnexContext]);
102
+ builder.rebind(ProcessedEventStore).toClass(KnexProcessedEventStore, [KnexContext]);
103
+ builder.rebind(IntegrationEventPublisher).toClass(OutboxPublisher, [OutboxStore]);
104
+ },
105
+ });
106
+ ```
107
+
108
+ ```ts
109
+ // main.ts
110
+ const app = createApplication({ modules: [kernelModule(), persistenceModule, contextModule('billing', billingDeclarations)] });
111
+ ```
112
+
113
+ List the module after `kernelModule()`: `rebind` replaces its in-memory defaults. The command
114
+ bus of the kernel module runs every command in the bound `UnitOfWork`; handlers do not know it.
115
+ `contextModule` of `@ddd-ts-kit/di` turns the declared classes of a context into its module.
116
+
117
+ A project written by the CLI holds the same wiring in two files:
118
+ `platform/database/persistence-over.ts` builds the unit of work and the two stores once, and
119
+ `platform/platform.module.ts` binds them, the outbox publisher being given to
120
+ `kernelModule({ eventPublisher })`.
121
+
122
+ `toClass` builds the stores with their default options (tables `outbox` and
123
+ `processed_events` in the default schema of the connection). A store with options is built by
124
+ a factory:
125
+
126
+ ```ts
127
+ builder
128
+ .bind(OutboxStore)
129
+ .toFactory((resolve) => new KnexOutboxStore(resolve(KnexContext), { schema: 'messaging' }));
130
+ builder
131
+ .rebind(ProcessedEventStore)
132
+ .toFactory((resolve) => new KnexProcessedEventStore(resolve(KnexContext), { schema: 'messaging' }));
133
+ ```
134
+
135
+ The relay reads the same store and hands the records to the broker:
136
+
137
+ ```ts
138
+ const relay = new OutboxRelay({ store: app.resolve(OutboxStore), publisher: broker, clock: app.resolve(Clock) });
139
+ relay.start({ intervalMs: 500 });
140
+ ```
141
+
142
+ `unitOfWork.run(work)` opens `knex.transaction` and keeps the transaction in an
143
+ `AsyncLocalStorage`; a nested `run` joins the outer transaction (no savepoint). Because
144
+ `append` uses the current transaction, an event published by a handler that then fails is
145
+ rolled back with the business write: that is the whole point of the outbox.
146
+ `createKnexUnitOfWork(db, { isolationLevel: 'repeatable read' })` forwards the transaction
147
+ configuration of knex.
148
+
149
+ `run` is built on `withinUnitOfWork` of the kernel. The domain events of the aggregates saved
150
+ inside it wait until `work` has returned, then are dispatched inside the transaction, before
151
+ the commit: their handlers see every save, and what they write commits or rolls back with it.
152
+ If `work`, a handler or the commit fails, nothing is left: no row, no outbox record, no handler
153
+ run after the commit. Handlers declared `after-commit` run once the transaction has committed.
154
+ A nested `run` joins the unit of work in progress: one dispatch, at the end of the outermost.
155
+ A `run` called directly (a consumer, a script) has the same timing as a command.
156
+ `unitOfWorkContract` of `@ddd-ts-kit/testing/contracts` proves it, with a deferred constraint
157
+ for the commit that fails; on a real PostgreSQL (`DDD_TS_KIT_TEST_DATABASE_URL`) it also proves
158
+ that the rows of a `before-commit` handler are invisible from another connection and visible
159
+ to the `after-commit` handlers.
160
+
161
+ `KnexContext` is an abstract class, so it is its own injection token. A project with two
162
+ databases gives each a class of its own (`export abstract class Ledger extends KnexContext {}`
163
+ in `platform/database/`) and binds each to its unit of work.
164
+
165
+ ## Repository
166
+
167
+ The port lives in the domain, the adapter in infrastructure. A repository receives the context,
168
+ never a transaction: `executor()` is the transaction of the unit of work when there is one and
169
+ the root knex instance otherwise.
170
+
171
+ ```ts
172
+ // contexts/billing-core/domain/repositories/invoice.repository.ts
173
+ import { Repository } from '@ddd-ts-kit/kernel';
174
+
175
+ export abstract class InvoiceRepository extends Repository<Invoice> {}
176
+ ```
177
+
178
+ ```ts
179
+ // contexts/billing-core/infrastructure/persistence/knex-invoice.repository.ts
180
+ import type { Knex } from 'knex';
181
+ import { Repository } from '@ddd-ts-kit/kernel/declarations';
182
+ import { insertIgnoringConflict, KnexContext, optimisticUpdate, schemaNameOf } from '@ddd-ts-kit/knex';
183
+
184
+ const BILLING = schemaNameOf('billing-core'); // billing_core
185
+
186
+ interface InvoiceRow {
187
+ id: string;
188
+ amount: number;
189
+ status: 'draft' | 'issued';
190
+ version: number;
191
+ }
192
+
193
+ @Repository(InvoiceRepository, [KnexContext])
194
+ export class KnexInvoiceRepository implements InvoiceRepository {
195
+ constructor(private readonly context: KnexContext) {}
196
+
197
+ async findById(id: string): Promise<Invoice | undefined> {
198
+ const row = await invoicesOf(this.context).where({ id }).first();
199
+
200
+ if (row === undefined) {
201
+ return undefined;
202
+ }
203
+
204
+ const invoice = Invoice.rehydrate(row);
205
+ invoice.version = row.version;
206
+
207
+ return invoice;
208
+ }
209
+
210
+ async save(invoice: Invoice): Promise<void> {
211
+ await optimisticUpdate(invoice, async ({ expected, next }) => {
212
+ const row = { ...invoice.state, version: next };
213
+
214
+ if (expected === 0) {
215
+ const inserted = await insertIgnoringConflict(this.context.executor(), invoicesOf(this.context), { row, conflict: 'id' });
216
+
217
+ return inserted ? 1 : 0;
218
+ }
219
+
220
+ return invoicesOf(this.context).where({ id: invoice.id, version: expected }).update(row);
221
+ });
222
+ }
223
+ }
224
+
225
+ // A function, not a method: see below.
226
+ function invoicesOf(context: KnexContext): Knex.QueryBuilder<InvoiceRow, InvoiceRow[]> {
227
+ return context.executor()<InvoiceRow, InvoiceRow[]>('invoices').withSchema(BILLING);
228
+ }
229
+ ```
230
+
231
+ **No method of an adapter answers a query builder.** A knex builder is thenable, and what
232
+ wraps an adapter from outside (the spans of `@ddd-ts-kit/tracing`, an aspect) reads a method
233
+ that answered a thenable as asynchronous: from its second call the method answers a promise
234
+ of the rows, and `.where(...)` on it fails. The table is therefore reached through a function
235
+ of the module, as the repository the CLI generates does, and a private method that runs a
236
+ statement is declared `async`.
237
+
238
+ In the explicit style, without the decorator, the same binding is one line of
239
+ `<context>.module.ts`:
240
+ `builder.bind(InvoiceRepository).toClass(KnexInvoiceRepository, [KnexContext])`.
241
+
242
+ `optimisticUpdate` gives the version to expect and the version to write, raises a
243
+ `ConcurrencyError` (a `DomainError` with code `CONCURRENCY_CONFLICT`) when the guarded
244
+ statement wrote no row, and moves `invoice.version` on otherwise. `insertIgnoringConflict`
245
+ inserts unless the key exists and says whether it did; unlike a caught unique violation it
246
+ leaves the surrounding PostgreSQL transaction usable, and it hides the one place where the
247
+ dialects differ (`RETURNING` on PostgreSQL and SQLite, the affected-row count on MySQL).
248
+
249
+ ## Audit columns, tenant scoping and pages
250
+
251
+ The standard persistence shapes of the kernel, on knex. Four small helpers, so that the columns
252
+ have the same names in every project (`created_at`, `created_by`, `updated_at`, `updated_by`,
253
+ `tenant_id`), which is what lets a dashboard, an export or the audit package be written once:
254
+ `auditColumns(table)` and `tenantColumn(table)` for the migration, `auditRow(stamp)` and
255
+ `tenantRow(scoped)` to turn the kernel `AuditStamp` and `TenantScoped` into those column names,
256
+ since a knex row is keyed by column.
257
+
258
+ ```ts
259
+ import { auditColumns, tenantColumn } from '@ddd-ts-kit/knex';
260
+
261
+ export async function up(knex: Knex): Promise<void> {
262
+ await knex.schema.withSchema(BILLING).createTable('invoices', (table) => {
263
+ table.text('id').primary();
264
+ table.integer('amount').notNullable();
265
+ table.text('status').notNullable();
266
+ table.integer('version').notNullable();
267
+ tenantColumn(table);
268
+ auditColumns(table);
269
+ table.index(['tenant_id', 'amount', 'id'], 'invoices_tenant_amount_idx');
270
+ });
271
+ }
272
+ ```
273
+
274
+ ```ts
275
+ import { Clock, ExecutionScope, stampCreation, stampUpdate, tenantScopeOf } from '@ddd-ts-kit/kernel';
276
+ import { auditRow, tenantRow, type AuditRow, type TenantRow } from '@ddd-ts-kit/knex';
277
+
278
+ interface InvoiceRow extends VersionedInvoice, TenantRow, AuditRow {}
279
+
280
+ @Repository(InvoiceRepository, [KnexContext, ExecutionScope, Clock])
281
+ export class KnexInvoiceRepository implements InvoiceRepository {
282
+ constructor(
283
+ private readonly context: KnexContext,
284
+ private readonly scope: ExecutionScope,
285
+ private readonly clock: Clock,
286
+ ) {}
287
+
288
+ async findById(id: string): Promise<Invoice | undefined> {
289
+ const row = await invoicesOf(this.context).where({ id, ...this.ownTenant() }).first();
290
+
291
+ return row === undefined ? undefined : InvoiceMapper.toDomain(row);
292
+ }
293
+
294
+ async save(invoice: Invoice): Promise<void> {
295
+ await optimisticUpdate(invoice, async ({ expected, next }) => {
296
+ const state = { ...InvoiceMapper.toRow(invoice), version: next };
297
+
298
+ return expected === 0 ? this.insert(state) : this.update(state, expected);
299
+ });
300
+ }
301
+
302
+ private async insert(state: VersionedInvoice): Promise<number> {
303
+ const row = { ...state, ...this.ownTenant(), ...auditRow(stampCreation(this.scope, this.clock)) };
304
+ const inserted = await insertIgnoringConflict(this.context.executor(), invoicesOf(this.context), { row, conflict: 'id' });
305
+
306
+ return inserted ? 1 : 0;
307
+ }
308
+
309
+ private async update(state: VersionedInvoice, expected: number): Promise<number> {
310
+ return invoicesOf(this.context)
311
+ .where({ id: state.id, version: expected, ...this.ownTenant() })
312
+ .update({ ...state, ...auditRow(stampUpdate(this.scope, this.clock)) });
313
+ }
314
+
315
+ private ownTenant(): TenantRow {
316
+ return tenantRow(tenantScopeOf(this.scope));
317
+ }
318
+ }
319
+ ```
320
+
321
+ A search query takes the kernel `PageRequest` and answers a `Page`, in both variants:
322
+
323
+ ```ts
324
+ // contexts/billing-core/application/ports/invoice-queries.port.ts
325
+ export abstract class InvoiceQueries {
326
+ abstract search(request: PageRequest<'amount'>): Promise<Page<InvoiceSummary>>;
327
+ }
328
+ ```
329
+
330
+ ```ts
331
+ // contexts/billing-core/infrastructure/persistence/knex-invoice-queries.reader.ts
332
+ import { ExecutionScope, encodeCursor, pageOf, tenantScopeOf } from '@ddd-ts-kit/kernel';
333
+ import { Repository } from '@ddd-ts-kit/kernel/declarations';
334
+
335
+ @Repository(InvoiceQueries, [KnexContext, ExecutionScope])
336
+ export class KnexInvoiceQueries implements InvoiceQueries {
337
+ constructor(
338
+ private readonly context: KnexContext,
339
+ private readonly scope: ExecutionScope,
340
+ ) {}
341
+
342
+ async search(request: PageRequest<'amount'>): Promise<Page<InvoiceSummary>> {
343
+ return request.kind === 'offset' ? this.byOffset(request) : this.byCursor(request);
344
+ }
345
+
346
+ private async byOffset(request: OffsetPageRequest<'amount'>): Promise<Page<InvoiceSummary>> {
347
+ const rows = await ownInvoices(this.context, this.scope)
348
+ .orderBy(totalOrder(directionOf(request)))
349
+ .limit(request.limit)
350
+ .offset(request.offset);
351
+ const counted = await ownInvoices(this.context, this.scope).count<{ total: string | number }[]>({ total: '*' }).first();
352
+
353
+ return pageOf(rows.map(toSummary), { total: Number(counted?.total ?? 0) });
354
+ }
355
+
356
+ private async byCursor(request: CursorPageRequest<'amount'>): Promise<Page<InvoiceSummary>> {
357
+ const direction = directionOf(request);
358
+ const query = ownInvoices(this.context, this.scope).orderBy(totalOrder(direction)).limit(request.limit + 1);
359
+ const rows = await (request.cursor === undefined ? query : beyond(query, positionOf(request.cursor), direction));
360
+ const items = rows.slice(0, request.limit);
361
+ const last = items.at(-1);
362
+ const more = rows.length > request.limit && last !== undefined;
363
+
364
+ return pageOf(items.map(toSummary), more ? { nextCursor: encodeCursor({ amount: last.amount, id: last.id }) } : {});
365
+ }
366
+ }
367
+
368
+ function ownInvoices(context: KnexContext, scope: ExecutionScope) {
369
+ return invoicesOf(context).where(tenantRow(tenantScopeOf(scope)));
370
+ }
371
+
372
+ const directionOf = (request: PageRequest<'amount'>) => request.sort[0]?.direction ?? 'asc';
373
+
374
+ // The primary key follows the sort field, in the same direction: the order is total.
375
+ const totalOrder = (direction: SortDirection) => [
376
+ { column: 'amount', order: direction },
377
+ { column: 'id', order: direction },
378
+ ];
379
+
380
+ // The rows after a position, as a tuple comparison: the tie-breaker decides among equals.
381
+ function beyond(query: Knex.QueryBuilder, { amount, id }: { amount: number; id: string }, direction: SortDirection) {
382
+ const past = direction === 'asc' ? '>' : '<';
383
+
384
+ return query.where((after) => {
385
+ void after.where('amount', past, amount).orWhere((tied) => {
386
+ void tied.where('amount', amount).where('id', past, id);
387
+ });
388
+ });
389
+ }
390
+ ```
391
+
392
+ `positionOf(cursor)` decodes with `decodeCursor` and checks that `amount` is a number and `id`
393
+ a string, raising `InvalidPageRequestError` otherwise.
394
+
395
+ A read adapter over the database is declared with `@Repository`, as the ones the CLI
396
+ generates: its kind is `repository`. Declared with `@Adapter` it would be `external`, and a
397
+ generated platform puts a retry and a time limit around every `external` and `gateway`
398
+ adapter, which a statement of the unit of work must not get.
399
+
400
+ The rules these examples hold, and that the tests of this package check on a real database:
401
+
402
+ - **Audit columns** are filled by the adapter, never by the aggregate: `stampCreation(scope,
403
+ clock)` on insert, `stampUpdate(scope, clock)` on update, which leaves `created_at` and
404
+ `created_by` alone. The actor is the one of the execution scope: the user of the request, or
405
+ `system:<consumer>` for a consumed event.
406
+ - **The tenant is part of every statement**: written on insert, and in the condition of every
407
+ read and every update, the version guard included. A row of another tenant is not found, and
408
+ an update aimed at it writes nothing, which `optimisticUpdate` reports as a
409
+ `ConcurrencyError`. Work that names no tenant raises `NoCurrentTenantError` before any SQL.
410
+ - **A list has a total order.** The primary key follows the sort field, in the same direction,
411
+ as a tie-breaker: rows equal on the sort field otherwise have no defined order, and a page
412
+ could repeat or skip one of them. The index covers `(tenant_id, <sort field>, id)`.
413
+ - **A cursor is a position, not a page number**: the sort value and the id of the last row,
414
+ written by `encodeCursor`, compared as a tuple (`amount > :amount OR (amount = :amount AND id
415
+ > :id)`). One row more than `limit` is fetched to know whether a page follows, so no count is
416
+ needed, and a row inserted before the cursor does not shift the next page as it does with an
417
+ offset. A cursor comes back from a client: `decodeCursor` refuses what it did not write, and
418
+ the adapter checks the types of the position before they reach a statement.
419
+ - **An offset page answers `total`**, a cursor page `nextCursor`. Offer the offset form for
420
+ small, browsable lists and the cursor form for anything that grows.
421
+
422
+ ## Criteria
423
+
424
+ `criteriaToKnex(builder, plan, columns)` adds a criteria plan of the kernel to a query: the
425
+ filters and the scope of the actor, the keyset of a cursor page, the total order ended by the
426
+ tie-breaker, the window. `predicateToKnex(builder, predicate, columns)` adds the filters alone,
427
+ for a count. `columns` maps each field of the read model to its column, and is the only source
428
+ of identifiers: every value is a binding, a text search is `like ? escape ?` with `%`, `_` and
429
+ `\` escaped.
430
+
431
+ ```ts
432
+ // infrastructure/persistence/knex-appointment-queries.reader.ts
433
+ async search(criteria: ScopedCriteria<AppointmentRow>): Promise<Page<AppointmentRow>> {
434
+ const plan = planCriteria(appointmentCriteria, criteria);
435
+
436
+ if (plan.empty) {
437
+ return pageOfPlan(plan, []);
438
+ }
439
+
440
+ const rows = await criteriaToKnex(this.context.executor()<AppointmentRow>('appointment_list'), plan, COLUMNS);
441
+
442
+ return pageOfPlan(plan, rows.map(toRow));
443
+ }
444
+ ```
445
+
446
+ A cursor page is fetched one row longer than asked, which is how `pageOfPlan` knows whether
447
+ another follows; an offset page takes its total from a second statement
448
+ (`predicateToKnex(...).count()`). A field declared `nullable` is ordered `nulls last`
449
+ ascending and `nulls first` descending on every dialect.
450
+
451
+ **Strings.** A text column is ordered and compared by range in the collation of the column;
452
+ criteria order strings by code point. Declare the text columns that are sorted or compared
453
+ with `gt`, `lt` or `between` with `COLLATE "C"`: the index then serves the sort, and the
454
+ order is the same as in the fake. **Decimals** are a `numeric` column and a string in the
455
+ row; they read back in the scale of the column. **Instants** are `timestamptz` and a `Date`.
456
+ An integer is `integer`: a `bigint` comes back as a string from the driver.
457
+
458
+ ## Read models
459
+
460
+ ```ts
461
+ // the migration
462
+ await knex.schema.withSchema('scheduling').createTable('appointment_list', (table) => {
463
+ table.text('id').primary();
464
+ table.text('status').notNullable();
465
+ table.datetime('starts_at', { useTz: true, precision: 3 }).notNullable();
466
+ readModelColumns(table); // last_event_id, last_event_at, last_event_version, deleted
467
+ });
468
+
469
+ // infrastructure/persistence/knex-appointment-list.repository.ts
470
+ @Repository(AppointmentList, [KnexContext])
471
+ export class KnexAppointmentList extends KnexReadModelStore<AppointmentRow> implements AppointmentList {
472
+ constructor(context: KnexContext) {
473
+ super(context, {
474
+ schema: 'scheduling',
475
+ table: 'appointment_list',
476
+ columns: { id: 'id', status: 'status', startsAt: 'starts_at' },
477
+ definition: appointmentCriteria,
478
+ });
479
+ }
480
+ }
481
+ ```
482
+
483
+ Every write goes through `TableReadModelStore` of the kernel: a row is written only if its
484
+ event is newer than the one it holds, on the executor of the unit of work, so the row and the
485
+ processed-event mark commit together. A removed row stays in the table with `deleted` set:
486
+ a query of your own on the table leaves those rows out, and so does a unique index
487
+ (`WHERE NOT deleted`). `store.table` is what `ProjectionRebuild` takes; the rebuild is
488
+ PostgreSQL only (a copy `<table>__rebuild` made `LIKE ... INCLUDING ALL`, the keys written
489
+ meanwhile in `<table>__touched`, the swap by rename in one transaction) and its guarantees
490
+ and limits are in the README of `@ddd-ts-kit/kernel` ("Rebuild"). The store passes
491
+ `criteriaContract` and `readModelStoreContract` of `@ddd-ts-kit/testing/contracts`, on PGlite
492
+ and on PostgreSQL 18.
493
+ Criteria and read models were never run on MySQL or SQLite.
494
+
495
+ ## Outbox and idempotency
496
+
497
+ - `KnexOutboxStore.claimBatch` selects the due rows `FOR UPDATE SKIP LOCKED` and leases them in
498
+ the same short transaction by pushing `next_attempt_at` forward (`leaseMs`, default 30 s), so
499
+ several relays share the work without waiting for each other, and the records of a relay
500
+ that died come back when the lease expires. Delivery is at least once, unordered.
501
+ - Where the database has no `SKIP LOCKED` (SQLite), or when you pass
502
+ `claim: 'compare-and-set'`, the claim reads the due rows and takes each one with an update
503
+ guarded by the attempt count it read: same guarantee, no lock clause, one statement per
504
+ record. The default is `skip-locked` on PostgreSQL and MySQL, `compare-and-set` elsewhere.
505
+ - `KnexProcessedEventStore.markProcessed` is an insert ignoring the conflict on
506
+ `(consumer, event_id)`. Run the consumer inside the unit of work: the mark then commits with
507
+ the effects of the handler, and disappears with them if it fails.
508
+
509
+ ## Dialect coverage
510
+
511
+ What follows is about this package. The CLI generates for PostgreSQL only: `init --orm knex`
512
+ refuses `--database mysql` and `--database sqlite`.
513
+
514
+ | Database | Status |
515
+ |---|---|
516
+ | PostgreSQL | Covered by the automated suite: contract suites of `@ddd-ts-kit/testing`, repository contract, rollback, nested unit of work, concurrent claims. It runs on PGlite served over the PostgreSQL wire protocol by default, and on a real server when `DDD_TS_KIT_TEST_DATABASE_URL` is set (last run: PostgreSQL 18). |
517
+ | MySQL 8 | Not in the automated suite. The outbox and processed-event contract suites and the rollback of `append` were run once by hand against MySQL 8.4 with `mysql2`, with `skip-locked` and `compare-and-set`. |
518
+ | SQLite | Not in the automated suite. The same suites were run once by hand with `better-sqlite3`. No `SKIP LOCKED`: the claim is `compare-and-set`. No schemas: `createOutboxTables` refuses `schema`, name the tables per context instead. |
519
+ | MariaDB, CockroachDB, SQL Server, Oracle | Never run. MariaDB stores JSON as text, so the payload would come back as a string. |
520
+
521
+ ## Limits
522
+
523
+ - MySQL `DATETIME` has no time zone and knex converts dates in the time zone of the process:
524
+ give every process the same one, or set `timezone: 'Z'` on the connection.
525
+ - On MySQL 5.7 and MariaDB before 10.6 there is no `SKIP LOCKED`: pass
526
+ `claim: 'compare-and-set'`.
527
+ - The outbox payload is serialized with `JSON.stringify`, so it must be JSON, which the
528
+ integration event contract already requires. An `undefined` payload is stored as SQL `NULL`
529
+ and read back as `null`.
530
+ - Ordering is not guaranteed, not even per aggregate: a failed event is retried later while its
531
+ successors go out.
532
+
533
+ ## Testing an adapter
534
+
535
+ The unit of work of this package passes `unitOfWorkContract`, and its stores
536
+ `outboxStoreContract` and `processedEventStoreContract`, of `@ddd-ts-kit/testing/contracts`. Hold your repositories to `repositoryContract` the same way,
537
+ next to their in-memory fake. The tests of this package need no container:
538
+
539
+ ```
540
+ npx vitest run packages/knex
541
+ DDD_TS_KIT_TEST_DATABASE_URL=postgresql://user:password@localhost:5432/postgres npx vitest run packages/knex
542
+ ```
543
+
544
+ With the variable set, each spec file creates a database of its own on that server and drops
545
+ it afterwards, and the tests that need two real connections (a relay passing over rows locked
546
+ by another one) run instead of being skipped.
@@ -0,0 +1,7 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Adds the four audit columns to a table being created, under the same names in every project:
4
+ * `created_at`, `created_by`, `updated_at`, `updated_by`. Fill them with `auditRow`.
5
+ */
6
+ export declare function auditColumns(table: Knex.CreateTableBuilder): void;
7
+ //# sourceMappingURL=audit-columns.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit-columns.d.ts","sourceRoot":"","sources":["../../src/columns/audit-columns.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAIjC;;;GAGG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,IAAI,CAAC,kBAAkB,GAAG,IAAI,CAKjE"}
@@ -0,0 +1,13 @@
1
+ import { INDEXABLE_LENGTH } from './indexable-length.js';
2
+ import { INSTANT } from './instant.js';
3
+ /**
4
+ * Adds the four audit columns to a table being created, under the same names in every project:
5
+ * `created_at`, `created_by`, `updated_at`, `updated_by`. Fill them with `auditRow`.
6
+ */
7
+ export function auditColumns(table) {
8
+ table.datetime('created_at', INSTANT).notNullable();
9
+ table.string('created_by', INDEXABLE_LENGTH).notNullable();
10
+ table.datetime('updated_at', INSTANT).notNullable();
11
+ table.string('updated_by', INDEXABLE_LENGTH).notNullable();
12
+ }
13
+ //# sourceMappingURL=audit-columns.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit-columns.js","sourceRoot":"","sources":["../../src/columns/audit-columns.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAEvC;;;GAGG;AACH,MAAM,UAAU,YAAY,CAAC,KAA8B;IACzD,KAAK,CAAC,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC,CAAC,WAAW,EAAE,CAAC;IACpD,KAAK,CAAC,MAAM,CAAC,YAAY,EAAE,gBAAgB,CAAC,CAAC,WAAW,EAAE,CAAC;IAC3D,KAAK,CAAC,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC,CAAC,WAAW,EAAE,CAAC;IACpD,KAAK,CAAC,MAAM,CAAC,YAAY,EAAE,gBAAgB,CAAC,CAAC,WAAW,EAAE,CAAC;AAC7D,CAAC"}
@@ -0,0 +1,16 @@
1
+ import type { AuditStamp, UpdateStamp } from '@ddd-ts-kit/kernel';
2
+ /** The audit columns as knex reads and writes them: the keys are the column names. */
3
+ export interface AuditRow {
4
+ created_at: Date;
5
+ created_by: string;
6
+ updated_at: Date;
7
+ updated_by: string;
8
+ }
9
+ export type UpdateAuditRow = Pick<AuditRow, 'updated_at' | 'updated_by'>;
10
+ /**
11
+ * The kernel stamp under the column names: `auditRow(stampCreation(scope, clock))` for an
12
+ * insert, `auditRow(stampUpdate(scope, clock))` for an update, which leaves the creation alone.
13
+ */
14
+ export declare function auditRow(stamp: AuditStamp): AuditRow;
15
+ export declare function auditRow(stamp: UpdateStamp): UpdateAuditRow;
16
+ //# sourceMappingURL=audit-row.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit-row.d.ts","sourceRoot":"","sources":["../../src/columns/audit-row.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAElE,sFAAsF;AACtF,MAAM,WAAW,QAAQ;IACvB,UAAU,EAAE,IAAI,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,IAAI,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,cAAc,GAAG,IAAI,CAAC,QAAQ,EAAE,YAAY,GAAG,YAAY,CAAC,CAAC;AAEzE;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,UAAU,GAAG,QAAQ,CAAC;AACtD,wBAAgB,QAAQ,CAAC,KAAK,EAAE,WAAW,GAAG,cAAc,CAAC"}
@@ -0,0 +1,5 @@
1
+ export function auditRow(stamp) {
2
+ const updated = { updated_at: stamp.updatedAt, updated_by: stamp.updatedBy };
3
+ return 'createdAt' in stamp ? { created_at: stamp.createdAt, created_by: stamp.createdBy, ...updated } : updated;
4
+ }
5
+ //# sourceMappingURL=audit-row.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit-row.js","sourceRoot":"","sources":["../../src/columns/audit-row.ts"],"names":[],"mappings":"AAmBA,MAAM,UAAU,QAAQ,CAAC,KAA+B;IACtD,MAAM,OAAO,GAAmB,EAAE,UAAU,EAAE,KAAK,CAAC,SAAS,EAAE,UAAU,EAAE,KAAK,CAAC,SAAS,EAAE,CAAC;IAE7F,OAAO,WAAW,IAAI,KAAK,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,KAAK,CAAC,SAAS,EAAE,UAAU,EAAE,KAAK,CAAC,SAAS,EAAE,GAAG,OAAO,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC;AACnH,CAAC"}
@@ -0,0 +1,3 @@
1
+ /** The length of key and filtered columns: bounded, because MySQL cannot index unbounded text. */
2
+ export declare const INDEXABLE_LENGTH = 191;
3
+ //# sourceMappingURL=indexable-length.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"indexable-length.d.ts","sourceRoot":"","sources":["../../src/columns/indexable-length.ts"],"names":[],"mappings":"AAAA,kGAAkG;AAClG,eAAO,MAAM,gBAAgB,MAAM,CAAC"}
@@ -0,0 +1,3 @@
1
+ /** The length of key and filtered columns: bounded, because MySQL cannot index unbounded text. */
2
+ export const INDEXABLE_LENGTH = 191;
3
+ //# sourceMappingURL=indexable-length.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"indexable-length.js","sourceRoot":"","sources":["../../src/columns/indexable-length.ts"],"names":[],"mappings":"AAAA,kGAAkG;AAClG,MAAM,CAAC,MAAM,gBAAgB,GAAG,GAAG,CAAC"}
@@ -0,0 +1,6 @@
1
+ /** How every instant is stored: with its time zone, to the millisecond. */
2
+ export declare const INSTANT: {
3
+ readonly useTz: true;
4
+ readonly precision: 3;
5
+ };
6
+ //# sourceMappingURL=instant.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"instant.d.ts","sourceRoot":"","sources":["../../src/columns/instant.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,eAAO,MAAM,OAAO;aAAK,KAAK;aAAQ,SAAS,EAAE,CAAC;CAAW,CAAC"}
@@ -0,0 +1,3 @@
1
+ /** How every instant is stored: with its time zone, to the millisecond. */
2
+ export const INSTANT = { useTz: true, precision: 3 };
3
+ //# sourceMappingURL=instant.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"instant.js","sourceRoot":"","sources":["../../src/columns/instant.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,MAAM,CAAC,MAAM,OAAO,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,EAAW,CAAC"}
@@ -0,0 +1,4 @@
1
+ import type { Knex } from 'knex';
2
+ /** Adds the tenant column, `tenant_id`. Fill it and filter on it with `tenantRow`. */
3
+ export declare function tenantColumn(table: Knex.CreateTableBuilder): void;
4
+ //# sourceMappingURL=tenant-column.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tenant-column.d.ts","sourceRoot":"","sources":["../../src/columns/tenant-column.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAGjC,sFAAsF;AACtF,wBAAgB,YAAY,CAAC,KAAK,EAAE,IAAI,CAAC,kBAAkB,GAAG,IAAI,CAEjE"}
@@ -0,0 +1,6 @@
1
+ import { INDEXABLE_LENGTH } from './indexable-length.js';
2
+ /** Adds the tenant column, `tenant_id`. Fill it and filter on it with `tenantRow`. */
3
+ export function tenantColumn(table) {
4
+ table.string('tenant_id', INDEXABLE_LENGTH).notNullable();
5
+ }
6
+ //# sourceMappingURL=tenant-column.js.map