@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.
- package/CHANGELOG.md +47 -23
- package/README.md +136 -180
- package/dist/base/d1/repository.d.ts +75 -66
- package/dist/base/d1/repository.d.ts.map +1 -1
- package/dist/base/d1/repository.js +134 -122
- package/dist/base/postgres/repository.d.ts +108 -86
- package/dist/base/postgres/repository.d.ts.map +1 -1
- package/dist/base/postgres/repository.js +175 -143
- package/dist/base/read-engine.d.ts +49 -0
- package/dist/base/read-engine.d.ts.map +1 -0
- package/dist/base/read-engine.js +93 -0
- package/dist/utils/filter.d.ts +40 -61
- package/dist/utils/filter.d.ts.map +1 -1
- package/dist/utils/filter.js +51 -85
- package/dist/utils/index.d.ts +3 -5
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +3 -5
- package/dist/utils/read.d.ts +86 -0
- package/dist/utils/read.d.ts.map +1 -0
- package/dist/utils/read.js +124 -0
- package/dist/utils/reads.d.ts +24 -0
- package/dist/utils/reads.d.ts.map +1 -0
- package/dist/utils/reads.js +23 -0
- package/dist/utils/relationships.d.ts +9 -6
- package/dist/utils/relationships.d.ts.map +1 -1
- package/dist/utils/relationships.js +26 -8
- package/package.json +2 -2
- package/dist/utils/assert-queryable-fields.d.ts +0 -27
- package/dist/utils/assert-queryable-fields.d.ts.map +0 -1
- package/dist/utils/assert-queryable-fields.js +0 -47
- package/dist/utils/constants.d.ts +0 -7
- package/dist/utils/constants.d.ts.map +0 -1
- package/dist/utils/constants.js +0 -6
- package/dist/utils/include.d.ts +0 -80
- package/dist/utils/include.d.ts.map +0 -1
- package/dist/utils/include.js +0 -132
- package/dist/utils/table-query.d.ts +0 -41
- package/dist/utils/table-query.d.ts.map +0 -1
- package/dist/utils/table-query.js +0 -1
- package/dist/utils/total-order.d.ts +0 -15
- package/dist/utils/total-order.d.ts.map +0 -1
- 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
|
-
- ⚠️
|
|
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
|
-
### ⚠️
|
|
136
|
+
### ⚠️ Breaking Changes
|
|
113
137
|
|
|
114
|
-
- **db:** take zod and drizzle-orm from the host, not a second copy
|
|
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
|
-
- ⚠️
|
|
156
|
+
- ⚠️ **db:** load a read's fieldsets and include paths off the schema ([90cbd8c](https://github.com/zeroxsolutions/zeroxsolutions/commit/90cbd8c))
|
|
133
157
|
|
|
134
|
-
### ⚠️
|
|
158
|
+
### ⚠️ Breaking Changes
|
|
135
159
|
|
|
136
|
-
- **db:** load a read's fieldsets and include paths off the schema
|
|
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
|
-
- ⚠️
|
|
180
|
+
- ⚠️ **db:** the audit columns store an instant, not a wall clock ([8760d45](https://github.com/zeroxsolutions/zeroxsolutions/commit/8760d45))
|
|
157
181
|
|
|
158
|
-
### ⚠️
|
|
182
|
+
### ⚠️ Breaking Changes
|
|
159
183
|
|
|
160
|
-
- **db:** the audit columns store an instant, not a wall clock
|
|
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
|
-
|
|
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
|
-
- ⚠️
|
|
211
|
+
- ⚠️ **query,response,server:** one wire spelling, flat exports ([5c57b3a](https://github.com/zeroxsolutions/zeroxsolutions/commit/5c57b3a))
|
|
188
212
|
|
|
189
|
-
### ⚠️
|
|
213
|
+
### ⚠️ Breaking Changes
|
|
190
214
|
|
|
191
|
-
- **query,response,server:** one wire spelling, flat exports
|
|
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
|
-
- ⚠️
|
|
250
|
+
- ⚠️ **db:** let each table declare its own id column ([eca4aca](https://github.com/zeroxsolutions/zeroxsolutions/commit/eca4aca))
|
|
227
251
|
|
|
228
|
-
### ⚠️
|
|
252
|
+
### ⚠️ Breaking Changes
|
|
229
253
|
|
|
230
|
-
- **db:** let each table declare its own id column
|
|
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
|
-
- ⚠️
|
|
400
|
+
- ⚠️ **db:** speak the wire's query vocabulary, and only it ([12801f0](https://github.com/zeroxsolutions/zeroxsolutions/commit/12801f0))
|
|
377
401
|
|
|
378
|
-
### ⚠️
|
|
402
|
+
### ⚠️ Breaking Changes
|
|
379
403
|
|
|
380
|
-
- **db:** speak the wire's query vocabulary, and only it
|
|
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
|
-
- ⚠️
|
|
466
|
+
- ⚠️ **db:** carry filters as a tree and refuse an inapplicable one ([1706c52](https://github.com/zeroxsolutions/zeroxsolutions/commit/1706c52))
|
|
443
467
|
|
|
444
|
-
### ⚠️
|
|
468
|
+
### ⚠️ Breaking Changes
|
|
445
469
|
|
|
446
|
-
- **db:** carry filters as a tree and refuse an inapplicable one
|
|
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`
|
|
9
|
-
|
|
10
|
-
-
|
|
11
|
-
|
|
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`, `
|
|
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` |
|
|
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
|
|
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
|
|
87
|
-
import
|
|
87
|
+
import * as schema from './schema';
|
|
88
|
+
import { READS, usersTable } from './schema';
|
|
88
89
|
|
|
89
|
-
export class UserRepository extends BaseRepository<typeof
|
|
90
|
+
export class UserRepository extends BaseRepository<typeof usersTable, typeof schema, 'usersTable'> {
|
|
90
91
|
constructor(db: PostgresJsDatabase<typeof schema>) {
|
|
91
|
-
super(db,
|
|
92
|
+
super(db, usersTable, 'usersTable', READS); // the export name, and the context's READS
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
|
|
95
|
-
return this.
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
|
108
|
-
|
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
111
|
-
| `
|
|
112
|
-
| `
|
|
113
|
-
| `
|
|
114
|
-
| `count(
|
|
115
|
-
| `
|
|
116
|
-
| `
|
|
117
|
-
| `
|
|
118
|
-
| `
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
`
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
+
detachCategory(categoryId: string, tx: Transaction) {
|
|
200
|
+
return this.nullifyBy('categoryId', categoryId, { tx });
|
|
201
|
+
}
|
|
163
202
|
```
|
|
164
203
|
|
|
165
204
|
#### Transactions
|
|
166
205
|
|
|
167
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
440
|
-
|
|
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
|
|
447
|
-
|
|
448
|
-
|
|
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
|
|
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
|