@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.
- package/README.md +545 -2
- package/dist/columns/audit-columns.d.ts +7 -0
- package/dist/columns/audit-columns.d.ts.map +1 -0
- package/dist/columns/audit-columns.js +13 -0
- package/dist/columns/audit-columns.js.map +1 -0
- package/dist/columns/audit-row.d.ts +16 -0
- package/dist/columns/audit-row.d.ts.map +1 -0
- package/dist/columns/audit-row.js +5 -0
- package/dist/columns/audit-row.js.map +1 -0
- package/dist/columns/indexable-length.d.ts +3 -0
- package/dist/columns/indexable-length.d.ts.map +1 -0
- package/dist/columns/indexable-length.js +3 -0
- package/dist/columns/indexable-length.js.map +1 -0
- package/dist/columns/instant.d.ts +6 -0
- package/dist/columns/instant.d.ts.map +1 -0
- package/dist/columns/instant.js +3 -0
- package/dist/columns/instant.js.map +1 -0
- package/dist/columns/tenant-column.d.ts +4 -0
- package/dist/columns/tenant-column.d.ts.map +1 -0
- package/dist/columns/tenant-column.js +6 -0
- package/dist/columns/tenant-column.js.map +1 -0
- package/dist/columns/tenant-row.d.ts +7 -0
- package/dist/columns/tenant-row.d.ts.map +1 -0
- package/dist/columns/tenant-row.js +5 -0
- package/dist/columns/tenant-row.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/outbox/builder-in.d.ts +3 -0
- package/dist/outbox/builder-in.d.ts.map +1 -0
- package/dist/outbox/builder-in.js +4 -0
- package/dist/outbox/builder-in.js.map +1 -0
- package/dist/outbox/create-outbox-tables.d.ts +9 -0
- package/dist/outbox/create-outbox-tables.d.ts.map +1 -0
- package/dist/outbox/create-outbox-tables.js +97 -0
- package/dist/outbox/create-outbox-tables.js.map +1 -0
- package/dist/outbox/drop-outbox-tables.d.ts +5 -0
- package/dist/outbox/drop-outbox-tables.d.ts.map +1 -0
- package/dist/outbox/drop-outbox-tables.js +8 -0
- package/dist/outbox/drop-outbox-tables.js.map +1 -0
- package/dist/outbox/knex-outbox-store.d.ts +61 -0
- package/dist/outbox/knex-outbox-store.d.ts.map +1 -0
- package/dist/outbox/knex-outbox-store.js +184 -0
- package/dist/outbox/knex-outbox-store.js.map +1 -0
- package/dist/outbox/knex-processed-event-store.d.ts +21 -0
- package/dist/outbox/knex-processed-event-store.d.ts.map +1 -0
- package/dist/outbox/knex-processed-event-store.js +26 -0
- package/dist/outbox/knex-processed-event-store.js.map +1 -0
- package/dist/outbox/outbox-tables-options.d.ts +12 -0
- package/dist/outbox/outbox-tables-options.d.ts.map +1 -0
- package/dist/outbox/outbox-tables-options.js +2 -0
- package/dist/outbox/outbox-tables-options.js.map +1 -0
- package/dist/outbox/table-names.d.ts +3 -0
- package/dist/outbox/table-names.d.ts.map +1 -0
- package/dist/outbox/table-names.js +3 -0
- package/dist/outbox/table-names.js.map +1 -0
- package/dist/read-model/column-of.d.ts +3 -0
- package/dist/read-model/column-of.d.ts.map +1 -0
- package/dist/read-model/column-of.js +9 -0
- package/dist/read-model/column-of.js.map +1 -0
- package/dist/read-model/criteria-to-knex.d.ts +11 -0
- package/dist/read-model/criteria-to-knex.d.ts.map +1 -0
- package/dist/read-model/criteria-to-knex.js +28 -0
- package/dist/read-model/criteria-to-knex.js.map +1 -0
- package/dist/read-model/knex-read-model-options.d.ts +10 -0
- package/dist/read-model/knex-read-model-options.d.ts.map +1 -0
- package/dist/read-model/knex-read-model-options.js +2 -0
- package/dist/read-model/knex-read-model-options.js.map +1 -0
- package/dist/read-model/knex-read-model-store.d.ts +16 -0
- package/dist/read-model/knex-read-model-store.d.ts.map +1 -0
- package/dist/read-model/knex-read-model-store.js +13 -0
- package/dist/read-model/knex-read-model-store.js.map +1 -0
- package/dist/read-model/knex-read-model-table.d.ts +32 -0
- package/dist/read-model/knex-read-model-table.d.ts.map +1 -0
- package/dist/read-model/knex-read-model-table.js +83 -0
- package/dist/read-model/knex-read-model-table.js.map +1 -0
- package/dist/read-model/knex-stamped-rows.d.ts +19 -0
- package/dist/read-model/knex-stamped-rows.d.ts.map +1 -0
- package/dist/read-model/knex-stamped-rows.js +68 -0
- package/dist/read-model/knex-stamped-rows.js.map +1 -0
- package/dist/read-model/like-pattern.d.ts +4 -0
- package/dist/read-model/like-pattern.d.ts.map +1 -0
- package/dist/read-model/like-pattern.js +6 -0
- package/dist/read-model/like-pattern.js.map +1 -0
- package/dist/read-model/predicate-to-knex.d.ts +11 -0
- package/dist/read-model/predicate-to-knex.d.ts.map +1 -0
- package/dist/read-model/predicate-to-knex.js +63 -0
- package/dist/read-model/predicate-to-knex.js.map +1 -0
- package/dist/read-model/read-model-columns.d.ts +8 -0
- package/dist/read-model/read-model-columns.d.ts.map +1 -0
- package/dist/read-model/read-model-columns.js +14 -0
- package/dist/read-model/read-model-columns.js.map +1 -0
- package/dist/read-model/records-of.d.ts +3 -0
- package/dist/read-model/records-of.d.ts.map +1 -0
- package/dist/read-model/records-of.js +9 -0
- package/dist/read-model/records-of.js.map +1 -0
- package/dist/read-model/row.mapper.d.ts +8 -0
- package/dist/read-model/row.mapper.d.ts.map +1 -0
- package/dist/read-model/row.mapper.js +17 -0
- package/dist/read-model/row.mapper.js.map +1 -0
- package/dist/sql/dialect-of.d.ts +4 -0
- package/dist/sql/dialect-of.d.ts.map +1 -0
- package/dist/sql/dialect-of.js +17 -0
- package/dist/sql/dialect-of.js.map +1 -0
- package/dist/sql/insert-ignoring-conflict.d.ts +16 -0
- package/dist/sql/insert-ignoring-conflict.d.ts.map +1 -0
- package/dist/sql/insert-ignoring-conflict.js +28 -0
- package/dist/sql/insert-ignoring-conflict.js.map +1 -0
- package/dist/sql/sql-dialect.d.ts +3 -0
- package/dist/sql/sql-dialect.d.ts.map +1 -0
- package/dist/sql/sql-dialect.js +2 -0
- package/dist/sql/sql-dialect.js.map +1 -0
- package/dist/unit-of-work/create-knex-unit-of-work.d.ts +14 -0
- package/dist/unit-of-work/create-knex-unit-of-work.d.ts.map +1 -0
- package/dist/unit-of-work/create-knex-unit-of-work.js +24 -0
- package/dist/unit-of-work/create-knex-unit-of-work.js.map +1 -0
- package/dist/unit-of-work/knex-context.d.ts +13 -0
- package/dist/unit-of-work/knex-context.d.ts.map +1 -0
- package/dist/unit-of-work/knex-context.js +10 -0
- package/dist/unit-of-work/knex-context.js.map +1 -0
- package/package.json +41 -4
package/README.md
CHANGED
|
@@ -1,3 +1,546 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ddd-ts-kit/knex
|
|
2
2
|
|
|
3
|
-
|
|
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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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
|