@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.
Files changed (211) hide show
  1. package/README.md +684 -0
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/dao/audit-columns.d.ts +19 -0
  4. package/dist/dao/audit-columns.d.ts.map +1 -0
  5. package/dist/dao/audit-columns.js +26 -0
  6. package/dist/dao/audit-columns.js.map +1 -0
  7. package/dist/dao/base-read.dao.d.ts +50 -0
  8. package/dist/dao/base-read.dao.d.ts.map +1 -0
  9. package/dist/dao/base-read.dao.js +65 -0
  10. package/dist/dao/base-read.dao.js.map +1 -0
  11. package/dist/dao/base-write.dao.d.ts +49 -0
  12. package/dist/dao/base-write.dao.d.ts.map +1 -0
  13. package/dist/dao/base-write.dao.js +130 -0
  14. package/dist/dao/base-write.dao.js.map +1 -0
  15. package/dist/dao/index.d.ts +3 -0
  16. package/dist/dao/index.d.ts.map +1 -0
  17. package/dist/dao/index.js +3 -0
  18. package/dist/dao/index.js.map +1 -0
  19. package/dist/database/database-health.type.d.ts +4 -0
  20. package/dist/database/database-health.type.d.ts.map +1 -0
  21. package/dist/database/database-health.type.js +2 -0
  22. package/dist/database/database-health.type.js.map +1 -0
  23. package/dist/database/database-result.type.d.ts +5 -0
  24. package/dist/database/database-result.type.d.ts.map +1 -0
  25. package/dist/database/database-result.type.js +2 -0
  26. package/dist/database/database-result.type.js.map +1 -0
  27. package/dist/database/database-transaction.interface.d.ts +10 -0
  28. package/dist/database/database-transaction.interface.d.ts.map +1 -0
  29. package/dist/database/database-transaction.interface.js +2 -0
  30. package/dist/database/database-transaction.interface.js.map +1 -0
  31. package/dist/database/database.interface.d.ts +10 -0
  32. package/dist/database/database.interface.d.ts.map +1 -0
  33. package/dist/database/database.interface.js +2 -0
  34. package/dist/database/database.interface.js.map +1 -0
  35. package/dist/database/index.d.ts +5 -0
  36. package/dist/database/index.d.ts.map +1 -0
  37. package/dist/database/index.js +2 -0
  38. package/dist/database/index.js.map +1 -0
  39. package/dist/db-adapter/filter-query.type.d.ts +4 -0
  40. package/dist/db-adapter/filter-query.type.d.ts.map +1 -0
  41. package/dist/db-adapter/filter-query.type.js +2 -0
  42. package/dist/db-adapter/filter-query.type.js.map +1 -0
  43. package/dist/db-adapter/index.d.ts +4 -0
  44. package/dist/db-adapter/index.d.ts.map +1 -0
  45. package/dist/db-adapter/index.js +2 -0
  46. package/dist/db-adapter/index.js.map +1 -0
  47. package/dist/db-adapter/read-db-adapter.interface.d.ts +37 -0
  48. package/dist/db-adapter/read-db-adapter.interface.d.ts.map +1 -0
  49. package/dist/db-adapter/read-db-adapter.interface.js +2 -0
  50. package/dist/db-adapter/read-db-adapter.interface.js.map +1 -0
  51. package/dist/db-adapter/write-db-adapter.interface.d.ts +61 -0
  52. package/dist/db-adapter/write-db-adapter.interface.d.ts.map +1 -0
  53. package/dist/db-adapter/write-db-adapter.interface.js +2 -0
  54. package/dist/db-adapter/write-db-adapter.interface.js.map +1 -0
  55. package/dist/errors/cross-scope-access.error.d.ts +10 -0
  56. package/dist/errors/cross-scope-access.error.d.ts.map +1 -0
  57. package/dist/errors/cross-scope-access.error.js +15 -0
  58. package/dist/errors/cross-scope-access.error.js.map +1 -0
  59. package/dist/errors/index.d.ts +3 -0
  60. package/dist/errors/index.d.ts.map +1 -0
  61. package/dist/errors/index.js +3 -0
  62. package/dist/errors/index.js.map +1 -0
  63. package/dist/errors/optimistic-lock.error.d.ts +9 -0
  64. package/dist/errors/optimistic-lock.error.d.ts.map +1 -0
  65. package/dist/errors/optimistic-lock.error.js +11 -0
  66. package/dist/errors/optimistic-lock.error.js.map +1 -0
  67. package/dist/index.d.ts +8 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +8 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/postgres/index.d.ts +7 -0
  72. package/dist/postgres/index.d.ts.map +1 -0
  73. package/dist/postgres/index.js +7 -0
  74. package/dist/postgres/index.js.map +1 -0
  75. package/dist/postgres/pg-query-builder.d.ts +42 -0
  76. package/dist/postgres/pg-query-builder.d.ts.map +1 -0
  77. package/dist/postgres/pg-query-builder.js +260 -0
  78. package/dist/postgres/pg-query-builder.js.map +1 -0
  79. package/dist/postgres/pg-read-db-adapter.d.ts +20 -0
  80. package/dist/postgres/pg-read-db-adapter.d.ts.map +1 -0
  81. package/dist/postgres/pg-read-db-adapter.js +28 -0
  82. package/dist/postgres/pg-read-db-adapter.js.map +1 -0
  83. package/dist/postgres/pg-sql.d.ts +44 -0
  84. package/dist/postgres/pg-sql.d.ts.map +1 -0
  85. package/dist/postgres/pg-sql.js +128 -0
  86. package/dist/postgres/pg-sql.js.map +1 -0
  87. package/dist/postgres/pg-write-db-adapter.d.ts +30 -0
  88. package/dist/postgres/pg-write-db-adapter.d.ts.map +1 -0
  89. package/dist/postgres/pg-write-db-adapter.js +133 -0
  90. package/dist/postgres/pg-write-db-adapter.js.map +1 -0
  91. package/dist/postgres/pg.database.d.ts +25 -0
  92. package/dist/postgres/pg.database.d.ts.map +1 -0
  93. package/dist/postgres/pg.database.js +48 -0
  94. package/dist/postgres/pg.database.js.map +1 -0
  95. package/dist/postgres/pg.transaction.d.ts +22 -0
  96. package/dist/postgres/pg.transaction.d.ts.map +1 -0
  97. package/dist/postgres/pg.transaction.js +78 -0
  98. package/dist/postgres/pg.transaction.js.map +1 -0
  99. package/dist/query/case.d.ts +3 -0
  100. package/dist/query/case.d.ts.map +1 -0
  101. package/dist/query/case.js +7 -0
  102. package/dist/query/case.js.map +1 -0
  103. package/dist/query/column-resolver.interface.d.ts +4 -0
  104. package/dist/query/column-resolver.interface.d.ts.map +1 -0
  105. package/dist/query/column-resolver.interface.js +2 -0
  106. package/dist/query/column-resolver.interface.js.map +1 -0
  107. package/dist/query/default.resolver.d.ts +10 -0
  108. package/dist/query/default.resolver.d.ts.map +1 -0
  109. package/dist/query/default.resolver.js +14 -0
  110. package/dist/query/default.resolver.js.map +1 -0
  111. package/dist/query/field-descriptor.type.d.ts +11 -0
  112. package/dist/query/field-descriptor.type.d.ts.map +1 -0
  113. package/dist/query/field-descriptor.type.js +20 -0
  114. package/dist/query/field-descriptor.type.js.map +1 -0
  115. package/dist/query/filter-query.type.d.ts +4 -0
  116. package/dist/query/filter-query.type.d.ts.map +1 -0
  117. package/dist/query/filter-query.type.js +2 -0
  118. package/dist/query/filter-query.type.js.map +1 -0
  119. package/dist/query/index.d.ts +9 -0
  120. package/dist/query/index.d.ts.map +1 -0
  121. package/dist/query/index.js +3 -0
  122. package/dist/query/index.js.map +1 -0
  123. package/dist/query/list-query.type.d.ts +12 -0
  124. package/dist/query/list-query.type.d.ts.map +1 -0
  125. package/dist/query/list-query.type.js +2 -0
  126. package/dist/query/list-query.type.js.map +1 -0
  127. package/dist/query/paginated-result.type.d.ts +7 -0
  128. package/dist/query/paginated-result.type.d.ts.map +1 -0
  129. package/dist/query/paginated-result.type.js +2 -0
  130. package/dist/query/paginated-result.type.js.map +1 -0
  131. package/dist/query/query-product.type.d.ts +6 -0
  132. package/dist/query/query-product.type.d.ts.map +1 -0
  133. package/dist/query/query-product.type.js +2 -0
  134. package/dist/query/query-product.type.js.map +1 -0
  135. package/dist/query/read-query-builder.interface.d.ts +17 -0
  136. package/dist/query/read-query-builder.interface.d.ts.map +1 -0
  137. package/dist/query/read-query-builder.interface.js +2 -0
  138. package/dist/query/read-query-builder.interface.js.map +1 -0
  139. package/dist/query/sql-query-builder.interface.d.ts +99 -0
  140. package/dist/query/sql-query-builder.interface.d.ts.map +1 -0
  141. package/dist/query/sql-query-builder.interface.js +2 -0
  142. package/dist/query/sql-query-builder.interface.js.map +1 -0
  143. package/dist/query/sql-statement.type.d.ts +5 -0
  144. package/dist/query/sql-statement.type.d.ts.map +1 -0
  145. package/dist/query/sql-statement.type.js +2 -0
  146. package/dist/query/sql-statement.type.js.map +1 -0
  147. package/dist/query/write-query-builder.interface.d.ts +34 -0
  148. package/dist/query/write-query-builder.interface.d.ts.map +1 -0
  149. package/dist/query/write-query-builder.interface.js +2 -0
  150. package/dist/query/write-query-builder.interface.js.map +1 -0
  151. package/dist/query-schema/field-descriptor-from-zod.d.ts +4 -0
  152. package/dist/query-schema/field-descriptor-from-zod.d.ts.map +1 -0
  153. package/dist/query-schema/field-descriptor-from-zod.js +31 -0
  154. package/dist/query-schema/field-descriptor-from-zod.js.map +1 -0
  155. package/dist/query-schema/index.d.ts +3 -0
  156. package/dist/query-schema/index.d.ts.map +1 -0
  157. package/dist/query-schema/index.js +3 -0
  158. package/dist/query-schema/index.js.map +1 -0
  159. package/dist/query-schema/zod.d.ts +73 -0
  160. package/dist/query-schema/zod.d.ts.map +1 -0
  161. package/dist/query-schema/zod.js +191 -0
  162. package/dist/query-schema/zod.js.map +1 -0
  163. package/dist/repository/base-aggregate.repository.d.ts +28 -0
  164. package/dist/repository/base-aggregate.repository.d.ts.map +1 -0
  165. package/dist/repository/base-aggregate.repository.js +49 -0
  166. package/dist/repository/base-aggregate.repository.js.map +1 -0
  167. package/dist/repository/base-basic.repository.d.ts +22 -0
  168. package/dist/repository/base-basic.repository.d.ts.map +1 -0
  169. package/dist/repository/base-basic.repository.js +30 -0
  170. package/dist/repository/base-basic.repository.js.map +1 -0
  171. package/dist/repository/base-persistence.mapper.d.ts +61 -0
  172. package/dist/repository/base-persistence.mapper.d.ts.map +1 -0
  173. package/dist/repository/base-persistence.mapper.js +119 -0
  174. package/dist/repository/base-persistence.mapper.js.map +1 -0
  175. package/dist/repository/base-scoped-aggregate.repository.d.ts +19 -0
  176. package/dist/repository/base-scoped-aggregate.repository.d.ts.map +1 -0
  177. package/dist/repository/base-scoped-aggregate.repository.js +35 -0
  178. package/dist/repository/base-scoped-aggregate.repository.js.map +1 -0
  179. package/dist/repository/base-unscoped-aggregate.repository.d.ts +17 -0
  180. package/dist/repository/base-unscoped-aggregate.repository.d.ts.map +1 -0
  181. package/dist/repository/base-unscoped-aggregate.repository.js +21 -0
  182. package/dist/repository/base-unscoped-aggregate.repository.js.map +1 -0
  183. package/dist/repository/index.d.ts +7 -0
  184. package/dist/repository/index.d.ts.map +1 -0
  185. package/dist/repository/index.js +6 -0
  186. package/dist/repository/index.js.map +1 -0
  187. package/dist/repository/mapper.interface.d.ts +5 -0
  188. package/dist/repository/mapper.interface.d.ts.map +1 -0
  189. package/dist/repository/mapper.interface.js +2 -0
  190. package/dist/repository/mapper.interface.js.map +1 -0
  191. package/dist/unit-of-work/event-sink.interface.d.ts +5 -0
  192. package/dist/unit-of-work/event-sink.interface.d.ts.map +1 -0
  193. package/dist/unit-of-work/event-sink.interface.js +2 -0
  194. package/dist/unit-of-work/event-sink.interface.js.map +1 -0
  195. package/dist/unit-of-work/index.d.ts +4 -0
  196. package/dist/unit-of-work/index.d.ts.map +1 -0
  197. package/dist/unit-of-work/index.js +2 -0
  198. package/dist/unit-of-work/index.js.map +1 -0
  199. package/dist/unit-of-work/outbox-writer.interface.d.ts +14 -0
  200. package/dist/unit-of-work/outbox-writer.interface.d.ts.map +1 -0
  201. package/dist/unit-of-work/outbox-writer.interface.js +2 -0
  202. package/dist/unit-of-work/outbox-writer.interface.js.map +1 -0
  203. package/dist/unit-of-work/unit-of-work-context.type.d.ts +8 -0
  204. package/dist/unit-of-work/unit-of-work-context.type.d.ts.map +1 -0
  205. package/dist/unit-of-work/unit-of-work-context.type.js +2 -0
  206. package/dist/unit-of-work/unit-of-work-context.type.js.map +1 -0
  207. package/dist/unit-of-work/unit-of-work.d.ts +20 -0
  208. package/dist/unit-of-work/unit-of-work.d.ts.map +1 -0
  209. package/dist/unit-of-work/unit-of-work.js +60 -0
  210. package/dist/unit-of-work/unit-of-work.js.map +1 -0
  211. 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
+ ```