@quilla-be-kit/persistence-init-test 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 +684 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/dao/audit-columns.d.ts +19 -0
- package/dist/dao/audit-columns.d.ts.map +1 -0
- package/dist/dao/audit-columns.js +26 -0
- package/dist/dao/audit-columns.js.map +1 -0
- package/dist/dao/base-read.dao.d.ts +50 -0
- package/dist/dao/base-read.dao.d.ts.map +1 -0
- package/dist/dao/base-read.dao.js +65 -0
- package/dist/dao/base-read.dao.js.map +1 -0
- package/dist/dao/base-write.dao.d.ts +49 -0
- package/dist/dao/base-write.dao.d.ts.map +1 -0
- package/dist/dao/base-write.dao.js +130 -0
- package/dist/dao/base-write.dao.js.map +1 -0
- package/dist/dao/index.d.ts +3 -0
- package/dist/dao/index.d.ts.map +1 -0
- package/dist/dao/index.js +3 -0
- package/dist/dao/index.js.map +1 -0
- package/dist/database/database-health.type.d.ts +4 -0
- package/dist/database/database-health.type.d.ts.map +1 -0
- package/dist/database/database-health.type.js +2 -0
- package/dist/database/database-health.type.js.map +1 -0
- package/dist/database/database-result.type.d.ts +5 -0
- package/dist/database/database-result.type.d.ts.map +1 -0
- package/dist/database/database-result.type.js +2 -0
- package/dist/database/database-result.type.js.map +1 -0
- package/dist/database/database-transaction.interface.d.ts +10 -0
- package/dist/database/database-transaction.interface.d.ts.map +1 -0
- package/dist/database/database-transaction.interface.js +2 -0
- package/dist/database/database-transaction.interface.js.map +1 -0
- package/dist/database/database.interface.d.ts +10 -0
- package/dist/database/database.interface.d.ts.map +1 -0
- package/dist/database/database.interface.js +2 -0
- package/dist/database/database.interface.js.map +1 -0
- package/dist/database/index.d.ts +5 -0
- package/dist/database/index.d.ts.map +1 -0
- package/dist/database/index.js +2 -0
- package/dist/database/index.js.map +1 -0
- package/dist/db-adapter/filter-query.type.d.ts +4 -0
- package/dist/db-adapter/filter-query.type.d.ts.map +1 -0
- package/dist/db-adapter/filter-query.type.js +2 -0
- package/dist/db-adapter/filter-query.type.js.map +1 -0
- package/dist/db-adapter/index.d.ts +4 -0
- package/dist/db-adapter/index.d.ts.map +1 -0
- package/dist/db-adapter/index.js +2 -0
- package/dist/db-adapter/index.js.map +1 -0
- package/dist/db-adapter/read-db-adapter.interface.d.ts +37 -0
- package/dist/db-adapter/read-db-adapter.interface.d.ts.map +1 -0
- package/dist/db-adapter/read-db-adapter.interface.js +2 -0
- package/dist/db-adapter/read-db-adapter.interface.js.map +1 -0
- package/dist/db-adapter/write-db-adapter.interface.d.ts +61 -0
- package/dist/db-adapter/write-db-adapter.interface.d.ts.map +1 -0
- package/dist/db-adapter/write-db-adapter.interface.js +2 -0
- package/dist/db-adapter/write-db-adapter.interface.js.map +1 -0
- package/dist/errors/cross-scope-access.error.d.ts +10 -0
- package/dist/errors/cross-scope-access.error.d.ts.map +1 -0
- package/dist/errors/cross-scope-access.error.js +15 -0
- package/dist/errors/cross-scope-access.error.js.map +1 -0
- package/dist/errors/index.d.ts +3 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +3 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/errors/optimistic-lock.error.d.ts +9 -0
- package/dist/errors/optimistic-lock.error.d.ts.map +1 -0
- package/dist/errors/optimistic-lock.error.js +11 -0
- package/dist/errors/optimistic-lock.error.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/postgres/index.d.ts +7 -0
- package/dist/postgres/index.d.ts.map +1 -0
- package/dist/postgres/index.js +7 -0
- package/dist/postgres/index.js.map +1 -0
- package/dist/postgres/pg-query-builder.d.ts +42 -0
- package/dist/postgres/pg-query-builder.d.ts.map +1 -0
- package/dist/postgres/pg-query-builder.js +260 -0
- package/dist/postgres/pg-query-builder.js.map +1 -0
- package/dist/postgres/pg-read-db-adapter.d.ts +20 -0
- package/dist/postgres/pg-read-db-adapter.d.ts.map +1 -0
- package/dist/postgres/pg-read-db-adapter.js +28 -0
- package/dist/postgres/pg-read-db-adapter.js.map +1 -0
- package/dist/postgres/pg-sql.d.ts +44 -0
- package/dist/postgres/pg-sql.d.ts.map +1 -0
- package/dist/postgres/pg-sql.js +128 -0
- package/dist/postgres/pg-sql.js.map +1 -0
- package/dist/postgres/pg-write-db-adapter.d.ts +30 -0
- package/dist/postgres/pg-write-db-adapter.d.ts.map +1 -0
- package/dist/postgres/pg-write-db-adapter.js +133 -0
- package/dist/postgres/pg-write-db-adapter.js.map +1 -0
- package/dist/postgres/pg.database.d.ts +25 -0
- package/dist/postgres/pg.database.d.ts.map +1 -0
- package/dist/postgres/pg.database.js +48 -0
- package/dist/postgres/pg.database.js.map +1 -0
- package/dist/postgres/pg.transaction.d.ts +22 -0
- package/dist/postgres/pg.transaction.d.ts.map +1 -0
- package/dist/postgres/pg.transaction.js +78 -0
- package/dist/postgres/pg.transaction.js.map +1 -0
- package/dist/query/case.d.ts +3 -0
- package/dist/query/case.d.ts.map +1 -0
- package/dist/query/case.js +7 -0
- package/dist/query/case.js.map +1 -0
- package/dist/query/column-resolver.interface.d.ts +4 -0
- package/dist/query/column-resolver.interface.d.ts.map +1 -0
- package/dist/query/column-resolver.interface.js +2 -0
- package/dist/query/column-resolver.interface.js.map +1 -0
- package/dist/query/default.resolver.d.ts +10 -0
- package/dist/query/default.resolver.d.ts.map +1 -0
- package/dist/query/default.resolver.js +14 -0
- package/dist/query/default.resolver.js.map +1 -0
- package/dist/query/field-descriptor.type.d.ts +11 -0
- package/dist/query/field-descriptor.type.d.ts.map +1 -0
- package/dist/query/field-descriptor.type.js +20 -0
- package/dist/query/field-descriptor.type.js.map +1 -0
- package/dist/query/filter-query.type.d.ts +4 -0
- package/dist/query/filter-query.type.d.ts.map +1 -0
- package/dist/query/filter-query.type.js +2 -0
- package/dist/query/filter-query.type.js.map +1 -0
- package/dist/query/index.d.ts +9 -0
- package/dist/query/index.d.ts.map +1 -0
- package/dist/query/index.js +3 -0
- package/dist/query/index.js.map +1 -0
- package/dist/query/list-query.type.d.ts +12 -0
- package/dist/query/list-query.type.d.ts.map +1 -0
- package/dist/query/list-query.type.js +2 -0
- package/dist/query/list-query.type.js.map +1 -0
- package/dist/query/paginated-result.type.d.ts +7 -0
- package/dist/query/paginated-result.type.d.ts.map +1 -0
- package/dist/query/paginated-result.type.js +2 -0
- package/dist/query/paginated-result.type.js.map +1 -0
- package/dist/query/query-product.type.d.ts +6 -0
- package/dist/query/query-product.type.d.ts.map +1 -0
- package/dist/query/query-product.type.js +2 -0
- package/dist/query/query-product.type.js.map +1 -0
- package/dist/query/read-query-builder.interface.d.ts +17 -0
- package/dist/query/read-query-builder.interface.d.ts.map +1 -0
- package/dist/query/read-query-builder.interface.js +2 -0
- package/dist/query/read-query-builder.interface.js.map +1 -0
- package/dist/query/sql-query-builder.interface.d.ts +99 -0
- package/dist/query/sql-query-builder.interface.d.ts.map +1 -0
- package/dist/query/sql-query-builder.interface.js +2 -0
- package/dist/query/sql-query-builder.interface.js.map +1 -0
- package/dist/query/sql-statement.type.d.ts +5 -0
- package/dist/query/sql-statement.type.d.ts.map +1 -0
- package/dist/query/sql-statement.type.js +2 -0
- package/dist/query/sql-statement.type.js.map +1 -0
- package/dist/query/write-query-builder.interface.d.ts +34 -0
- package/dist/query/write-query-builder.interface.d.ts.map +1 -0
- package/dist/query/write-query-builder.interface.js +2 -0
- package/dist/query/write-query-builder.interface.js.map +1 -0
- package/dist/query-schema/field-descriptor-from-zod.d.ts +4 -0
- package/dist/query-schema/field-descriptor-from-zod.d.ts.map +1 -0
- package/dist/query-schema/field-descriptor-from-zod.js +31 -0
- package/dist/query-schema/field-descriptor-from-zod.js.map +1 -0
- package/dist/query-schema/index.d.ts +3 -0
- package/dist/query-schema/index.d.ts.map +1 -0
- package/dist/query-schema/index.js +3 -0
- package/dist/query-schema/index.js.map +1 -0
- package/dist/query-schema/zod.d.ts +73 -0
- package/dist/query-schema/zod.d.ts.map +1 -0
- package/dist/query-schema/zod.js +191 -0
- package/dist/query-schema/zod.js.map +1 -0
- package/dist/repository/base-aggregate.repository.d.ts +28 -0
- package/dist/repository/base-aggregate.repository.d.ts.map +1 -0
- package/dist/repository/base-aggregate.repository.js +49 -0
- package/dist/repository/base-aggregate.repository.js.map +1 -0
- package/dist/repository/base-basic.repository.d.ts +22 -0
- package/dist/repository/base-basic.repository.d.ts.map +1 -0
- package/dist/repository/base-basic.repository.js +30 -0
- package/dist/repository/base-basic.repository.js.map +1 -0
- package/dist/repository/base-persistence.mapper.d.ts +61 -0
- package/dist/repository/base-persistence.mapper.d.ts.map +1 -0
- package/dist/repository/base-persistence.mapper.js +119 -0
- package/dist/repository/base-persistence.mapper.js.map +1 -0
- package/dist/repository/base-scoped-aggregate.repository.d.ts +19 -0
- package/dist/repository/base-scoped-aggregate.repository.d.ts.map +1 -0
- package/dist/repository/base-scoped-aggregate.repository.js +35 -0
- package/dist/repository/base-scoped-aggregate.repository.js.map +1 -0
- package/dist/repository/base-unscoped-aggregate.repository.d.ts +17 -0
- package/dist/repository/base-unscoped-aggregate.repository.d.ts.map +1 -0
- package/dist/repository/base-unscoped-aggregate.repository.js +21 -0
- package/dist/repository/base-unscoped-aggregate.repository.js.map +1 -0
- package/dist/repository/index.d.ts +7 -0
- package/dist/repository/index.d.ts.map +1 -0
- package/dist/repository/index.js +6 -0
- package/dist/repository/index.js.map +1 -0
- package/dist/repository/mapper.interface.d.ts +5 -0
- package/dist/repository/mapper.interface.d.ts.map +1 -0
- package/dist/repository/mapper.interface.js +2 -0
- package/dist/repository/mapper.interface.js.map +1 -0
- package/dist/unit-of-work/event-sink.interface.d.ts +5 -0
- package/dist/unit-of-work/event-sink.interface.d.ts.map +1 -0
- package/dist/unit-of-work/event-sink.interface.js +2 -0
- package/dist/unit-of-work/event-sink.interface.js.map +1 -0
- package/dist/unit-of-work/index.d.ts +4 -0
- package/dist/unit-of-work/index.d.ts.map +1 -0
- package/dist/unit-of-work/index.js +2 -0
- package/dist/unit-of-work/index.js.map +1 -0
- package/dist/unit-of-work/outbox-writer.interface.d.ts +14 -0
- package/dist/unit-of-work/outbox-writer.interface.d.ts.map +1 -0
- package/dist/unit-of-work/outbox-writer.interface.js +2 -0
- package/dist/unit-of-work/outbox-writer.interface.js.map +1 -0
- package/dist/unit-of-work/unit-of-work-context.type.d.ts +8 -0
- package/dist/unit-of-work/unit-of-work-context.type.d.ts.map +1 -0
- package/dist/unit-of-work/unit-of-work-context.type.js +2 -0
- package/dist/unit-of-work/unit-of-work-context.type.js.map +1 -0
- package/dist/unit-of-work/unit-of-work.d.ts +20 -0
- package/dist/unit-of-work/unit-of-work.d.ts.map +1 -0
- package/dist/unit-of-work/unit-of-work.js +60 -0
- package/dist/unit-of-work/unit-of-work.js.map +1 -0
- package/package.json +83 -0
package/README.md
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
# @quilla-be-kit/persistence
|
|
2
|
+
|
|
3
|
+
Persistence primitives: `Database` abstraction, `ReadDbAdapter` /
|
|
4
|
+
`WriteDbAdapter` for CQRS-isolated dialect adapters, DAOs with audit
|
|
5
|
+
injection and optimistic locking, aggregate repositories with scope
|
|
6
|
+
isolation, and a `UnitOfWork` with pluggable outbox.
|
|
7
|
+
|
|
8
|
+
Ships a **Postgres reference implementation** under
|
|
9
|
+
`@quilla-be-kit/persistence/postgres` — `PgDatabase`, `PgTransaction`,
|
|
10
|
+
`PgWriteDbAdapter` (with info-schema cache + JSONB / UUID / timestamp
|
|
11
|
+
casts), `PgReadDbAdapter`. Works with the `pg` package (optional peer dep).
|
|
12
|
+
|
|
13
|
+
Peer deps: `@quilla-be-kit/ddd`, `@quilla-be-kit/execution-context`,
|
|
14
|
+
`@quilla-be-kit/errors`. `pg` is an optional peer — required only if you
|
|
15
|
+
import from `/postgres`.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
# Core:
|
|
21
|
+
pnpm add @quilla-be-kit/persistence @quilla-be-kit/ddd @quilla-be-kit/execution-context @quilla-be-kit/errors
|
|
22
|
+
|
|
23
|
+
# Plus Postgres adapter:
|
|
24
|
+
pnpm add pg
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Architectural invariants
|
|
28
|
+
|
|
29
|
+
- **Scope isolation is repo-layer explicit.** `BaseScopedAggregateRepository`
|
|
30
|
+
takes `scopeId` on every load and throws `CrossScopeAccessError` on miss
|
|
31
|
+
or mismatch. DAOs never inject `scope_id` implicitly.
|
|
32
|
+
- **Audit fields are DAO-layer implicit.** `inserted_by` and `updated_by`
|
|
33
|
+
resolve from `ExecutionContextProvider.getContext().session?.userId` on
|
|
34
|
+
every write. Callers cannot pass them; rows with audit fields in them
|
|
35
|
+
get stripped. Writes under system contexts (no session) persist
|
|
36
|
+
`null` audit.
|
|
37
|
+
- **Optimistic locking is opt-in via `updated_at`.** Include `updated_at`
|
|
38
|
+
in the row passed to `update()` and the DAO asserts `rowCount === 1` —
|
|
39
|
+
mismatch throws `OptimisticLockError`. Omit to update unconditionally.
|
|
40
|
+
- **Outbox is orthogonal, not built-in.** Wire an `OutboxWriter` on
|
|
41
|
+
`UnitOfWork` to drain aggregate events + registered integration events
|
|
42
|
+
in the same transaction. Omit for apps that don't use outbox.
|
|
43
|
+
- **Nested `transaction()` calls JOIN** — inner call sees the outer
|
|
44
|
+
`UnitOfWorkContext` (via AsyncLocalStorage) and reuses the trx.
|
|
45
|
+
- **CQRS isolation at the type level.** `ReadDbAdapter` exposes only
|
|
46
|
+
`select`; `WriteDbAdapter` exposes `insert`/`update`/`delete`/`find`/
|
|
47
|
+
`findForUpdate`/`exists`. A single physical class can implement both
|
|
48
|
+
interfaces for wiring convenience, but DAO-facing types enforce the
|
|
49
|
+
boundary. **Read DAOs never accept a `trx` parameter** — reads don't
|
|
50
|
+
participate in write transactions.
|
|
51
|
+
- **Pre-create uniqueness checks use the write side's unlocked reads**
|
|
52
|
+
(`findOne` / `existsBy` on `BaseWriteDao`), not the read side. Locked
|
|
53
|
+
reads (`findOneForUpdate`) are for read-before-update only.
|
|
54
|
+
|
|
55
|
+
## Vocabulary
|
|
56
|
+
|
|
57
|
+
- **DAO** layer — verb `find*`. Returns raw `TRow` from the database.
|
|
58
|
+
- **Repository** layer — verb `load*`. Returns `TAggregate` mapped from
|
|
59
|
+
rows. The verb split marks the layer.
|
|
60
|
+
|
|
61
|
+
## Minimal usage (with Postgres)
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import {
|
|
65
|
+
BasePersistenceMapper,
|
|
66
|
+
BaseScopedAggregateRepository,
|
|
67
|
+
BaseWriteDao,
|
|
68
|
+
UnitOfWork,
|
|
69
|
+
} from '@quilla-be-kit/persistence';
|
|
70
|
+
import {
|
|
71
|
+
PgDatabase,
|
|
72
|
+
PgReadDbAdapter,
|
|
73
|
+
PgWriteDbAdapter,
|
|
74
|
+
} from '@quilla-be-kit/persistence/postgres';
|
|
75
|
+
|
|
76
|
+
// 1. Write DAO for your row shape:
|
|
77
|
+
class UserDao extends BaseWriteDao<UserRow> {
|
|
78
|
+
protected readonly tableName = 'users';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 2. Mapper — extend BasePersistenceMapper for automatic column conversion:
|
|
82
|
+
class UserMapper extends BasePersistenceMapper<User, UserProps, UserRow> {
|
|
83
|
+
protected createDomain(props: UserProps, id: string) {
|
|
84
|
+
return User.reconstitute(props, id);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 3. Scoped repository:
|
|
89
|
+
class UserRepository extends BaseScopedAggregateRepository<User, UserRow> {}
|
|
90
|
+
|
|
91
|
+
// 4. Wire at composition root:
|
|
92
|
+
const db = new PgDatabase({ host, database, user, password });
|
|
93
|
+
// Or, to share the pool with PgLocalOutbox / PgEventBus — see "Sharing a Pool" below.
|
|
94
|
+
const writeAdapter = new PgWriteDbAdapter(db);
|
|
95
|
+
const readAdapter = new PgReadDbAdapter(db);
|
|
96
|
+
const uow = new UnitOfWork({ db, outboxWriter });
|
|
97
|
+
|
|
98
|
+
const userDao = new UserDao(writeAdapter, contextProvider);
|
|
99
|
+
const userRepo = new UserRepository(new UserMapper(), userDao);
|
|
100
|
+
|
|
101
|
+
// 5. Use:
|
|
102
|
+
await uow.transaction(async (ctx) => {
|
|
103
|
+
const user = await userRepo.loadForUpdateByIdAndScopeOrFail(
|
|
104
|
+
userId, scopeId, ctx,
|
|
105
|
+
);
|
|
106
|
+
user.changeName('Alice');
|
|
107
|
+
await userRepo.update(user, ctx);
|
|
108
|
+
// Domain events drained to outbox, trx committed atomically.
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Pre-create uniqueness check — unlocked read on the write side:
|
|
112
|
+
await uow.transaction(async (ctx) => {
|
|
113
|
+
if (await userDao.existsBy({ email: input.email }, ctx.trx)) {
|
|
114
|
+
throw new DuplicateEmailError({ email: input.email });
|
|
115
|
+
}
|
|
116
|
+
await userRepo.create(newUser, ctx);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Read-side projections — one DAO exposes many query methods, each
|
|
120
|
+
// building its own SQL via `this.qb<T>()`. See "Read-side queries" below
|
|
121
|
+
// for the full flow (builder, column resolver, HTTP query schema).
|
|
122
|
+
class UserReadDao extends BaseReadDao {
|
|
123
|
+
listActive(scopeId: string) {
|
|
124
|
+
const q = this.qb<UserListRow>()
|
|
125
|
+
.select(['id', 'name', 'createdAt'])
|
|
126
|
+
.from('users')
|
|
127
|
+
.filters({ scopeId, isActive: true })
|
|
128
|
+
.orderBy([{ createdAt: 'desc' }])
|
|
129
|
+
.build();
|
|
130
|
+
return this.findMany<UserListRow>(q);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const userReadDao = new UserReadDao({
|
|
134
|
+
adapter: readAdapter,
|
|
135
|
+
builderFactory: (r) => new PgSqlQueryBuilder(r),
|
|
136
|
+
});
|
|
137
|
+
const rows = await userReadDao.listActive(scopeId); // no ctx, no trx
|
|
138
|
+
|
|
139
|
+
// Pool lifecycle: register with @quilla-be-kit/runtime:
|
|
140
|
+
shutdown.addPhase({
|
|
141
|
+
name: 'database',
|
|
142
|
+
participants: [{ name: 'PgDatabase', dispose: () => db.disconnect() }],
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Sharing a Pool
|
|
147
|
+
|
|
148
|
+
`PgDatabase` accepts either a `PoolConfig` (the adapter creates and owns
|
|
149
|
+
the pool) or `{ pool }` (the caller owns it — use this when the same
|
|
150
|
+
physical Postgres connection pool needs to back other adapters like
|
|
151
|
+
`PgLocalOutbox` / `PgEventBus` from `@quilla-be-kit/messaging`):
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
import { Pool } from 'pg';
|
|
155
|
+
import { PgDatabase } from '@quilla-be-kit/persistence/postgres';
|
|
156
|
+
import { PgLocalOutbox, PgEventBus } from '@quilla-be-kit/messaging/postgres';
|
|
157
|
+
|
|
158
|
+
// Adapter-owned pool — disconnect() ends it:
|
|
159
|
+
const db = new PgDatabase({ connectionString: process.env.DATABASE_URL });
|
|
160
|
+
|
|
161
|
+
// Caller-owned pool — shared with the messaging adapters:
|
|
162
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
163
|
+
const db = new PgDatabase({ pool });
|
|
164
|
+
const outbox = new PgLocalOutbox({ pool });
|
|
165
|
+
const bus = new PgEventBus({ pool });
|
|
166
|
+
|
|
167
|
+
// When the pool is caller-owned, db.disconnect() is a no-op.
|
|
168
|
+
// Register pool.end() on your ShutdownManager yourself:
|
|
169
|
+
shutdown.addPhase({
|
|
170
|
+
name: 'database',
|
|
171
|
+
participants: [{ name: 'pg.Pool', dispose: () => pool.end() }],
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Handling optimistic lock conflicts
|
|
176
|
+
|
|
177
|
+
When an update includes `updated_at` in the input row, the DAO asserts
|
|
178
|
+
`rowCount === 1` and throws `OptimisticLockError` (extends `ConflictError`
|
|
179
|
+
from `@quilla-be-kit/errors`) on a mismatch. Catch it at the command handler
|
|
180
|
+
boundary and retry or surface a 409 to the client:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
import { OptimisticLockError } from '@quilla-be-kit/persistence';
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
await uow.transaction(async (ctx) => {
|
|
187
|
+
const user = await userRepo.loadForUpdateByIdOrFail(userId, ctx);
|
|
188
|
+
user.changeName(newName);
|
|
189
|
+
await userRepo.update(user, ctx);
|
|
190
|
+
});
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (err instanceof OptimisticLockError) {
|
|
193
|
+
// someone else updated this row — retry, or surface 409 CONFLICT
|
|
194
|
+
}
|
|
195
|
+
throw err;
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Mappers — row ↔ aggregate conversion
|
|
200
|
+
|
|
201
|
+
`BasePersistenceMapper` handles the bidirectional row↔aggregate conversion
|
|
202
|
+
with **no explicit column list**. It uses prototype reflection on the
|
|
203
|
+
aggregate to discover persisted properties, then converts names between
|
|
204
|
+
`camelCase` (domain) and `snake_case` (DB) automatically.
|
|
205
|
+
|
|
206
|
+
### The contract your aggregate must follow
|
|
207
|
+
|
|
208
|
+
For a domain property to be persisted, the aggregate must expose both a
|
|
209
|
+
getter and a setter for it on the class prototype:
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
class Tenant extends AggregateRoot<TenantProps> {
|
|
213
|
+
// Persisted — private setter + public getter pair:
|
|
214
|
+
private set name(v: string) { this.props.name = v; }
|
|
215
|
+
get name(): string { return this.props.name; }
|
|
216
|
+
|
|
217
|
+
private set country(v: string) { this.props.country = v; }
|
|
218
|
+
get country(): string { return this.props.country; }
|
|
219
|
+
|
|
220
|
+
// Computed — getter only; mapper ignores it:
|
|
221
|
+
get displayName(): string { return `${this.name} (${this.country})`; }
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Why setters? The mapper distinguishes **persisted** properties from
|
|
226
|
+
**computed** ones by checking whether a setter exists. `private` is the
|
|
227
|
+
right visibility — only the aggregate itself should mutate state; external
|
|
228
|
+
callers use intention-revealing methods (`tenant.changeName('NewName')`)
|
|
229
|
+
that internally assign `this.name = 'NewName'`, which routes through the
|
|
230
|
+
private setter.
|
|
231
|
+
|
|
232
|
+
Setters are also where **structural invariants** live (non-empty strings,
|
|
233
|
+
non-null required fields) — guards that must hold after rehydration from
|
|
234
|
+
the DB, not just after a command. See
|
|
235
|
+
[mutation patterns in `@quilla-be-kit/ddd`](../ddd/README.md#mutation-patterns)
|
|
236
|
+
for the full command-side idiom (`updateFromInput`, `changeX`, domain
|
|
237
|
+
methods) and how structural vs. business invariants split between setters
|
|
238
|
+
and mutation methods.
|
|
239
|
+
|
|
240
|
+
### Mapper — minimum boilerplate case (pure snake_case)
|
|
241
|
+
|
|
242
|
+
When every domain property name maps cleanly to its snake_case column
|
|
243
|
+
(e.g. `adminEmail` ↔ `admin_email`), the mapper is just one method:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
import { BasePersistenceMapper } from '@quilla-be-kit/persistence';
|
|
247
|
+
|
|
248
|
+
class TenantMapper extends BasePersistenceMapper<Tenant, TenantProps, TenantRow> {
|
|
249
|
+
protected createDomain(props: TenantProps, id: string): Tenant {
|
|
250
|
+
return Tenant.reconstitute(props, id);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
That's it. The base iterates every `get`+`set` accessor pair on `Tenant`'s
|
|
256
|
+
prototype chain (including inherited `createdAt` / `updatedAt` /
|
|
257
|
+
`insertedBy` / `updatedBy` from `Entity`), converts each name to
|
|
258
|
+
snake_case, and reads values via the getter.
|
|
259
|
+
|
|
260
|
+
`createDomain` calls `Tenant.reconstitute(props, id)` — the rehydration
|
|
261
|
+
factory that skips validation and emits no domain events (contrasted
|
|
262
|
+
with `Tenant.create(...)`, the new-aggregate factory). See
|
|
263
|
+
[construction patterns in `@quilla-be-kit/ddd`](../ddd/README.md#construction-patterns)
|
|
264
|
+
for why these are two separate factories and what each is responsible for.
|
|
265
|
+
|
|
266
|
+
### Mapper — with overrides + value-object serialization (User)
|
|
267
|
+
|
|
268
|
+
When some columns don't follow the convention, and some properties wrap
|
|
269
|
+
value objects that need scalar serialization:
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
class UserMapper extends BasePersistenceMapper<User, UserProps, UserRow> {
|
|
273
|
+
// Only declare the odd-ones-out:
|
|
274
|
+
protected readonly columnOverrides = {
|
|
275
|
+
password: 'password_hash',
|
|
276
|
+
resetPasswordTokenExpiresAt: 'reset_password_token_expiration',
|
|
277
|
+
} as const;
|
|
278
|
+
|
|
279
|
+
protected createDomain(props: UserProps, id: string): User {
|
|
280
|
+
return User.reconstitute({
|
|
281
|
+
...props,
|
|
282
|
+
// Wrap scalar → value object on the way in:
|
|
283
|
+
password: Password.fromHashedValue(props.password as string),
|
|
284
|
+
userType: UserTypeFactory.create(props.userType as string),
|
|
285
|
+
}, id);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Serialize value objects on the way out. Merged OVER the default
|
|
289
|
+
// prototype-reflected row (last-write-wins):
|
|
290
|
+
protected createPersistence(user: User): Partial<UserRow> {
|
|
291
|
+
return {
|
|
292
|
+
password_hash: user.password.getHashedValue(),
|
|
293
|
+
user_type: user.userType.toString(),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### What `BasePersistenceMapper` handles for you
|
|
300
|
+
|
|
301
|
+
- **`id`** — read from `aggregate.id`, written to the `id` column.
|
|
302
|
+
- **`createdAt` / `updatedAt` / `insertedBy` / `updatedBy`** — inherited
|
|
303
|
+
accessors on `Entity` are auto-discovered and mapped to their snake_case
|
|
304
|
+
columns. You never declare them.
|
|
305
|
+
- **Every persisted domain property** — discovered via prototype reflection
|
|
306
|
+
(any accessor with both `get` and `set`).
|
|
307
|
+
- **Column name resolution** — `columnOverrides[domainKey]` wins; otherwise
|
|
308
|
+
defaults to `camelToSnake(domainKey)`.
|
|
309
|
+
- **Reverse lookup** on `toDomain` — inverts `columnOverrides` first, then
|
|
310
|
+
falls back to `snakeToCamel(column)`.
|
|
311
|
+
|
|
312
|
+
### What `BasePersistenceMapper` does *not* do
|
|
313
|
+
|
|
314
|
+
- **Value object serialization** — a `Password` in `props` stays a
|
|
315
|
+
`Password` on the output row unless `createPersistence` overrides the
|
|
316
|
+
specific column with its scalar form.
|
|
317
|
+
- **Skip computed getters** — getters without setters are excluded
|
|
318
|
+
automatically; no explicit opt-out needed.
|
|
319
|
+
|
|
320
|
+
### Caveats to be aware of
|
|
321
|
+
|
|
322
|
+
- Relies on **class prototype** accessors, not instance own-properties.
|
|
323
|
+
Fine for idiomatic TypeScript class syntax (`get name() { ... }`).
|
|
324
|
+
- **Minification** can rename getter/setter identifiers in the consumer's
|
|
325
|
+
backend build, which would break reflection. Standard Node.js backends
|
|
326
|
+
don't minify TypeScript output — a non-issue unless you're deliberately
|
|
327
|
+
minifying your class names. If that's your pipeline, implement
|
|
328
|
+
`PersistenceMapper` directly instead of extending `BasePersistenceMapper`.
|
|
329
|
+
|
|
330
|
+
## Read-side queries
|
|
331
|
+
|
|
332
|
+
The read side is **projection-driven, not table-driven**. A single read DAO exposes as many query methods as the module needs, each building its own SQL — possibly over different tables, joins, aggregates, or views. There is no one-table-per-DAO rule.
|
|
333
|
+
|
|
334
|
+
Three pieces cooperate:
|
|
335
|
+
|
|
336
|
+
| Piece | Role |
|
|
337
|
+
| --- | --- |
|
|
338
|
+
| `SqlQueryBuilder<T>` | Fluent SQL builder. `.select / .from / .join / .groupBy / .where / .filters / .orderBy / .paginate / .build`. Immutable — each fluent call returns a new instance. |
|
|
339
|
+
| `ColumnResolver` | Translates domain vocabulary (camelCase, `scopeId`) to DB columns (snake_case, `tenant_id`) at build time. |
|
|
340
|
+
| `BaseReadDao` | Owns the resolver + builder factory. Exposes `qb<T>()`, `findOne<T>(q)`, `findMany<T>(q)`, `findPaginated<T>(q, page)`. |
|
|
341
|
+
|
|
342
|
+
And one optional add-on for HTTP controllers:
|
|
343
|
+
|
|
344
|
+
| Piece | Role |
|
|
345
|
+
| --- | --- |
|
|
346
|
+
| `createQueryParametersSchema` (`/query-schema`) | Zod-based: takes a base filter shape, generates a full validation + transform schema that parses `?name__contains=foo&createdAt__gte=...&sort=...&page=2` into a typed `StandardListQuery<TFilters>`. Opt-in sub-path with `zod` as optional peer. |
|
|
347
|
+
|
|
348
|
+
### A read DAO with two query methods
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
import { BaseReadDao, type PaginatedResult, type StandardListQuery } from '@quilla-be-kit/persistence';
|
|
352
|
+
import { PgSqlQueryBuilder } from '@quilla-be-kit/persistence/postgres';
|
|
353
|
+
|
|
354
|
+
type RoleDetailsReadModel = {
|
|
355
|
+
id: string;
|
|
356
|
+
name: string;
|
|
357
|
+
description: string | null;
|
|
358
|
+
permissions: readonly string[];
|
|
359
|
+
isActive: boolean;
|
|
360
|
+
createdAt: Date;
|
|
361
|
+
updatedAt: Date;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
type RoleListReadModel = Pick<RoleDetailsReadModel, 'id' | 'name' | 'isActive' | 'createdAt'>;
|
|
365
|
+
|
|
366
|
+
type RoleListQuery = StandardListQuery<{
|
|
367
|
+
name?: string;
|
|
368
|
+
isActive?: boolean;
|
|
369
|
+
createdAt?: Date;
|
|
370
|
+
}>;
|
|
371
|
+
|
|
372
|
+
export class RoleReadDao extends BaseReadDao {
|
|
373
|
+
getDetails(id: string, scopeId: string): Promise<RoleDetailsReadModel | null> {
|
|
374
|
+
const q = this.qb<RoleDetailsReadModel>()
|
|
375
|
+
.select(['id', 'name', 'description', 'permissions', 'isActive', 'createdAt', 'updatedAt'])
|
|
376
|
+
.from('iam_roles')
|
|
377
|
+
.filters({ id, scopeId })
|
|
378
|
+
.build();
|
|
379
|
+
return this.findOne<RoleDetailsReadModel>(q);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
listPage(query: RoleListQuery, scopeId: string): Promise<PaginatedResult<RoleListReadModel>> {
|
|
383
|
+
const page = query.pagination ?? { page: 1, pageSize: 20 };
|
|
384
|
+
const q = this.qb<RoleListReadModel>()
|
|
385
|
+
.select(['id', 'name', 'isActive', 'createdAt'])
|
|
386
|
+
.from('iam_roles')
|
|
387
|
+
.filters({ ...query.filters, scopeId })
|
|
388
|
+
.orderBy(query.sort, {
|
|
389
|
+
enforced: [{ scopeId: 'asc' }],
|
|
390
|
+
defaults: [{ createdAt: 'desc' }],
|
|
391
|
+
})
|
|
392
|
+
.paginate(page)
|
|
393
|
+
.build();
|
|
394
|
+
return this.findPaginated<RoleListReadModel>(q, page);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Wire it at the composition root:
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
import { PgReadDbAdapter, PgSqlQueryBuilder } from '@quilla-be-kit/persistence/postgres';
|
|
403
|
+
|
|
404
|
+
const roleReadDao = new RoleReadDao({
|
|
405
|
+
adapter: new PgReadDbAdapter(db),
|
|
406
|
+
builderFactory: (resolver) => new PgSqlQueryBuilder(resolver),
|
|
407
|
+
});
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### Domain vocabulary in, snake_case out
|
|
411
|
+
|
|
412
|
+
You write columns in domain vocabulary (`createdAt`, `isActive`, `scopeId`). The builder resolves them through the `ColumnResolver` and emits the correct DB names **plus output aliases** so read models receive the domain shape straight back:
|
|
413
|
+
|
|
414
|
+
```ts
|
|
415
|
+
this.qb().select(['id', 'createdAt', 'isActive']).from('users').build();
|
|
416
|
+
// -> SELECT id, created_at AS "createdAt", is_active AS "isActive" FROM users
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
The same resolver applies to `.filters()`, `.orderBy()`, and `.groupBy()`. `this.findOne` / `findMany` return rows with the camelCase keys the read model expects — no more hand-written `'created_at as "createdAt"'` lists.
|
|
420
|
+
|
|
421
|
+
### The filter suffix DSL
|
|
422
|
+
|
|
423
|
+
`.filters({ ... })` accepts a flat object keyed by `field` or `field__operator`. The delimiter is double-underscore (`__`) to avoid collisions with field names that contain underscores.
|
|
424
|
+
|
|
425
|
+
| Operator | SQL | Available for |
|
|
426
|
+
| --- | --- | --- |
|
|
427
|
+
| `field` (bare) | `field = $n` (or `IS NULL` if value is `null`) | all kinds |
|
|
428
|
+
| `field__contains` | `field ILIKE '%value%'` | string |
|
|
429
|
+
| `field__in` | `field = ANY($n)` | string, number, date |
|
|
430
|
+
| `field__notIn` | `field <> ALL($n) OR field IS NULL` | string, number, date |
|
|
431
|
+
| `field__gt` / `__gte` / `__lt` / `__lte` | `field > $n` etc. | number, date |
|
|
432
|
+
| `field__isNull` | `field IS NULL` (or `IS NOT NULL` if value is `false`) | all kinds |
|
|
433
|
+
| `field__isNotNull` | `field IS NOT NULL` (inverse of above) | all kinds |
|
|
434
|
+
|
|
435
|
+
`undefined` values are skipped, so `filters({ name: opts.name })` composes cleanly when `opts.name` is optional. Unknown operator suffixes throw at build time.
|
|
436
|
+
|
|
437
|
+
### Raw WHERE fragments for dialect-specific operators
|
|
438
|
+
|
|
439
|
+
When the suffix DSL isn't enough (JSONB containment, full-text search, custom functions), use `.where(sql, ...params)`. Positional `?` placeholders are rebased onto the builder's parameter sequence:
|
|
440
|
+
|
|
441
|
+
```ts
|
|
442
|
+
this.qb<TaskRow>()
|
|
443
|
+
.from('tasks')
|
|
444
|
+
.filters({ scopeId })
|
|
445
|
+
.where('tags @> ?::jsonb', JSON.stringify(['urgent']))
|
|
446
|
+
.where('assignees @> ?::jsonb', JSON.stringify([userId]))
|
|
447
|
+
.build();
|
|
448
|
+
// -> WHERE tenant_id = $1 AND tags @> $2::jsonb AND assignees @> $3::jsonb
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Each `.where()` call is ANDed with the rest. Never concatenate user input into the SQL string — pass it as a parameter.
|
|
452
|
+
|
|
453
|
+
### Pagination
|
|
454
|
+
|
|
455
|
+
`.paginate({ page, pageSize })` adds `LIMIT`/`OFFSET` and automatically emits a `countSql` alongside the data SQL. `findPaginated` runs both queries in parallel on the read pool and returns a `PaginatedResult<T>`:
|
|
456
|
+
|
|
457
|
+
```ts
|
|
458
|
+
const result = await dao.listPage(query, scopeId);
|
|
459
|
+
// { rows: [...], total: 137, page: 2, pageSize: 20 }
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
`distinctOn` is supported for Postgres:
|
|
463
|
+
|
|
464
|
+
```ts
|
|
465
|
+
.paginate({ page: 1, pageSize: 50, distinctOn: ['customerId'] })
|
|
466
|
+
// -> SELECT DISTINCT ON (customer_id) ... LIMIT 50 OFFSET 0
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
If the query uses `GROUP BY`, the count is computed over the grouped set via a subquery wrap — you don't need to do anything special.
|
|
470
|
+
|
|
471
|
+
### Sorting with enforced + default directives
|
|
472
|
+
|
|
473
|
+
`.orderBy(userSort, { enforced, defaults })` gives you three layers:
|
|
474
|
+
|
|
475
|
+
- **User sort** — from an HTTP query, or the `sort` on a `StandardListQuery`.
|
|
476
|
+
- **Enforced** — always applied, prepended to whatever user sort is there (use for scope-first stability, deterministic ordering across equal keys).
|
|
477
|
+
- **Defaults** — applied only when `userSort` is empty or absent.
|
|
478
|
+
|
|
479
|
+
```ts
|
|
480
|
+
.orderBy(query.sort, {
|
|
481
|
+
enforced: [{ scopeId: 'asc' }],
|
|
482
|
+
defaults: [{ createdAt: 'desc' }, { id: 'asc' }],
|
|
483
|
+
})
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
### `ColumnResolver` — mapping domain keys to your column names
|
|
487
|
+
|
|
488
|
+
Every `BaseReadDao` carries a `ColumnResolver`. The default is `DefaultColumnResolver`, which does camelCase → snake_case plus any explicit overrides you pass:
|
|
489
|
+
|
|
490
|
+
```ts
|
|
491
|
+
import { DefaultColumnResolver } from '@quilla-be-kit/persistence';
|
|
492
|
+
|
|
493
|
+
new DefaultColumnResolver({
|
|
494
|
+
overrides: {
|
|
495
|
+
scopeId: 'tenant_id', // your column isn't called `scope_id`
|
|
496
|
+
password: 'password_hash',
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
Pass it to the DAO constructor:
|
|
502
|
+
|
|
503
|
+
```ts
|
|
504
|
+
new RoleReadDao({
|
|
505
|
+
adapter: readAdapter,
|
|
506
|
+
builderFactory: (r) => new PgSqlQueryBuilder(r),
|
|
507
|
+
columnResolver: new DefaultColumnResolver({ overrides: { scopeId: 'tenant_id' } }),
|
|
508
|
+
});
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
Or, more commonly, bake the overrides into a **shell base class** once and every read DAO in your project inherits them (see the "Adopting the toolkit: build a shell" section in the root [README](../../README.md)):
|
|
512
|
+
|
|
513
|
+
```ts
|
|
514
|
+
export abstract class RelmoBaseReadDao extends BaseReadDao {
|
|
515
|
+
constructor(adapter: ReadDbAdapter) {
|
|
516
|
+
super({
|
|
517
|
+
adapter,
|
|
518
|
+
builderFactory: (r) => new PgSqlQueryBuilder(r),
|
|
519
|
+
columnResolver: new DefaultColumnResolver({ overrides: { scopeId: 'tenant_id' } }),
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
Then `extends RelmoBaseReadDao` everywhere and every query translates `scopeId` → `tenant_id` automatically.
|
|
526
|
+
|
|
527
|
+
### HTTP query string → validated DTO → read DAO
|
|
528
|
+
|
|
529
|
+
The `@quilla-be-kit/persistence/query-schema` sub-path provides `createQueryParametersSchema` — a Zod helper that generates the full validation + transform schema from a plain filter shape:
|
|
530
|
+
|
|
531
|
+
```ts
|
|
532
|
+
// application/queries/list-roles.query.ts
|
|
533
|
+
import type { StandardListQuery } from '@quilla-be-kit/persistence';
|
|
534
|
+
|
|
535
|
+
export type ListRolesFilters = {
|
|
536
|
+
name?: string;
|
|
537
|
+
isActive?: boolean;
|
|
538
|
+
createdAt?: Date;
|
|
539
|
+
};
|
|
540
|
+
export type ListRolesQuery = StandardListQuery<ListRolesFilters>;
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
```ts
|
|
544
|
+
// presentation/dto/list-roles.request-dto.ts
|
|
545
|
+
import { z } from 'zod';
|
|
546
|
+
import { createQueryParametersSchema } from '@quilla-be-kit/persistence/query-schema';
|
|
547
|
+
import type { ListRolesFilters } from '../../application/queries/list-roles.query.js';
|
|
548
|
+
|
|
549
|
+
const filters = z.object({
|
|
550
|
+
name: z.string().optional(),
|
|
551
|
+
isActive: z.boolean().optional(),
|
|
552
|
+
createdAt: z.coerce.date().optional(),
|
|
553
|
+
}) as z.ZodObject<{ [K in keyof ListRolesFilters]: z.ZodType<ListRolesFilters[K]> }>;
|
|
554
|
+
|
|
555
|
+
export const ListRolesRequestDto = createQueryParametersSchema<ListRolesFilters>(filters, {
|
|
556
|
+
defaultPageSize: 20,
|
|
557
|
+
maxPageSize: 100,
|
|
558
|
+
});
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
```ts
|
|
562
|
+
// presentation/controllers/roles.controller.ts
|
|
563
|
+
@Controller('/roles')
|
|
564
|
+
export class RolesController {
|
|
565
|
+
constructor(private readonly roleRead: RoleReadDao) {}
|
|
566
|
+
|
|
567
|
+
@Get('/')
|
|
568
|
+
@ValidateRequest(ListRolesRequestDto, ['query'])
|
|
569
|
+
async list(req: HttpRequest): Promise<HttpResponse> {
|
|
570
|
+
const query = req.getValidatedInput<ListRolesQuery>();
|
|
571
|
+
const ctx = req.getExecutionContext();
|
|
572
|
+
const result = await this.roleRead.listPage(query, ctx.scopeId!);
|
|
573
|
+
return HttpResponse.ok(result);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
A request like:
|
|
579
|
+
|
|
580
|
+
```
|
|
581
|
+
GET /roles?name__contains=admin&isActive=true&createdAt__gte=2026-01-01&sort=createdAt:desc&page=2&pageSize=50
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
becomes, by the time the controller's handler sees it:
|
|
585
|
+
|
|
586
|
+
```ts
|
|
587
|
+
{
|
|
588
|
+
filters: {
|
|
589
|
+
name__contains: 'admin',
|
|
590
|
+
isActive: true,
|
|
591
|
+
createdAt__gte: new Date('2026-01-01'),
|
|
592
|
+
},
|
|
593
|
+
sort: [{ createdAt: 'desc' }],
|
|
594
|
+
pagination: { page: 2, pageSize: 50 },
|
|
595
|
+
}
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
The filter shape you declare drives the generated operator set automatically — string fields get `__contains` / `__in` / `__notIn` / `__isNull` / `__isNotNull`, numbers and dates add `__gt` / `__gte` / `__lt` / `__lte`, booleans get `__isNull` / `__isNotNull`. Unknown query keys are stripped. Sort directives pointing at unknown fields are dropped. `pageSize` is clamped to `maxPageSize`.
|
|
599
|
+
|
|
600
|
+
#### Strict vs tolerant parsing
|
|
601
|
+
|
|
602
|
+
By default the parser is **tolerant** — unknown keys, unknown sort fields, bad sort directions, and invalid `page` / `pageSize` values are silently normalized (dropped or replaced with defaults). That suits public HTTP endpoints where you'd rather serve a valid response with sensible defaults than 400 the caller for typos.
|
|
603
|
+
|
|
604
|
+
Opt into **strict** mode for trusted callers (internal RPC, background jobs) or when you want client bugs to surface loudly:
|
|
605
|
+
|
|
606
|
+
```ts
|
|
607
|
+
export const ListRolesRequestDto = createQueryParametersSchema<ListRolesFilters>(filters, {
|
|
608
|
+
defaultPageSize: 20,
|
|
609
|
+
maxPageSize: 100,
|
|
610
|
+
strict: true,
|
|
611
|
+
});
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
In strict mode a request like `?unknown=x&sort=foo:sideways&page=-1` produces a single `ZodError` with one issue per problem: unknown key, unknown sort field, invalid sort direction, invalid page. `maxPageSize` stays a **clamp** even in strict mode — a client asking for more data than you're willing to serve isn't malformed input, just bounded.
|
|
615
|
+
|
|
616
|
+
#### Extra top-level fields (auth-derived identifiers, correlation ids, etc.)
|
|
617
|
+
|
|
618
|
+
Some queries need fields that belong on the **query envelope** but aren't client-narrowable filters — typically auth-derived identifiers the server populates post-validation (`scopeId`, `userId`, etc.). Putting those inside the filter shape would be wrong — the generator would auto-expand them into suffix operators (`scopeId__in`, `scopeId__contains`) and expose scope-crossing filters to the client.
|
|
619
|
+
|
|
620
|
+
Use the `extraFields` option to declare them at the top level of the generated schema instead. The generator:
|
|
621
|
+
|
|
622
|
+
- Declares them at the top level (so strict mode accepts them and doesn't reject as unknown).
|
|
623
|
+
- **Skips** suffix-operator expansion for their names.
|
|
624
|
+
- Passes them through to the transform output at the top level, alongside `filters` / `sort` / `pagination` — **not** nested under `filters`.
|
|
625
|
+
|
|
626
|
+
```ts
|
|
627
|
+
import { z } from 'zod';
|
|
628
|
+
import { createQueryParametersSchema } from '@quilla-be-kit/persistence/query-schema';
|
|
629
|
+
|
|
630
|
+
export const ListRolesRequestDto = createQueryParametersSchema<
|
|
631
|
+
ListRolesFilters,
|
|
632
|
+
{ scopeId: string; userId: string }
|
|
633
|
+
>(filters, {
|
|
634
|
+
strict: true,
|
|
635
|
+
extraFields: z.object({
|
|
636
|
+
scopeId: z.string().optional(),
|
|
637
|
+
userId: z.string().optional(),
|
|
638
|
+
}),
|
|
639
|
+
});
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
`@ValidateRequest` then populates `scopeId` / `userId` from the active `ExecutionContext` because the schema declares them — see [conditional auth-injection in `@quilla-be-kit/http`](../http/README.md#validaterequestschema-sources). Your consumer-side query type composes via intersection:
|
|
643
|
+
|
|
644
|
+
```ts
|
|
645
|
+
export type ListRolesQuery = StandardListQuery<ListRolesFilters> & {
|
|
646
|
+
scopeId?: string;
|
|
647
|
+
userId?: string;
|
|
648
|
+
};
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
Handler reads `query.scopeId` directly — no separate arg, no explicit stitch at the controller. The toolkit stays naming-agnostic: `scopeId` / `userId` are your choice (some apps call the boundary `tenantId`, `workspaceId`, etc.), and any field can flow through `extraFields`, not just auth identifiers.
|
|
652
|
+
|
|
653
|
+
The generator is Zod-bound (extracts field kinds by walking the `ZodObject` schema) but the output — `StandardListQuery<TFilters> & Partial<TExtra>` — is validator-agnostic. A Valibot or ArkType consumer can implement their own generator against the same output contract. Field descriptors are available via `fieldDescriptorsFromZod` for building alternative generators on top of Zod.
|
|
654
|
+
|
|
655
|
+
### Safety discipline
|
|
656
|
+
|
|
657
|
+
`.select()`, `.from()`, `.join()`, `.groupBy()`, `.orderBy()`, and `.filters()` validate identifiers with a strict regex — user input must **never** reach those seams. When you need a runtime value in a condition, it flows through `.where(sql, ?)` or `.filters({...})`, both of which parameterise. Raw user text should never be interpolated into a SQL string.
|
|
658
|
+
|
|
659
|
+
Consumer-facing consequences:
|
|
660
|
+
|
|
661
|
+
- `.from('users; DROP TABLE users')` throws at build time.
|
|
662
|
+
- `.select(['id', injectedFromUser])` throws if the user string isn't a plain identifier.
|
|
663
|
+
- `.where('name = ' + userInput)` — still your own bug. Always use `.where('name = ?', userInput)`.
|
|
664
|
+
|
|
665
|
+
### When to drop back to `ReadDbAdapter.select`
|
|
666
|
+
|
|
667
|
+
The builder covers the common projection shapes. For one-off reads where the builder is overkill, `ReadDbAdapter` retains its original `select({ table, where, limit, orderBy })` API for structured single-table selects, and `raw<T>(sql, params)` for anything else. Both are available on `this.adapter` inside a DAO. The builder path is the recommended default; the raw paths stay as honest escape hatches.
|
|
668
|
+
|
|
669
|
+
## Files
|
|
670
|
+
|
|
671
|
+
```
|
|
672
|
+
src/
|
|
673
|
+
├── database/ Database / DatabaseTransaction / DatabaseResult / DatabaseHealth
|
|
674
|
+
├── db-adapter/ FilterQuery, Read/Write DbAdapter interfaces + options
|
|
675
|
+
├── dao/ BaseReadDao, BaseWriteDao
|
|
676
|
+
├── query/ QueryProduct, PaginatedResult, StandardListQuery, FieldDescriptor,
|
|
677
|
+
│ ColumnResolver + DefaultColumnResolver, SqlQueryBuilder
|
|
678
|
+
├── query-schema/ createQueryParametersSchema (Zod adapter — sub-path export)
|
|
679
|
+
├── repository/ BaseBasic/Aggregate/Scoped/Unscoped repositories + mapper
|
|
680
|
+
├── unit-of-work/ UnitOfWork, UnitOfWorkContext, OutboxWriter
|
|
681
|
+
├── errors/ CrossScopeAccessError, OptimisticLockError
|
|
682
|
+
└── postgres/ PgDatabase, PgTransaction, PgWriteDbAdapter, PgReadDbAdapter,
|
|
683
|
+
PgSqlQueryBuilder
|
|
684
|
+
```
|