@ddd-ts-kit/drizzle 0.0.0-stage → 0.1.1
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 +476 -2
- package/dist/postgres/columns/audit-columns.d.ts +13 -0
- package/dist/postgres/columns/audit-columns.d.ts.map +1 -0
- package/dist/postgres/columns/audit-columns.js +17 -0
- package/dist/postgres/columns/audit-columns.js.map +1 -0
- package/dist/postgres/columns/instant.d.ts +3 -0
- package/dist/postgres/columns/instant.d.ts.map +1 -0
- package/dist/postgres/columns/instant.js +6 -0
- package/dist/postgres/columns/instant.js.map +1 -0
- package/dist/postgres/columns/tenant-column.d.ts +5 -0
- package/dist/postgres/columns/tenant-column.d.ts.map +1 -0
- package/dist/postgres/columns/tenant-column.js +6 -0
- package/dist/postgres/columns/tenant-column.js.map +1 -0
- package/dist/postgres/outbox/drizzle-outbox-store.d.ts +39 -0
- package/dist/postgres/outbox/drizzle-outbox-store.d.ts.map +1 -0
- package/dist/postgres/outbox/drizzle-outbox-store.js +139 -0
- package/dist/postgres/outbox/drizzle-outbox-store.js.map +1 -0
- package/dist/postgres/outbox/drizzle-processed-event-store.d.ts +21 -0
- package/dist/postgres/outbox/drizzle-processed-event-store.d.ts.map +1 -0
- package/dist/postgres/outbox/drizzle-processed-event-store.js +24 -0
- package/dist/postgres/outbox/drizzle-processed-event-store.js.map +1 -0
- package/dist/postgres/outbox/outbox-table.d.ts +254 -0
- package/dist/postgres/outbox/outbox-table.d.ts.map +1 -0
- package/dist/postgres/outbox/outbox-table.js +33 -0
- package/dist/postgres/outbox/outbox-table.js.map +1 -0
- package/dist/postgres/outbox/processed-events-table.d.ts +61 -0
- package/dist/postgres/outbox/processed-events-table.d.ts.map +1 -0
- package/dist/postgres/outbox/processed-events-table.js +14 -0
- package/dist/postgres/outbox/processed-events-table.js.map +1 -0
- package/dist/postgres/read-model/column-of.d.ts +4 -0
- package/dist/postgres/read-model/column-of.d.ts.map +1 -0
- package/dist/postgres/read-model/column-of.js +9 -0
- package/dist/postgres/read-model/column-of.js.map +1 -0
- package/dist/postgres/read-model/criteria-to-drizzle.d.ts +21 -0
- package/dist/postgres/read-model/criteria-to-drizzle.d.ts.map +1 -0
- package/dist/postgres/read-model/criteria-to-drizzle.js +26 -0
- package/dist/postgres/read-model/criteria-to-drizzle.js.map +1 -0
- package/dist/postgres/read-model/drizzle-read-model-options.d.ts +10 -0
- package/dist/postgres/read-model/drizzle-read-model-options.d.ts.map +1 -0
- package/dist/postgres/read-model/drizzle-read-model-options.js +2 -0
- package/dist/postgres/read-model/drizzle-read-model-options.js.map +1 -0
- package/dist/postgres/read-model/drizzle-read-model-store.d.ts +18 -0
- package/dist/postgres/read-model/drizzle-read-model-store.d.ts.map +1 -0
- package/dist/postgres/read-model/drizzle-read-model-store.js +13 -0
- package/dist/postgres/read-model/drizzle-read-model-store.js.map +1 -0
- package/dist/postgres/read-model/drizzle-read-model-table.d.ts +35 -0
- package/dist/postgres/read-model/drizzle-read-model-table.d.ts.map +1 -0
- package/dist/postgres/read-model/drizzle-read-model-table.js +92 -0
- package/dist/postgres/read-model/drizzle-read-model-table.js.map +1 -0
- package/dist/postgres/read-model/drizzle-stamped-rows.d.ts +29 -0
- package/dist/postgres/read-model/drizzle-stamped-rows.d.ts.map +1 -0
- package/dist/postgres/read-model/drizzle-stamped-rows.js +79 -0
- package/dist/postgres/read-model/drizzle-stamped-rows.js.map +1 -0
- package/dist/postgres/read-model/like-pattern.d.ts +4 -0
- package/dist/postgres/read-model/like-pattern.d.ts.map +1 -0
- package/dist/postgres/read-model/like-pattern.js +6 -0
- package/dist/postgres/read-model/like-pattern.js.map +1 -0
- package/dist/postgres/read-model/predicate-to-drizzle.d.ts +12 -0
- package/dist/postgres/read-model/predicate-to-drizzle.d.ts.map +1 -0
- package/dist/postgres/read-model/predicate-to-drizzle.js +43 -0
- package/dist/postgres/read-model/predicate-to-drizzle.js.map +1 -0
- package/dist/postgres/read-model/read-model-columns.d.ts +13 -0
- package/dist/postgres/read-model/read-model-columns.d.ts.map +1 -0
- package/dist/postgres/read-model/read-model-columns.js +17 -0
- package/dist/postgres/read-model/read-model-columns.js.map +1 -0
- package/dist/postgres/read-model/row.mapper.d.ts +15 -0
- package/dist/postgres/read-model/row.mapper.d.ts.map +1 -0
- package/dist/postgres/read-model/row.mapper.js +26 -0
- package/dist/postgres/read-model/row.mapper.js.map +1 -0
- package/dist/postgres/read-model/rows-of.d.ts +3 -0
- package/dist/postgres/read-model/rows-of.d.ts.map +1 -0
- package/dist/postgres/read-model/rows-of.js +10 -0
- package/dist/postgres/read-model/rows-of.js.map +1 -0
- package/dist/postgres/read-model/stamp-columns.d.ts +10 -0
- package/dist/postgres/read-model/stamp-columns.d.ts.map +1 -0
- package/dist/postgres/read-model/stamp-columns.js +14 -0
- package/dist/postgres/read-model/stamp-columns.js.map +1 -0
- package/dist/postgres/schema/context-schema.d.ts +4 -0
- package/dist/postgres/schema/context-schema.d.ts.map +1 -0
- package/dist/postgres/schema/context-schema.js +7 -0
- package/dist/postgres/schema/context-schema.js.map +1 -0
- package/dist/postgres/schema/table-in.d.ts +3 -0
- package/dist/postgres/schema/table-in.d.ts.map +1 -0
- package/dist/postgres/schema/table-in.js +5 -0
- package/dist/postgres/schema/table-in.js.map +1 -0
- package/dist/postgres/unit-of-work/create-drizzle-unit-of-work.d.ts +16 -0
- package/dist/postgres/unit-of-work/create-drizzle-unit-of-work.d.ts.map +1 -0
- package/dist/postgres/unit-of-work/create-drizzle-unit-of-work.js +25 -0
- package/dist/postgres/unit-of-work/create-drizzle-unit-of-work.js.map +1 -0
- package/dist/postgres/unit-of-work/drizzle-context.d.ts +19 -0
- package/dist/postgres/unit-of-work/drizzle-context.d.ts.map +1 -0
- package/dist/postgres/unit-of-work/drizzle-context.js +15 -0
- package/dist/postgres/unit-of-work/drizzle-context.js.map +1 -0
- package/dist/postgres.d.ts +17 -0
- package/dist/postgres.d.ts.map +1 -0
- package/dist/postgres.js +17 -0
- package/dist/postgres.js.map +1 -0
- package/package.json +40 -4
package/README.md
CHANGED
|
@@ -1,3 +1,477 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ddd-ts-kit/drizzle
|
|
2
2
|
|
|
3
|
-
|
|
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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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"}
|