@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.
- package/CHANGELOG.md +57 -22
- package/README.md +149 -180
- package/dist/base/d1/entity.d.ts.map +1 -1
- package/dist/base/d1/outbox.d.ts.map +1 -1
- package/dist/base/d1/outbox.js +1 -3
- package/dist/base/d1/repository.d.ts +76 -66
- package/dist/base/d1/repository.d.ts.map +1 -1
- package/dist/base/d1/repository.js +134 -122
- package/dist/base/d1/unit-of-work.d.ts.map +1 -1
- package/dist/base/postgres/entity.d.ts.map +1 -1
- package/dist/base/postgres/outbox.d.ts.map +1 -1
- package/dist/base/postgres/outbox.js +1 -3
- package/dist/base/postgres/repository.d.ts +109 -86
- package/dist/base/postgres/repository.d.ts.map +1 -1
- package/dist/base/postgres/repository.js +182 -143
- package/dist/base/postgres/unit-of-work.d.ts.map +1 -1
- package/dist/base/read-engine.d.ts +48 -0
- package/dist/base/read-engine.d.ts.map +1 -0
- package/dist/base/read-engine.js +94 -0
- package/dist/utils/filter.d.ts +44 -59
- 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 +88 -0
- package/dist/utils/read.d.ts.map +1 -0
- package/dist/utils/read.js +124 -0
- package/dist/utils/reads.d.ts +30 -0
- package/dist/utils/reads.d.ts.map +1 -0
- package/dist/utils/reads.js +26 -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 +7 -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,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
|
-
- ⚠️
|
|
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
|
-
### ⚠️
|
|
147
|
+
### ⚠️ Breaking Changes
|
|
113
148
|
|
|
114
|
-
- **db:** take zod and drizzle-orm from the host, not a second copy
|
|
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
|
-
- ⚠️
|
|
167
|
+
- ⚠️ **db:** load a read's fieldsets and include paths off the schema ([90cbd8c](https://github.com/zeroxsolutions/zeroxsolutions/commit/90cbd8c))
|
|
133
168
|
|
|
134
|
-
### ⚠️
|
|
169
|
+
### ⚠️ Breaking Changes
|
|
135
170
|
|
|
136
|
-
- **db:** load a read's fieldsets and include paths off the schema
|
|
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
|
-
- ⚠️
|
|
191
|
+
- ⚠️ **db:** the audit columns store an instant, not a wall clock ([8760d45](https://github.com/zeroxsolutions/zeroxsolutions/commit/8760d45))
|
|
157
192
|
|
|
158
|
-
### ⚠️
|
|
193
|
+
### ⚠️ Breaking Changes
|
|
159
194
|
|
|
160
|
-
- **db:** the audit columns store an instant, not a wall clock
|
|
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
|
-
- ⚠️
|
|
222
|
+
- ⚠️ **query,response,server:** one wire spelling, flat exports ([5c57b3a](https://github.com/zeroxsolutions/zeroxsolutions/commit/5c57b3a))
|
|
188
223
|
|
|
189
|
-
### ⚠️
|
|
224
|
+
### ⚠️ Breaking Changes
|
|
190
225
|
|
|
191
|
-
- **query,response,server:** one wire spelling, flat exports
|
|
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
|
-
- ⚠️
|
|
261
|
+
- ⚠️ **db:** let each table declare its own id column ([eca4aca](https://github.com/zeroxsolutions/zeroxsolutions/commit/eca4aca))
|
|
227
262
|
|
|
228
|
-
### ⚠️
|
|
263
|
+
### ⚠️ Breaking Changes
|
|
229
264
|
|
|
230
|
-
- **db:** let each table declare its own id column
|
|
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
|
-
- ⚠️
|
|
411
|
+
- ⚠️ **db:** speak the wire's query vocabulary, and only it ([12801f0](https://github.com/zeroxsolutions/zeroxsolutions/commit/12801f0))
|
|
377
412
|
|
|
378
|
-
### ⚠️
|
|
413
|
+
### ⚠️ Breaking Changes
|
|
379
414
|
|
|
380
|
-
- **db:** speak the wire's query vocabulary, and only it
|
|
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
|
-
- ⚠️
|
|
477
|
+
- ⚠️ **db:** carry filters as a tree and refuse an inapplicable one ([1706c52](https://github.com/zeroxsolutions/zeroxsolutions/commit/1706c52))
|
|
443
478
|
|
|
444
|
-
### ⚠️
|
|
479
|
+
### ⚠️ Breaking Changes
|
|
445
480
|
|
|
446
|
-
- **db:** carry filters as a tree and refuse an inapplicable one
|
|
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`
|
|
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,145 @@ 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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
`
|
|
142
|
-
|
|
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
|
-
|
|
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 |
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
+
detachCategory(categoryId: string, tx: Transaction) {
|
|
213
|
+
return this.nullifyBy('categoryId', categoryId, { tx });
|
|
214
|
+
}
|
|
163
215
|
```
|
|
164
216
|
|
|
165
217
|
#### Transactions
|
|
166
218
|
|
|
167
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
|
440
|
-
|
|
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
|
|
447
|
-
|
|
448
|
-
|
|
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
|
|
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,
|
|
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;
|
|
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"}
|
package/dist/base/d1/outbox.js
CHANGED
|
@@ -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
|
})));
|