@zeroxsolutions/db 9.5.0 → 10.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 (50) hide show
  1. package/CHANGELOG.md +57 -22
  2. package/README.md +149 -180
  3. package/dist/base/d1/entity.d.ts.map +1 -1
  4. package/dist/base/d1/outbox.d.ts.map +1 -1
  5. package/dist/base/d1/outbox.js +1 -3
  6. package/dist/base/d1/repository.d.ts +76 -66
  7. package/dist/base/d1/repository.d.ts.map +1 -1
  8. package/dist/base/d1/repository.js +134 -122
  9. package/dist/base/d1/unit-of-work.d.ts.map +1 -1
  10. package/dist/base/postgres/entity.d.ts.map +1 -1
  11. package/dist/base/postgres/outbox.d.ts.map +1 -1
  12. package/dist/base/postgres/outbox.js +1 -3
  13. package/dist/base/postgres/repository.d.ts +109 -86
  14. package/dist/base/postgres/repository.d.ts.map +1 -1
  15. package/dist/base/postgres/repository.js +182 -143
  16. package/dist/base/postgres/unit-of-work.d.ts.map +1 -1
  17. package/dist/base/read-engine.d.ts +48 -0
  18. package/dist/base/read-engine.d.ts.map +1 -0
  19. package/dist/base/read-engine.js +94 -0
  20. package/dist/utils/filter.d.ts +44 -59
  21. package/dist/utils/filter.d.ts.map +1 -1
  22. package/dist/utils/filter.js +51 -85
  23. package/dist/utils/index.d.ts +3 -5
  24. package/dist/utils/index.d.ts.map +1 -1
  25. package/dist/utils/index.js +3 -5
  26. package/dist/utils/read.d.ts +88 -0
  27. package/dist/utils/read.d.ts.map +1 -0
  28. package/dist/utils/read.js +124 -0
  29. package/dist/utils/reads.d.ts +30 -0
  30. package/dist/utils/reads.d.ts.map +1 -0
  31. package/dist/utils/reads.js +26 -0
  32. package/dist/utils/relationships.d.ts +9 -6
  33. package/dist/utils/relationships.d.ts.map +1 -1
  34. package/dist/utils/relationships.js +26 -8
  35. package/package.json +7 -2
  36. package/dist/utils/assert-queryable-fields.d.ts +0 -27
  37. package/dist/utils/assert-queryable-fields.d.ts.map +0 -1
  38. package/dist/utils/assert-queryable-fields.js +0 -47
  39. package/dist/utils/constants.d.ts +0 -7
  40. package/dist/utils/constants.d.ts.map +0 -1
  41. package/dist/utils/constants.js +0 -6
  42. package/dist/utils/include.d.ts +0 -80
  43. package/dist/utils/include.d.ts.map +0 -1
  44. package/dist/utils/include.js +0 -132
  45. package/dist/utils/table-query.d.ts +0 -41
  46. package/dist/utils/table-query.d.ts.map +0 -1
  47. package/dist/utils/table-query.js +0 -1
  48. package/dist/utils/total-order.d.ts +0 -15
  49. package/dist/utils/total-order.d.ts.map +0 -1
  50. package/dist/utils/total-order.js +0 -35
package/CHANGELOG.md CHANGED
@@ -1,3 +1,38 @@
1
+ ## 10.1.0 (2026-10-19)
2
+
3
+ ### 🚀 Features
4
+
5
+ - **db:** hand a virtual name the request's own values as context ([3008328](https://github.com/zeroxsolutions/zeroxsolutions/commit/3008328))
6
+
7
+ ### ❤️ Thank You
8
+
9
+ - Claude Opus 5.5
10
+ - Lương Văn Tú
11
+
12
+ # 10.0.0 (2026-10-18)
13
+
14
+ ### 🚀 Features
15
+
16
+ - ⚠️ **db:** split the base repository as spring data does over a neutral read ([2b6fdba](https://github.com/zeroxsolutions/zeroxsolutions/commit/2b6fdba))
17
+
18
+ ### ⚠️ Breaking Changes
19
+
20
+ - **db:** split the base repository as spring data does over a neutral read ([2b6fdba](https://github.com/zeroxsolutions/zeroxsolutions/commit/2b6fdba))
21
+ `findOne`, `findAll(where)`, `findAllAndCount(where)`,
22
+ `count(where)`, `exists`, `update`, `delete`, `softDelete` and `nullifyBy`
23
+ are no longer public; reads take `read`, `filter`, `orderBy`, `limit` and
24
+ `offset` in place of `fields`, `include`, `with`, `sort` and `filter`, have no
25
+ `tx` and no default page window; `TableQuery`, `assertQueryableFields`,
26
+ `buildFilterConditions`, `buildOrderBy`, the filter resolvers,
27
+ `relationshipsOf` (now `relationsOf`) and the page-size constants are gone;
28
+ `BaseRepository` takes `READS` as its fourth argument and
29
+ `BaseViewRepository` its key columns as its third.
30
+
31
+ ### ❤️ Thank You
32
+
33
+ - Claude Opus 5.5
34
+ - Lương Văn Tú
35
+
1
36
  ## 9.5.0 (2026-10-16)
2
37
 
3
38
  ### 🚀 Features
@@ -107,11 +142,11 @@ This was a version bump only for @zeroxsolutions/db to align it with other proje
107
142
 
108
143
  ### 🩹 Fixes
109
144
 
110
- - ⚠️ **db:** take zod and drizzle-orm from the host, not a second copy ([128a772](https://github.com/zeroxsolutions/zeroxsolutions/commit/128a772))
145
+ - ⚠️ **db:** take zod and drizzle-orm from the host, not a second copy ([128a772](https://github.com/zeroxsolutions/zeroxsolutions/commit/128a772))
111
146
 
112
- ### ⚠️ Breaking Changes
147
+ ### ⚠️ Breaking Changes
113
148
 
114
- - **db:** take zod and drizzle-orm from the host, not a second copy ([128a772](https://github.com/zeroxsolutions/zeroxsolutions/commit/128a772))
149
+ - **db:** take zod and drizzle-orm from the host, not a second copy ([128a772](https://github.com/zeroxsolutions/zeroxsolutions/commit/128a772))
115
150
  `zod` and `drizzle-orm` are peer dependencies now. A consumer
116
151
  already installing both is unaffected; one that relied on db to drag them in
117
152
  must declare them itself.
@@ -129,11 +164,11 @@ This was a version bump only for @zeroxsolutions/db to align it with other proje
129
164
 
130
165
  ### 🚀 Features
131
166
 
132
- - ⚠️ **db:** load a read's fieldsets and include paths off the schema ([90cbd8c](https://github.com/zeroxsolutions/zeroxsolutions/commit/90cbd8c))
167
+ - ⚠️ **db:** load a read's fieldsets and include paths off the schema ([90cbd8c](https://github.com/zeroxsolutions/zeroxsolutions/commit/90cbd8c))
133
168
 
134
- ### ⚠️ Breaking Changes
169
+ ### ⚠️ Breaking Changes
135
170
 
136
- - **db:** load a read's fieldsets and include paths off the schema ([90cbd8c](https://github.com/zeroxsolutions/zeroxsolutions/commit/90cbd8c))
171
+ - **db:** load a read's fieldsets and include paths off the schema ([90cbd8c](https://github.com/zeroxsolutions/zeroxsolutions/commit/90cbd8c))
137
172
  `include` and `fields` are typed by `TableQuery`, so an
138
173
  unguarded `string[]` no longer compiles where the schema generics are given;
139
174
  `buildRelationalWith` is replaced by `buildRelationalRead`; the view
@@ -153,11 +188,11 @@ This was a version bump only for @zeroxsolutions/db to align it with other proje
153
188
 
154
189
  ### 🩹 Fixes
155
190
 
156
- - ⚠️ **db:** the audit columns store an instant, not a wall clock ([8760d45](https://github.com/zeroxsolutions/zeroxsolutions/commit/8760d45))
191
+ - ⚠️ **db:** the audit columns store an instant, not a wall clock ([8760d45](https://github.com/zeroxsolutions/zeroxsolutions/commit/8760d45))
157
192
 
158
- ### ⚠️ Breaking Changes
193
+ ### ⚠️ Breaking Changes
159
194
 
160
- - **db:** the audit columns store an instant, not a wall clock ([8760d45](https://github.com/zeroxsolutions/zeroxsolutions/commit/8760d45))
195
+ - **db:** the audit columns store an instant, not a wall clock ([8760d45](https://github.com/zeroxsolutions/zeroxsolutions/commit/8760d45))
161
196
  every Postgres consumer must run `drizzle:generate` and apply the
162
197
  migration it emits. Read that SQL before committing it: a bare `ALTER COLUMN ... SET DATA
163
198
  TYPE timestamptz` reinterprets existing values in the session's time zone, so a store
@@ -184,11 +219,11 @@ This was a version bump only for @zeroxsolutions/db to align it with other proje
184
219
 
185
220
  ### 💅 Refactors
186
221
 
187
- - ⚠️ **query,response,server:** one wire spelling, flat exports ([5c57b3a](https://github.com/zeroxsolutions/zeroxsolutions/commit/5c57b3a))
222
+ - ⚠️ **query,response,server:** one wire spelling, flat exports ([5c57b3a](https://github.com/zeroxsolutions/zeroxsolutions/commit/5c57b3a))
188
223
 
189
- ### ⚠️ Breaking Changes
224
+ ### ⚠️ Breaking Changes
190
225
 
191
- - **query,response,server:** one wire spelling, flat exports ([5c57b3a](https://github.com/zeroxsolutions/zeroxsolutions/commit/5c57b3a))
226
+ - **query,response,server:** one wire spelling, flat exports ([5c57b3a](https://github.com/zeroxsolutions/zeroxsolutions/commit/5c57b3a))
192
227
  `@zeroxsolutions/response/http`, `@zeroxsolutions/query/http`
193
228
  and `@zeroxsolutions/server/hono/http` are gone, and the surviving `/jsonapi`
194
229
  and `/hono` subpaths move to the package root. Every import drops its subpath.
@@ -223,11 +258,11 @@ This was a version bump only for @zeroxsolutions/db to align it with other proje
223
258
 
224
259
  ### 🚀 Features
225
260
 
226
- - ⚠️ **db:** let each table declare its own id column ([eca4aca](https://github.com/zeroxsolutions/zeroxsolutions/commit/eca4aca))
261
+ - ⚠️ **db:** let each table declare its own id column ([eca4aca](https://github.com/zeroxsolutions/zeroxsolutions/commit/eca4aca))
227
262
 
228
- ### ⚠️ Breaking Changes
263
+ ### ⚠️ Breaking Changes
229
264
 
230
- - **db:** let each table declare its own id column ([eca4aca](https://github.com/zeroxsolutions/zeroxsolutions/commit/eca4aca))
265
+ - **db:** let each table declare its own id column ([eca4aca](https://github.com/zeroxsolutions/zeroxsolutions/commit/eca4aca))
231
266
  `baseTable` is removed - take `idColumn` and spread
232
267
  `timestamps`. `defaultIndexedBaseColumns` is now `timestampIndexes` and
233
268
  `BaseTableShape` is `TimestampColumns`; both were named for the removed concept.
@@ -373,11 +408,11 @@ This was a version bump only for @zeroxsolutions/db to align it with other proje
373
408
 
374
409
  ### 🚀 Features
375
410
 
376
- - ⚠️ **db:** speak the wire's query vocabulary, and only it ([12801f0](https://github.com/zeroxsolutions/zeroxsolutions/commit/12801f0))
411
+ - ⚠️ **db:** speak the wire's query vocabulary, and only it ([12801f0](https://github.com/zeroxsolutions/zeroxsolutions/commit/12801f0))
377
412
 
378
- ### ⚠️ Breaking Changes
413
+ ### ⚠️ Breaking Changes
379
414
 
380
- - **db:** speak the wire's query vocabulary, and only it ([12801f0](https://github.com/zeroxsolutions/zeroxsolutions/commit/12801f0))
415
+ - **db:** speak the wire's query vocabulary, and only it ([12801f0](https://github.com/zeroxsolutions/zeroxsolutions/commit/12801f0))
381
416
  `FindAllOptions.orderBy` is now `sort` and `FilterQuery.filters`
382
417
  is now `filter`, in both the Postgres and D1 dialects. `baseQuerySchema`,
383
418
  `BaseQuery`, `OrderByValue` and `orderByValueSchema` are removed; a sort clause
@@ -439,11 +474,11 @@ This was a version bump only for @zeroxsolutions/db to align it with other proje
439
474
  ### 🚀 Features
440
475
 
441
476
  - **db:** hold FilterNode to the wire package's FilterTree at typecheck ([fb2a9a6](https://github.com/zeroxsolutions/zeroxsolutions/commit/fb2a9a6))
442
- - ⚠️ **db:** carry filters as a tree and refuse an inapplicable one ([1706c52](https://github.com/zeroxsolutions/zeroxsolutions/commit/1706c52))
477
+ - ⚠️ **db:** carry filters as a tree and refuse an inapplicable one ([1706c52](https://github.com/zeroxsolutions/zeroxsolutions/commit/1706c52))
443
478
 
444
- ### ⚠️ Breaking Changes
479
+ ### ⚠️ Breaking Changes
445
480
 
446
- - **db:** carry filters as a tree and refuse an inapplicable one ([1706c52](https://github.com/zeroxsolutions/zeroxsolutions/commit/1706c52))
481
+ - **db:** carry filters as a tree and refuse an inapplicable one ([1706c52](https://github.com/zeroxsolutions/zeroxsolutions/commit/1706c52))
447
482
  `buildFilterConditions` takes one options object;
448
483
  `filters` is a `FilterNode[]` tree; `baseQuerySchema` validates decoded
449
484
  objects and has no `search`; `serializeQuery` is removed - rendering the
@@ -626,4 +661,4 @@ This was a version bump only for @zeroxsolutions/db to align it with other proje
626
661
 
627
662
  ### ❤️ Thank You
628
663
 
629
- - Lương Văn Tú
664
+ - Lương Văn Tú
package/README.md CHANGED
@@ -5,16 +5,17 @@ A shared database library providing base entities, repository patterns, and quer
5
5
  ## Features
6
6
 
7
7
  - Base table schema with common audit columns (`id`, `createdAt`, `updatedAt`, `deletedAt`)
8
- - Abstract `BaseRepository` with full CRUD, soft-delete, pagination, and relation support
9
- - `BaseViewRepository` for PostgreSQL materialized views
10
- - Type-safe filter and ordering utilities with Zod validation, plus a `QueryableFields` whitelist guard
11
- (`assertQueryableFields`) keyed to the row type, so a removed column fails typecheck at the whitelist
8
+ - Abstract `BaseRepository` split as a Spring Data repository is: public methods that take no condition, and
9
+ protected ones taking a `where` that only a finder the repository names can call
10
+ - `BaseViewRepository` for PostgreSQL materialized views, reading with the options a table repository takes
11
+ - Reads that name what they load: a `read` of columns and nested relations, a `filter` tree, an `orderBy` with the
12
+ primary key appended, and a page window only when one is given
13
+ - `READS` (`defineReads`), declared once per context: each table's order as a relation and its `virtual` filter and
14
+ order names; `relationsOf` for the relationships a permit list names
12
15
  - The filter vocabulary the wire toolkit is generic over: `filterOperatorSchema` / `FilterOperator` (15 operators),
13
- `filterValueSchema`, and typed refusals (`UnknownQueryField`, `InvalidQueryFilter`, `InvalidQuerySort`)
14
- - Finders read a JSON:API query as drizzle's relational config: `fields` and `include`, typed off the schema by
15
- `TableQuery`, resolved by `buildRelationalRead`
16
+ `filterValueSchema`, and typed refusals (`UnknownQueryField`, `UnknownReadName`)
16
17
  - Transaction support for all operations
17
- - Automatic soft-delete filtering (excludes `deletedAt IS NOT NULL` by default)
18
+ - Automatic soft-delete filtering at every level of a read (excludes `deletedAt IS NOT NULL` by default)
18
19
  - `DrizzleUnitOfWork` + transactional `outboxTable` / `DrizzleOutbox` - the `@zeroxsolutions/cosmic` `IUnitOfWork` port implementation (atomic use case + domain events staged in the same transaction)
19
20
 
20
21
  ## Installation
@@ -33,13 +34,13 @@ Import from the subpath that matches your driver. **There is no root entry**: `p
33
34
  declares `"."`, but no `src/index.ts` exists for the build to emit it from, so
34
35
  `from '@zeroxsolutions/db'` does not resolve.
35
36
 
36
- | Export path | Description |
37
- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
38
- | `@zeroxsolutions/db/postgres` | PostgreSQL entity, repository, view repository, unit of work, outbox, and types |
39
- | `@zeroxsolutions/db/d1` | Cloudflare D1 (SQLite) entity, repository, unit of work, outbox, and types |
40
- | `@zeroxsolutions/db/utils` | Filter and order builders, `TableQuery` and the relational-read builder, the field-whitelist guard, the filter-value schema + operator enum, omit masks, pagination constants |
41
- | `@zeroxsolutions/db/testing` | A throwaway Postgres container carrying your schema, on the engine you name, plus the container timeout and a fake uuid v7 |
42
- | `@zeroxsolutions/db/containers` | The forced remove that tears down a container you started yourself |
37
+ | Export path | Description |
38
+ | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
39
+ | `@zeroxsolutions/db/postgres` | PostgreSQL entity, repository, view repository, unit of work, outbox, and types |
40
+ | `@zeroxsolutions/db/d1` | Cloudflare D1 (SQLite) entity, repository, unit of work, outbox, and types |
41
+ | `@zeroxsolutions/db/utils` | `defineReads`, `relationsOf`, the read and order types, the filter-value schema + operator enum, the refusals, omit masks |
42
+ | `@zeroxsolutions/db/testing` | A throwaway Postgres container carrying your schema, on the engine you name, plus the container timeout and a fake uuid v7 |
43
+ | `@zeroxsolutions/db/containers` | The forced remove that tears down a container you started yourself |
43
44
 
44
45
  ---
45
46
 
@@ -77,94 +78,145 @@ export const users = pgTable(
77
78
 
78
79
  ### BaseRepository
79
80
 
80
- Extend `BaseRepository` to get a fully featured data access layer:
81
+ Extend `BaseRepository`; a read or a write keyed by anything but the id is a finder you name, over a protected method:
81
82
 
82
83
  ```ts
83
84
  import { eq } from 'drizzle-orm';
84
- import { BaseRepository } from '@zeroxsolutions/db/postgres';
85
+ import { BaseRepository, type FindAllOptions } from '@zeroxsolutions/db/postgres';
85
86
  import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
86
- import { users } from './schema';
87
- import type * as schema from './schema';
87
+ import * as schema from './schema';
88
+ import { READS, usersTable } from './schema';
88
89
 
89
- export class UserRepository extends BaseRepository<typeof users, typeof schema, 'users'> {
90
+ export class UserRepository extends BaseRepository<typeof usersTable, typeof schema, 'usersTable'> {
90
91
  constructor(db: PostgresJsDatabase<typeof schema>) {
91
- super(db, users, 'users'); // 'users' is the queryKey for relational queries
92
+ super(db, usersTable, 'usersTable', READS); // the export name, and the context's READS
92
93
  }
93
94
 
94
- findOneByEmail(email: string) {
95
- return this.findOne(eq(users.email, email));
95
+ findAllAndCountByTenantId(tenantId: string, options?: FindAllOptions<typeof schema, 'usersTable'>) {
96
+ return this.findAllAndCountWhere(eq(usersTable.tenantId, tenantId), options);
96
97
  }
97
98
  }
98
99
  ```
99
100
 
100
- #### Available Methods
101
-
102
- | Method | Description |
103
- | ----------------------------------- | -------------------------------------------------- |
104
- | `findAll(where, options?)` | Paginated list query |
105
- | `findAllAndCount(where, options?)` | Paginated list + total count in parallel |
106
- | `findOne(where, options?)` | First matching record or `null` |
107
- | `findOneById(id, options?)` | Find by primary key or `null` |
108
- | `create(data, options?)` | Insert one record, returns inserted row |
109
- | `createMany(data[], options?)` | Bulk insert, returns inserted rows |
110
- | `update(where, data, options?)` | Update matching records, returns first updated row |
111
- | `updateById(id, data, options?)` | Update by primary key |
112
- | `delete(where, options?)` | Hard delete matching records |
113
- | `deleteById(id, options?)` | Hard delete by primary key |
114
- | `count(where, options?)` | Count matching records |
115
- | `exists(where, options?)` | Returns `true` if any record matches |
116
- | `softDelete(where, options?)` | Sets `deletedAt` to now |
117
- | `softDeleteById(id, options?)` | Soft delete by primary key |
118
- | `nullifyBy(field, value, options?)` | Set field to `null` where field equals value |
119
-
120
- #### FindAll Options
101
+ The third argument is the table's EXPORT name in the schema, which drizzle's relational query looks a relation up
102
+ by; omitted, a read that loads a relation throws.
103
+
104
+ #### Public methods
105
+
106
+ No public method takes a condition, so no SQL reaches the repository from outside it.
107
+
108
+ | Method | Description |
109
+ | ---------------------------------- | ------------------------------------------------------------ |
110
+ | `findOneById(id, options?)` | The row with that id, or `null` |
111
+ | `findAllById(ids, options?)` | Every row with one of those ids; no page window |
112
+ | `existsById(id, options?)` | Whether a row holds the id |
113
+ | `findAll(options?)` | The rows the options match |
114
+ | `findAllAndCount(options?)` | Those rows, and the count under the same filter, in parallel |
115
+ | `count(options?)` | How many rows the filter matches |
116
+ | `create(data, options?)` | Insert one row, returns it |
117
+ | `createMany(data[], options?)` | Bulk insert, returns the rows |
118
+ | `updateById(id, data, options?)` | Update by id |
119
+ | `deleteById(id, options?)` | Hard delete by id |
120
+ | `deleteAllById(ids, options?)` | Hard delete every id |
121
+ | `softDeleteById(id, options?)` | Set `deletedAt` by id |
122
+ | `softDeleteAllById(ids, options?)` | Set `deletedAt` on every id |
123
+
124
+ On a multi-tenant table every one of them crosses tenants; a tenant-scoped repository exposes named finders instead.
125
+
126
+ #### Protected methods
127
+
128
+ `findOneWhere`, `findAllWhere`, `findAllAndCountWhere`, `countWhere`, `existsWhere`, `updateWhere`, `deleteWhere`,
129
+ `softDeleteWhere` and `nullifyBy`, each taking a drizzle `where` beside the options. A finder's name states that
130
+ condition (`findAllAndCountByTenantId`), the way a Spring Data derived query's does.
131
+
132
+ #### Read options
121
133
 
122
134
  ```ts
123
- type FindAllOptions = {
124
- tx?: Transaction; // Run within a transaction
125
- limit?: number; // Default: 20; a larger value is clamped to 100, not rejected
126
- offset?: number; // Default: 0
127
- softDeleted?: boolean; // Include soft-deleted records (default: false)
128
- sort?: SortField[]; // [{ field: 'createdAt', direction: 'desc' }]; the primary key is appended
129
- fields?: { books?: readonly ('title' | 'author')[] }; // Fieldsets keyed by TABLE name: columns and relations
130
- include?: readonly ('author' | 'author.books')[]; // Relation paths, dot-separated (requires queryKey)
131
- columns?: { authorId?: boolean }; // Root columns added to the root fieldset; never narrows the row
132
- with?: { ... }; // Drizzle relational "with" clause, spelled out (requires queryKey)
135
+ type FindOneOptions = {
136
+ read?: Read; // what loads; every column and no relation where absent
137
+ softDeleted?: boolean; // whether marked root rows match (default: false)
138
+ at?: Date; // the instant a virtual name is decided at (default: the call)
139
+ context?: unknown; // the request's own values a virtual name is decided against, handed through unchecked
133
140
  };
141
+ type FindAllOptions = FindOneOptions & {
142
+ filter?: FilterNode[]; // the condition tree; root nodes join by `and`
143
+ orderBy?: { field: string; direction: 'asc' | 'desc' }[]; // the primary key is appended
144
+ limit?: number; // with none, every match
145
+ offset?: number;
146
+ };
147
+ type CountOptions = { filter?: FilterNode[]; softDeleted?: boolean; at?: Date; context?: unknown };
134
148
  ```
135
149
 
136
- `findAll` and `findAllAndCount` order by `sort` followed by each primary key column it does not
137
- already name, ascending, on both Postgres and D1. Rows that tie on `sort` have no order of their own,
138
- so without the key two pages could be cut from two orders of the same rows, repeating one row and
139
- skipping another. With no `sort`, a page is ordered by the key alone.
150
+ A read holds no transaction: one that must see a write in progress belongs to an aggregate repository on the unit of
151
+ work's executor. The writes still take `tx`.
152
+
153
+ | `read` holds | The finder loads |
154
+ | ------------------------------ | --------------------------------------------------------------------------- |
155
+ | nothing | every column of the root table, and no relation |
156
+ | `columns` | those columns; on the root table its primary key beside them |
157
+ | `relations.<name>` | that relation under its own nested `read`, every column where it names none |
158
+ | `relations.<name>.orderBy` | the related rows in that order, in place of the one `READS` declares |
159
+ | `relations.<name>.softDeleted` | that relation's marked rows too |
160
+
161
+ The soft-delete condition holds at every level: a marked related row of a relation to one loads as `null`, and the
162
+ marked rows of a relation to many are left out. A name the table does not hold throws `UnknownReadName`, naming its
163
+ path. A read loading no relation takes a plain `select`, so a repository given no `queryKey` still narrows its row.
140
164
 
141
- `fields` and `include` are `TableQuery<typeof schema, '<export name>'>`, the shape `@zeroxsolutions/query` guards, so
142
- a guarded query spreads straight into the options:
165
+ `findAll` and `findAllAndCount` order by `orderBy` followed by each primary key column it does not already name,
166
+ ascending, on both Postgres and D1. Rows that tie have no order of their own, so without the key two pages could be cut
167
+ from two orders of the same rows, repeating one row and skipping another.
143
168
 
144
- | The query says | The finder loads |
145
- | ----------------------------- | ------------------------------------------------------------------- |
146
- | no fieldset for a table | every column of that table |
147
- | a fieldset | those columns and the table's primary key |
148
- | a relation name in a fieldset | that relation's primary keys - the linkage a relationship publishes |
149
- | an empty fieldset | the primary key alone |
150
- | an include path | each relation on it, under its own table's fieldset |
169
+ A filter or an order name resolves to a `virtual` name of the context's `READS` first, then to a relation to one row,
170
+ compared by the related row's id (`equals` and `in` only), then to a column; anything else throws `UnknownQueryField`
171
+ before any SQL is built.
151
172
 
152
- `with` and `include` reach the same relational query; where both name one relation, `with` wins. A read loading no
153
- relation takes a plain `select` of the columns it narrowed to, so a repository given no `queryKey` still narrows its
154
- own row. An include path is typed three relations deep; spell a deeper one out with `with`.
173
+ #### READS
174
+
175
+ ```ts
176
+ import { defineReads } from '@zeroxsolutions/db/utils';
177
+
178
+ type PlaceReadContext = { origin: { lat: number; lng: number } };
179
+
180
+ export const READS = defineReads<typeof schema, PlaceReadContext>(schema, {
181
+ openingHoursTable: { orderBy: [{ field: 'weekday', direction: 'asc' }] },
182
+ placesTable: {
183
+ virtual: {
184
+ 'openState.status': {
185
+ filter: (condition, { at }) => sql`(<an expression over ${placesTable.id} and ${at}>) = ${condition.value}`,
186
+ order: ({ at }) => sql`<an expression over ${placesTable.id} and ${at}>`,
187
+ },
188
+ distance: {
189
+ order: ({ context }) => sql`<the distance from ${context?.origin.lat}, ${context?.origin.lng} to the place>`,
190
+ },
191
+ },
192
+ },
193
+ });
194
+
195
+ // a route hands the request's values beside the guarded query
196
+ places.findAllAndCount({ ...findOptionsOf(query, permits), context: { origin } });
197
+ ```
198
+
199
+ Each key is a table's export name and each order field one of its columns, checked when `defineReads` runs. A
200
+ `virtual` filter expression is wrapped in parentheses, so its `or` cannot reach the soft-delete condition.
201
+
202
+ A `virtual` name receives `at` and the finder's `context`, typed by `defineReads`'s second type argument. The finder
203
+ does not check `context`, and hands `undefined` where the caller passed none, so a name reading it decides what it
204
+ means without one. A count reads `context` as the page does, so a total and its page match the same rows.
155
205
 
156
206
  #### Nullify FK
157
207
 
158
- Set a foreign key column to `null` where it matches a given value. Useful when soft-deleting a parent but keeping child rows:
208
+ `nullifyBy` sets a foreign key column to `null` where it matches a given value, from inside a named method:
159
209
 
160
210
  ```ts
161
211
  // Set categoryId = null on all transactions where categoryId = '123'
162
- await transactionRepo.nullifyBy('categoryId', '123', { tx });
212
+ detachCategory(categoryId: string, tx: Transaction) {
213
+ return this.nullifyBy('categoryId', categoryId, { tx });
214
+ }
163
215
  ```
164
216
 
165
217
  #### Transactions
166
218
 
167
- All methods accept an optional `tx` in their options:
219
+ Every write accepts an optional `tx` in its options:
168
220
 
169
221
  ```ts
170
222
  await db.transaction(async (tx) => {
@@ -182,7 +234,7 @@ import { userSummaryView } from './views';
182
234
  // The same schema-typed client the service's table repositories take.
183
235
  export class UserSummaryViewRepository extends BaseViewRepository<typeof userSummaryView, typeof schema> {
184
236
  constructor(db: PostgresJsDatabase<typeof schema>) {
185
- super(db, userSummaryView);
237
+ super(db, userSummaryView, ['tenantId']); // the columns of the view's unique index
186
238
  }
187
239
  }
188
240
 
@@ -190,10 +242,13 @@ export class UserSummaryViewRepository extends BaseViewRepository<typeof userSum
190
242
  await repo.refresh({ concurrently: true });
191
243
  ```
192
244
 
193
- `findAll` takes `FindAllViewOptions` - `tx`, `limit`, `offset` and `fields` - and `findOne` the same without the page
194
- window. `fields` is keyed by the view's name; a fieldset loads those columns and any the view selected from a table's
195
- primary key. A view has no `deletedAt` and no relational config, so it takes no `softDeleted`, `with` or `include`,
196
- and a view read is ordered in the view's own definition.
245
+ A view repository reads with the options a table repository takes, without relations, `softDeleted` or `at`:
246
+ `FindAllViewOptions` holds a `read` of `columns`, `filter`, `orderBy`, `limit` and `offset`, `FindOneViewOptions` the
247
+ `read`, and `CountViewOptions` the `filter`. Its public `findAll`, `findAllAndCount` and `count` take no condition, and
248
+ the protected `findAllWhere`, `findOneWhere`, `findAllAndCountWhere` and `countWhere` do. The constructor's third
249
+ argument, the columns of the view's unique index, is appended to every order: a materialized view is read back with no
250
+ order of its own, and PostgreSQL documents that different `LIMIT` and `OFFSET` values give inconsistent results
251
+ without an `ORDER BY` that fixes one.
197
252
 
198
253
  ### Unit of Work & Transactional Outbox
199
254
 
@@ -330,27 +385,8 @@ sends it), with the same two indexes over the unsent rows as the Postgres table.
330
385
 
331
386
  ## Utilities
332
387
 
333
- Import from `@zeroxsolutions/db/utils`.
334
-
335
- ### Filter Builder
336
-
337
- Converts typed filter descriptors into Drizzle `SQL` conditions:
338
-
339
- Takes one options object, so a parsed wire query spreads straight in. Returns `undefined` when the
340
- tree contributes no condition, and throws `UnknownQueryField` when a clause names a column the table
341
- does not hold:
342
-
343
- ```ts
344
- import { buildFilterConditions } from '@zeroxsolutions/db/utils';
345
-
346
- const condition = buildFilterConditions({
347
- table: users,
348
- filter: [
349
- { field: 'name', operator: 'contains', value: 'alice' },
350
- { field: 'createdAt', operator: 'gte', value: '2024-01-01' },
351
- ],
352
- });
353
- ```
388
+ Import from `@zeroxsolutions/db/utils`. The builders that turn a filter and an order into SQL are the base
389
+ repository's own; a finder hands `filter` and `orderBy` to it unchanged.
354
390
 
355
391
  #### Supported Operators
356
392
 
@@ -368,90 +404,32 @@ All 15, as `filterOperatorSchema` declares them:
368
404
 
369
405
  A `%` or `_` in a text value is escaped, so it matches literally rather than as a wildcard.
370
406
 
371
- #### Relationship Filters
372
-
373
- A relationship named in a permit list is no column - its id sits in a foreign-key column instead, which
374
- `buildFilterConditions` cannot resolve on its own. Declare the permit through `permitsFor`'s own
375
- `relationship(name, operators)` (`@zeroxsolutions/query`), then hand `columnResolver` the foreign-key
376
- column under that same name in `resolvers`:
377
-
378
- ```ts
379
- import { permitsFor } from '@zeroxsolutions/query';
380
- import { buildFilterConditions, columnResolver } from '@zeroxsolutions/db/utils';
381
-
382
- const placesQueryPermits = permitsFor(placeAttributes, { relationships: places.relationships }).relationship('region', [
383
- 'equals',
384
- 'in',
385
- ]);
386
-
387
- const condition = buildFilterConditions({
388
- table: places,
389
- filter: query.filter,
390
- resolvers: { region: columnResolver(places.regionId) },
391
- });
392
- ```
393
-
394
- `columnResolver` answers `equals` and `in` - the only two operators a relationship filter grants -
395
- comparing the id against the column it closes over, and throws on any other operator.
396
-
397
- - `relationshipsOf(schema, '<table export>')` - the relations the schema declares on that table, as the map
398
- `permitsFor(schema, { relationships })` takes, so a relationship filter names a relation the schema holds.
399
- Throws on a key that is no table export (a table's SQL name is not one).
400
-
401
- ### Order Builder
402
-
403
- ```ts
404
- import { buildOrderBy } from '@zeroxsolutions/db/utils';
405
-
406
- const clauses = buildOrderBy(users, [
407
- { field: 'createdAt', direction: 'desc' },
408
- { field: 'name', direction: 'asc' },
409
- ]);
410
- ```
411
-
412
- `buildOrderBy` translates exactly the clauses it is given and appends no key, so it can build part of
413
- an order one clause at a time. A read that pages over its result owes itself a unique last clause.
414
-
415
- ### Field Whitelist Guard
416
-
417
- Reject a `filter`/`sort` on any field outside a listable entity's whitelist **before** building SQL, so a
418
- client can never filter or order by a hidden column (e.g. an access code). `QueryableFields<TSelect>` is keyed
419
- to the row's `$inferSelect` type, so a renamed/removed column fails typecheck at the whitelist. The guard is
420
- transport-agnostic - it throws a plain `Error` subclass (`InvalidQueryFilter` / `InvalidQuerySort`) carrying
421
- only the offending field name; the calling service maps each error **type** to its HTTP status/code at its
422
- own transport.
423
-
424
- ```ts
425
- import { assertQueryableFields, InvalidQueryFilter, type QueryableFields } from '@zeroxsolutions/db/utils';
407
+ ### relationsOf
426
408
 
427
- const USERS_QUERYABLE = {
428
- filterable: ['name', 'createdAt'],
429
- sortable: ['name', 'createdAt'],
430
- } as const satisfies QueryableFields<typeof users.$inferSelect>;
431
-
432
- // In a repository finder, before building SQL:
433
- assertQueryableFields(query, USERS_QUERYABLE); // throws InvalidQueryFilter / InvalidQuerySort on a hidden field
434
- ```
409
+ `relationsOf(schema, '<table export>')` answers the relations the schema declares on that table, each with the type
410
+ its fieldset is keyed by (its table's SQL name), its primary key, and its own relations, read when a path walks into
411
+ them. A permit list carries it as `relationships`, `permitsFor` checks a relationship filter's name against it, and
412
+ `findOptionsOf` in `@zeroxsolutions/jsonapi` finds an included relation's fieldset through it. It throws on a key that
413
+ is no table export (a table's SQL name is not one).
435
414
 
436
415
  ### Reading the query off a request
437
416
 
438
- This package ships **no** query schema. Parse the request with
439
- [`@zeroxsolutions/query`](../query) and hand its result straight to a finder - the
440
- member names line up (`offset` / `limit` / `sort` / `filter` / `fields` / `include`), so nothing renames anything:
417
+ This package ships **no** query schema and knows no wire format. Parse the request with
418
+ [`@zeroxsolutions/query`](../query), and turn the guarded query into finder options with `findOptionsOf` from
419
+ [`@zeroxsolutions/jsonapi`](../jsonapi):
441
420
 
442
421
  ```ts
443
422
  import { collectionQuerySchema } from '@zeroxsolutions/query';
423
+ import { findOptionsOf } from '@zeroxsolutions/jsonapi';
444
424
 
445
425
  createRoute({ request: { query: collectionQuerySchema(usersQueryPermits) } });
446
- const query = c.req.valid('query');
447
- const [rows, total] = await usersRepository.findAllAndCount(
448
- buildFilterConditions({ table: users, filter: query.filter }),
449
- query,
426
+ const [rows, total] = await usersRepository.findAllAndCountByTenantId(
427
+ tenantId,
428
+ findOptionsOf(c.req.valid('query'), usersQueryPermits),
450
429
  );
451
430
  ```
452
431
 
453
- Pass `filterItem: filterValueSchema` in the spec so the parsed clauses are the ones this package's
454
- builders accept. A test in `utils/filter.spec.ts` pins the absence of a second query shape.
432
+ Pass `filterItem: filterValueSchema` in the permits so the parsed clauses are the ones this package's finders accept.
455
433
 
456
434
  ### Omit Constants
457
435
 
@@ -470,15 +448,6 @@ import { defaultInsertOmit } from '@zeroxsolutions/db/utils';
470
448
  type InsertUser = Omit<typeof users.$inferInsert, keyof typeof defaultInsertOmit>;
471
449
  ```
472
450
 
473
- ### Pagination Constants
474
-
475
- ```ts
476
- import { DEFAULT_PAGE_SIZE, DEFAULT_OFFSET, MAX_PAGE_SIZE } from '@zeroxsolutions/db/utils';
477
- // DEFAULT_PAGE_SIZE = 20
478
- // DEFAULT_OFFSET = 0
479
- // MAX_PAGE_SIZE = 100
480
- ```
481
-
482
451
  ---
483
452
 
484
453
  ## Testing Seam
@@ -1 +1 @@
1
- {"version":3,"file":"entity.d.ts","sourceRoot":"","sources":["../../../src/base/d1/entity.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAC,MAAM,yBAAyB,CAAC;AAI5E,yGAAyG;AACzG,eAAO,MAAM,UAAU;;;;CAStB,CAAC;AAEF,kFAAkF;AAClF,eAAO,MAAM,QAAQ,2PAEQ,CAAC;AAE9B,iGAAiG;AACjG,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,eAAe,CAAC;IAC3B,SAAS,EAAE,eAAe,CAAC;IAC3B,SAAS,EAAE,eAAe,CAAC;CAC5B;AAED,0GAA0G;AAC1G,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,gBAAgB,EAAE,KAAK,EAAE,CAAC,EAAE,SAAS,EAAE,MAAM,GAAG,YAAY,EAAE,CAMxG"}
1
+ {"version":3,"file":"entity.d.ts","sourceRoot":"","sources":["../../../src/base/d1/entity.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAI7E,yGAAyG;AACzG,eAAO,MAAM,UAAU;;;;CAStB,CAAC;AAEF,kFAAkF;AAClF,eAAO,MAAM,QAAQ,2PAEQ,CAAC;AAE9B,iGAAiG;AACjG,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,eAAe,CAAC;IAC3B,SAAS,EAAE,eAAe,CAAC;IAC3B,SAAS,EAAE,eAAe,CAAC;CAC5B;AAED,0GAA0G;AAC1G,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,gBAAgB,EAAE,KAAK,EAAE,CAAC,EAAE,SAAS,EAAE,MAAM,GAAG,YAAY,EAAE,CAMxG"}
@@ -1 +1 @@
1
- {"version":3,"file":"outbox.d.ts","sourceRoot":"","sources":["../../../src/base/d1/outbox.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAG1D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAe,KAAK,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAChE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAExD;;;;GAIG;AACH,qBAAa,aAAa;IAEtB,OAAO,CAAC,QAAQ,CAAC,EAAE;IACnB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC;gBADV,EAAE,EAAE,iBAAiB,EACrB,SAAS,CAAC,EAAE,cAAc,YAAA;IAG7C,oGAAoG;IAC9F,KAAK,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAiBjD,0FAA0F;IAC1F,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IASpD,4GAA4G;IACtG,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,WAAW,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;CAOrE"}
1
+ {"version":3,"file":"outbox.d.ts","sourceRoot":"","sources":["../../../src/base/d1/outbox.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAG1D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAe,KAAK,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAChE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAExD;;;;GAIG;AACH,qBAAa,aAAa;IAEtB,OAAO,CAAC,QAAQ,CAAC,EAAE;IACnB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC;gBADV,EAAE,EAAE,iBAAiB,EACrB,SAAS,CAAC,EAAE,cAAc,YAAA;IAG7C,oGAAoG;IAC9F,KAAK,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAejD,0FAA0F;IAC1F,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IASpD,4GAA4G;IACtG,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,WAAW,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;CAOrE"}
@@ -16,9 +16,7 @@ export class DrizzleOutbox {
16
16
  async stage(events) {
17
17
  if (events.length === 0)
18
18
  return;
19
- const statement = this.db
20
- .insert(outboxTable)
21
- .values(events.map((event) => ({
19
+ const statement = this.db.insert(outboxTable).values(events.map((event) => ({
22
20
  type: event.type,
23
21
  payload: JSON.stringify(event),
24
22
  })));