@zeroxsolutions/db 9.5.0 → 10.0.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 (42) hide show
  1. package/CHANGELOG.md +47 -23
  2. package/README.md +136 -180
  3. package/dist/base/d1/repository.d.ts +75 -66
  4. package/dist/base/d1/repository.d.ts.map +1 -1
  5. package/dist/base/d1/repository.js +134 -122
  6. package/dist/base/postgres/repository.d.ts +108 -86
  7. package/dist/base/postgres/repository.d.ts.map +1 -1
  8. package/dist/base/postgres/repository.js +175 -143
  9. package/dist/base/read-engine.d.ts +49 -0
  10. package/dist/base/read-engine.d.ts.map +1 -0
  11. package/dist/base/read-engine.js +93 -0
  12. package/dist/utils/filter.d.ts +40 -61
  13. package/dist/utils/filter.d.ts.map +1 -1
  14. package/dist/utils/filter.js +51 -85
  15. package/dist/utils/index.d.ts +3 -5
  16. package/dist/utils/index.d.ts.map +1 -1
  17. package/dist/utils/index.js +3 -5
  18. package/dist/utils/read.d.ts +86 -0
  19. package/dist/utils/read.d.ts.map +1 -0
  20. package/dist/utils/read.js +124 -0
  21. package/dist/utils/reads.d.ts +24 -0
  22. package/dist/utils/reads.d.ts.map +1 -0
  23. package/dist/utils/reads.js +23 -0
  24. package/dist/utils/relationships.d.ts +9 -6
  25. package/dist/utils/relationships.d.ts.map +1 -1
  26. package/dist/utils/relationships.js +26 -8
  27. package/package.json +2 -2
  28. package/dist/utils/assert-queryable-fields.d.ts +0 -27
  29. package/dist/utils/assert-queryable-fields.d.ts.map +0 -1
  30. package/dist/utils/assert-queryable-fields.js +0 -47
  31. package/dist/utils/constants.d.ts +0 -7
  32. package/dist/utils/constants.d.ts.map +0 -1
  33. package/dist/utils/constants.js +0 -6
  34. package/dist/utils/include.d.ts +0 -80
  35. package/dist/utils/include.d.ts.map +0 -1
  36. package/dist/utils/include.js +0 -132
  37. package/dist/utils/table-query.d.ts +0 -41
  38. package/dist/utils/table-query.d.ts.map +0 -1
  39. package/dist/utils/table-query.js +0 -1
  40. package/dist/utils/total-order.d.ts +0 -15
  41. package/dist/utils/total-order.d.ts.map +0 -1
  42. package/dist/utils/total-order.js +0 -35
package/CHANGELOG.md CHANGED
@@ -1,3 +1,27 @@
1
+ # 10.0.0 (2026-10-18)
2
+
3
+ ### 🚀 Features
4
+
5
+ - ⚠️ **db:** split the base repository as spring data does over a neutral read ([2b6fdba](https://github.com/zeroxsolutions/zeroxsolutions/commit/2b6fdba))
6
+
7
+ ### ⚠️ Breaking Changes
8
+
9
+ - **db:** split the base repository as spring data does over a neutral read ([2b6fdba](https://github.com/zeroxsolutions/zeroxsolutions/commit/2b6fdba))
10
+ `findOne`, `findAll(where)`, `findAllAndCount(where)`,
11
+ `count(where)`, `exists`, `update`, `delete`, `softDelete` and `nullifyBy`
12
+ are no longer public; reads take `read`, `filter`, `orderBy`, `limit` and
13
+ `offset` in place of `fields`, `include`, `with`, `sort` and `filter`, have no
14
+ `tx` and no default page window; `TableQuery`, `assertQueryableFields`,
15
+ `buildFilterConditions`, `buildOrderBy`, the filter resolvers,
16
+ `relationshipsOf` (now `relationsOf`) and the page-size constants are gone;
17
+ `BaseRepository` takes `READS` as its fourth argument and
18
+ `BaseViewRepository` its key columns as its third.
19
+
20
+ ### ❤️ Thank You
21
+
22
+ - Claude Opus 5.5
23
+ - Lương Văn Tú
24
+
1
25
  ## 9.5.0 (2026-10-16)
2
26
 
3
27
  ### 🚀 Features
@@ -107,11 +131,11 @@ This was a version bump only for @zeroxsolutions/db to align it with other proje
107
131
 
108
132
  ### 🩹 Fixes
109
133
 
110
- - ⚠️ **db:** take zod and drizzle-orm from the host, not a second copy ([128a772](https://github.com/zeroxsolutions/zeroxsolutions/commit/128a772))
134
+ - ⚠️ **db:** take zod and drizzle-orm from the host, not a second copy ([128a772](https://github.com/zeroxsolutions/zeroxsolutions/commit/128a772))
111
135
 
112
- ### ⚠️ Breaking Changes
136
+ ### ⚠️ Breaking Changes
113
137
 
114
- - **db:** take zod and drizzle-orm from the host, not a second copy ([128a772](https://github.com/zeroxsolutions/zeroxsolutions/commit/128a772))
138
+ - **db:** take zod and drizzle-orm from the host, not a second copy ([128a772](https://github.com/zeroxsolutions/zeroxsolutions/commit/128a772))
115
139
  `zod` and `drizzle-orm` are peer dependencies now. A consumer
116
140
  already installing both is unaffected; one that relied on db to drag them in
117
141
  must declare them itself.
@@ -129,11 +153,11 @@ This was a version bump only for @zeroxsolutions/db to align it with other proje
129
153
 
130
154
  ### 🚀 Features
131
155
 
132
- - ⚠️ **db:** load a read's fieldsets and include paths off the schema ([90cbd8c](https://github.com/zeroxsolutions/zeroxsolutions/commit/90cbd8c))
156
+ - ⚠️ **db:** load a read's fieldsets and include paths off the schema ([90cbd8c](https://github.com/zeroxsolutions/zeroxsolutions/commit/90cbd8c))
133
157
 
134
- ### ⚠️ Breaking Changes
158
+ ### ⚠️ Breaking Changes
135
159
 
136
- - **db:** load a read's fieldsets and include paths off the schema ([90cbd8c](https://github.com/zeroxsolutions/zeroxsolutions/commit/90cbd8c))
160
+ - **db:** load a read's fieldsets and include paths off the schema ([90cbd8c](https://github.com/zeroxsolutions/zeroxsolutions/commit/90cbd8c))
137
161
  `include` and `fields` are typed by `TableQuery`, so an
138
162
  unguarded `string[]` no longer compiles where the schema generics are given;
139
163
  `buildRelationalWith` is replaced by `buildRelationalRead`; the view
@@ -153,14 +177,14 @@ This was a version bump only for @zeroxsolutions/db to align it with other proje
153
177
 
154
178
  ### 🩹 Fixes
155
179
 
156
- - ⚠️ **db:** the audit columns store an instant, not a wall clock ([8760d45](https://github.com/zeroxsolutions/zeroxsolutions/commit/8760d45))
180
+ - ⚠️ **db:** the audit columns store an instant, not a wall clock ([8760d45](https://github.com/zeroxsolutions/zeroxsolutions/commit/8760d45))
157
181
 
158
- ### ⚠️ Breaking Changes
182
+ ### ⚠️ Breaking Changes
159
183
 
160
- - **db:** the audit columns store an instant, not a wall clock ([8760d45](https://github.com/zeroxsolutions/zeroxsolutions/commit/8760d45))
184
+ - **db:** the audit columns store an instant, not a wall clock ([8760d45](https://github.com/zeroxsolutions/zeroxsolutions/commit/8760d45))
161
185
  every Postgres consumer must run `drizzle:generate` and apply the
162
186
  migration it emits. Read that SQL before committing it: a bare `ALTER COLUMN ... SET DATA
163
- TYPE timestamptz` reinterprets existing values in the session's time zone, so a store
187
+ TYPE timestamptz` reinterprets existing values in the session's time zone, so a store
164
188
  holding rows needs `USING <column> AT TIME ZONE 'UTC'` written explicitly rather than the
165
189
  server default. A consumer that bumps without migrating keeps its old columns and gets the
166
190
  new type only on tables it creates afterwards, which is the state this is worth a major to
@@ -184,11 +208,11 @@ This was a version bump only for @zeroxsolutions/db to align it with other proje
184
208
 
185
209
  ### 💅 Refactors
186
210
 
187
- - ⚠️ **query,response,server:** one wire spelling, flat exports ([5c57b3a](https://github.com/zeroxsolutions/zeroxsolutions/commit/5c57b3a))
211
+ - ⚠️ **query,response,server:** one wire spelling, flat exports ([5c57b3a](https://github.com/zeroxsolutions/zeroxsolutions/commit/5c57b3a))
188
212
 
189
- ### ⚠️ Breaking Changes
213
+ ### ⚠️ Breaking Changes
190
214
 
191
- - **query,response,server:** one wire spelling, flat exports ([5c57b3a](https://github.com/zeroxsolutions/zeroxsolutions/commit/5c57b3a))
215
+ - **query,response,server:** one wire spelling, flat exports ([5c57b3a](https://github.com/zeroxsolutions/zeroxsolutions/commit/5c57b3a))
192
216
  `@zeroxsolutions/response/http`, `@zeroxsolutions/query/http`
193
217
  and `@zeroxsolutions/server/hono/http` are gone, and the surviving `/jsonapi`
194
218
  and `/hono` subpaths move to the package root. Every import drops its subpath.
@@ -223,11 +247,11 @@ This was a version bump only for @zeroxsolutions/db to align it with other proje
223
247
 
224
248
  ### 🚀 Features
225
249
 
226
- - ⚠️ **db:** let each table declare its own id column ([eca4aca](https://github.com/zeroxsolutions/zeroxsolutions/commit/eca4aca))
250
+ - ⚠️ **db:** let each table declare its own id column ([eca4aca](https://github.com/zeroxsolutions/zeroxsolutions/commit/eca4aca))
227
251
 
228
- ### ⚠️ Breaking Changes
252
+ ### ⚠️ Breaking Changes
229
253
 
230
- - **db:** let each table declare its own id column ([eca4aca](https://github.com/zeroxsolutions/zeroxsolutions/commit/eca4aca))
254
+ - **db:** let each table declare its own id column ([eca4aca](https://github.com/zeroxsolutions/zeroxsolutions/commit/eca4aca))
231
255
  `baseTable` is removed - take `idColumn` and spread
232
256
  `timestamps`. `defaultIndexedBaseColumns` is now `timestampIndexes` and
233
257
  `BaseTableShape` is `TimestampColumns`; both were named for the removed concept.
@@ -373,11 +397,11 @@ This was a version bump only for @zeroxsolutions/db to align it with other proje
373
397
 
374
398
  ### 🚀 Features
375
399
 
376
- - ⚠️ **db:** speak the wire's query vocabulary, and only it ([12801f0](https://github.com/zeroxsolutions/zeroxsolutions/commit/12801f0))
400
+ - ⚠️ **db:** speak the wire's query vocabulary, and only it ([12801f0](https://github.com/zeroxsolutions/zeroxsolutions/commit/12801f0))
377
401
 
378
- ### ⚠️ Breaking Changes
402
+ ### ⚠️ Breaking Changes
379
403
 
380
- - **db:** speak the wire's query vocabulary, and only it ([12801f0](https://github.com/zeroxsolutions/zeroxsolutions/commit/12801f0))
404
+ - **db:** speak the wire's query vocabulary, and only it ([12801f0](https://github.com/zeroxsolutions/zeroxsolutions/commit/12801f0))
381
405
  `FindAllOptions.orderBy` is now `sort` and `FilterQuery.filters`
382
406
  is now `filter`, in both the Postgres and D1 dialects. `baseQuerySchema`,
383
407
  `BaseQuery`, `OrderByValue` and `orderByValueSchema` are removed; a sort clause
@@ -439,11 +463,11 @@ This was a version bump only for @zeroxsolutions/db to align it with other proje
439
463
  ### 🚀 Features
440
464
 
441
465
  - **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))
466
+ - ⚠️ **db:** carry filters as a tree and refuse an inapplicable one ([1706c52](https://github.com/zeroxsolutions/zeroxsolutions/commit/1706c52))
443
467
 
444
- ### ⚠️ Breaking Changes
468
+ ### ⚠️ Breaking Changes
445
469
 
446
- - **db:** carry filters as a tree and refuse an inapplicable one ([1706c52](https://github.com/zeroxsolutions/zeroxsolutions/commit/1706c52))
470
+ - **db:** carry filters as a tree and refuse an inapplicable one ([1706c52](https://github.com/zeroxsolutions/zeroxsolutions/commit/1706c52))
447
471
  `buildFilterConditions` takes one options object;
448
472
  `filters` is a `FilterNode[]` tree; `baseQuerySchema` validates decoded
449
473
  objects and has no `search`; `serializeQuery` is removed - rendering the
@@ -626,4 +650,4 @@ This was a version bump only for @zeroxsolutions/db to align it with other proje
626
650
 
627
651
  ### ❤️ Thank You
628
652
 
629
- - Lương Văn Tú
653
+ - 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,132 @@ 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)
133
139
  };
140
+ type FindAllOptions = FindOneOptions & {
141
+ filter?: FilterNode[]; // the condition tree; root nodes join by `and`
142
+ orderBy?: { field: string; direction: 'asc' | 'desc' }[]; // the primary key is appended
143
+ limit?: number; // with none, every match
144
+ offset?: number;
145
+ };
146
+ type CountOptions = { filter?: FilterNode[]; softDeleted?: boolean; at?: Date };
134
147
  ```
135
148
 
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.
149
+ A read holds no transaction: one that must see a write in progress belongs to an aggregate repository on the unit of
150
+ work's executor. The writes still take `tx`.
151
+
152
+ | `read` holds | The finder loads |
153
+ | ------------------------------ | --------------------------------------------------------------------------- |
154
+ | nothing | every column of the root table, and no relation |
155
+ | `columns` | those columns; on the root table its primary key beside them |
156
+ | `relations.<name>` | that relation under its own nested `read`, every column where it names none |
157
+ | `relations.<name>.orderBy` | the related rows in that order, in place of the one `READS` declares |
158
+ | `relations.<name>.softDeleted` | that relation's marked rows too |
159
+
160
+ The soft-delete condition holds at every level: a marked related row of a relation to one loads as `null`, and the
161
+ marked rows of a relation to many are left out. A name the table does not hold throws `UnknownReadName`, naming its
162
+ path. A read loading no relation takes a plain `select`, so a repository given no `queryKey` still narrows its row.
140
163
 
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:
164
+ `findAll` and `findAllAndCount` order by `orderBy` followed by each primary key column it does not already name,
165
+ ascending, on both Postgres and D1. Rows that tie have no order of their own, so without the key two pages could be cut
166
+ from two orders of the same rows, repeating one row and skipping another.
143
167
 
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 |
168
+ A filter or an order name resolves to a `virtual` name of the context's `READS` first, then to a relation to one row,
169
+ compared by the related row's id (`equals` and `in` only), then to a column; anything else throws `UnknownQueryField`
170
+ before any SQL is built.
151
171
 
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`.
172
+ #### READS
173
+
174
+ ```ts
175
+ import { defineReads } from '@zeroxsolutions/db/utils';
176
+
177
+ export const READS = defineReads(schema, {
178
+ openingHoursTable: { orderBy: [{ field: 'weekday', direction: 'asc' }] },
179
+ placesTable: {
180
+ virtual: {
181
+ 'openState.status': {
182
+ filter: (condition, { at }) => sql`(<an expression over ${placesTable.id} and ${at}>) = ${condition.value}`,
183
+ order: ({ at }) => sql`<an expression over ${placesTable.id} and ${at}>`,
184
+ },
185
+ },
186
+ },
187
+ });
188
+ ```
189
+
190
+ Each key is a table's export name and each order field one of its columns, checked when `defineReads` runs. A
191
+ `virtual` filter expression is wrapped in parentheses, so its `or` cannot reach the soft-delete condition.
155
192
 
156
193
  #### Nullify FK
157
194
 
158
- Set a foreign key column to `null` where it matches a given value. Useful when soft-deleting a parent but keeping child rows:
195
+ `nullifyBy` sets a foreign key column to `null` where it matches a given value, from inside a named method:
159
196
 
160
197
  ```ts
161
198
  // Set categoryId = null on all transactions where categoryId = '123'
162
- await transactionRepo.nullifyBy('categoryId', '123', { tx });
199
+ detachCategory(categoryId: string, tx: Transaction) {
200
+ return this.nullifyBy('categoryId', categoryId, { tx });
201
+ }
163
202
  ```
164
203
 
165
204
  #### Transactions
166
205
 
167
- All methods accept an optional `tx` in their options:
206
+ Every write accepts an optional `tx` in its options:
168
207
 
169
208
  ```ts
170
209
  await db.transaction(async (tx) => {
@@ -182,7 +221,7 @@ import { userSummaryView } from './views';
182
221
  // The same schema-typed client the service's table repositories take.
183
222
  export class UserSummaryViewRepository extends BaseViewRepository<typeof userSummaryView, typeof schema> {
184
223
  constructor(db: PostgresJsDatabase<typeof schema>) {
185
- super(db, userSummaryView);
224
+ super(db, userSummaryView, ['tenantId']); // the columns of the view's unique index
186
225
  }
187
226
  }
188
227
 
@@ -190,10 +229,13 @@ export class UserSummaryViewRepository extends BaseViewRepository<typeof userSum
190
229
  await repo.refresh({ concurrently: true });
191
230
  ```
192
231
 
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.
232
+ A view repository reads with the options a table repository takes, without relations, `softDeleted` or `at`:
233
+ `FindAllViewOptions` holds a `read` of `columns`, `filter`, `orderBy`, `limit` and `offset`, `FindOneViewOptions` the
234
+ `read`, and `CountViewOptions` the `filter`. Its public `findAll`, `findAllAndCount` and `count` take no condition, and
235
+ the protected `findAllWhere`, `findOneWhere`, `findAllAndCountWhere` and `countWhere` do. The constructor's third
236
+ argument, the columns of the view's unique index, is appended to every order: a materialized view is read back with no
237
+ order of its own, and PostgreSQL documents that different `LIMIT` and `OFFSET` values give inconsistent results
238
+ without an `ORDER BY` that fixes one.
197
239
 
198
240
  ### Unit of Work & Transactional Outbox
199
241
 
@@ -330,27 +372,8 @@ sends it), with the same two indexes over the unsent rows as the Postgres table.
330
372
 
331
373
  ## Utilities
332
374
 
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
- ```
375
+ Import from `@zeroxsolutions/db/utils`. The builders that turn a filter and an order into SQL are the base
376
+ repository's own; a finder hands `filter` and `orderBy` to it unchanged.
354
377
 
355
378
  #### Supported Operators
356
379
 
@@ -368,90 +391,32 @@ All 15, as `filterOperatorSchema` declares them:
368
391
 
369
392
  A `%` or `_` in a text value is escaped, so it matches literally rather than as a wildcard.
370
393
 
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
- ```
394
+ ### relationsOf
393
395
 
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';
426
-
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
- ```
396
+ `relationsOf(schema, '<table export>')` answers the relations the schema declares on that table, each with the type
397
+ its fieldset is keyed by (its table's SQL name), its primary key, and its own relations, read when a path walks into
398
+ them. A permit list carries it as `relationships`, `permitsFor` checks a relationship filter's name against it, and
399
+ `findOptionsOf` in `@zeroxsolutions/jsonapi` finds an included relation's fieldset through it. It throws on a key that
400
+ is no table export (a table's SQL name is not one).
435
401
 
436
402
  ### Reading the query off a request
437
403
 
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:
404
+ This package ships **no** query schema and knows no wire format. Parse the request with
405
+ [`@zeroxsolutions/query`](../query), and turn the guarded query into finder options with `findOptionsOf` from
406
+ [`@zeroxsolutions/jsonapi`](../jsonapi):
441
407
 
442
408
  ```ts
443
409
  import { collectionQuerySchema } from '@zeroxsolutions/query';
410
+ import { findOptionsOf } from '@zeroxsolutions/jsonapi';
444
411
 
445
412
  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,
413
+ const [rows, total] = await usersRepository.findAllAndCountByTenantId(
414
+ tenantId,
415
+ findOptionsOf(c.req.valid('query'), usersQueryPermits),
450
416
  );
451
417
  ```
452
418
 
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.
419
+ Pass `filterItem: filterValueSchema` in the permits so the parsed clauses are the ones this package's finders accept.
455
420
 
456
421
  ### Omit Constants
457
422
 
@@ -470,15 +435,6 @@ import { defaultInsertOmit } from '@zeroxsolutions/db/utils';
470
435
  type InsertUser = Omit<typeof users.$inferInsert, keyof typeof defaultInsertOmit>;
471
436
  ```
472
437
 
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
438
  ---
483
439
 
484
440
  ## Testing Seam