@zeroxsolutions/db 9.4.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 +58 -23
- package/README.md +163 -203
- package/dist/base/d1/outbox-table.d.ts.map +1 -1
- package/dist/base/d1/outbox-table.js +9 -1
- 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/outbox-table.d.ts.map +1 -1
- package/dist/base/postgres/outbox-table.js +9 -1
- 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 +3 -3
- 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/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
|
|
37
|
-
|
|
|
38
|
-
| `@zeroxsolutions/db/postgres`
|
|
39
|
-
| `@zeroxsolutions/db/d1`
|
|
40
|
-
| `@zeroxsolutions/db/utils`
|
|
41
|
-
| `@zeroxsolutions/db/testing`
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
+
};
|
|
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;
|
|
133
145
|
};
|
|
146
|
+
type CountOptions = { filter?: FilterNode[]; softDeleted?: boolean; at?: Date };
|
|
134
147
|
```
|
|
135
148
|
|
|
136
|
-
|
|
137
|
-
|
|
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`.
|
|
140
151
|
|
|
141
|
-
|
|
142
|
-
|
|
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 |
|
|
143
159
|
|
|
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 |
|
|
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.
|
|
151
163
|
|
|
152
|
-
`
|
|
153
|
-
|
|
154
|
-
|
|
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.
|
|
167
|
+
|
|
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.
|
|
171
|
+
|
|
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
|
|
|
@@ -210,7 +252,7 @@ import { DrizzleUnitOfWork, DrizzleOutbox } from '@zeroxsolutions/db/postgres';
|
|
|
210
252
|
|
|
211
253
|
// built in the transport per invocation (the db client comes from the connection string)
|
|
212
254
|
const uow = new DrizzleUnitOfWork(db, (tx) => {
|
|
213
|
-
const scope = container.createChildContainer();
|
|
255
|
+
const scope = container.createChildContainer(); // bind the transaction to a scoped container
|
|
214
256
|
scope.register(TOKENS.TX, { useValue: tx });
|
|
215
257
|
scope.register(TOKENS.OUTBOX, { useValue: new DrizzleOutbox(tx) });
|
|
216
258
|
return scope;
|
|
@@ -224,9 +266,14 @@ await uow.run(async (tx) => {
|
|
|
224
266
|
```
|
|
225
267
|
|
|
226
268
|
`outboxTable` columns: `id` (uuid v7), `type` (event discriminator), `payload` (`jsonb`), `occurredAt`,
|
|
227
|
-
and `publishedAt` (NULL until the relay sends it
|
|
269
|
+
and `publishedAt` (NULL until the relay sends it). The relay reads unsent rows with
|
|
228
270
|
`outbox.pullUnpublished(limit)` and acknowledges them with `outbox.markPublished(ids, publishedAt)`.
|
|
229
271
|
|
|
272
|
+
Two indexes hold the unsent rows alone, so the published ones never slow a relay down:
|
|
273
|
+
`idx_outbox_unpublished_occurred_at` on `occurredAt`, for a relay that sends every type, and
|
|
274
|
+
`idx_outbox_unpublished_type_occurred_at` on `(type, occurredAt)`, for one that sends only some. Both
|
|
275
|
+
answer in `occurredAt` order without a sort.
|
|
276
|
+
|
|
230
277
|
---
|
|
231
278
|
|
|
232
279
|
## Cloudflare D1 (SQLite)
|
|
@@ -289,7 +336,7 @@ import { DrizzleD1UnitOfWork, DrizzleOutbox } from '@zeroxsolutions/db/d1';
|
|
|
289
336
|
|
|
290
337
|
// built in the transport per invocation (the db client comes from the Hyperdrive/D1 binding)
|
|
291
338
|
const uow = new DrizzleD1UnitOfWork(db, ({ db, collector }) => {
|
|
292
|
-
const scope = container.createChildContainer();
|
|
339
|
+
const scope = container.createChildContainer(); // reads use db; writes push to collector
|
|
293
340
|
scope.register(TOKENS.TX, { useValue: { db, collector } });
|
|
294
341
|
scope.register(TOKENS.OUTBOX, { useValue: new DrizzleOutbox(db, collector) });
|
|
295
342
|
return scope;
|
|
@@ -299,7 +346,7 @@ const uow = new DrizzleD1UnitOfWork(db, ({ db, collector }) => {
|
|
|
299
346
|
await uow.run(async (tx) => {
|
|
300
347
|
await tx.resolve<ILinkAggregateRepository>(TOKENS.LINK_AGGREGATE_REPOSITORY).save(link);
|
|
301
348
|
await tx.resolve<IOutboxWriter>(TOKENS.OUTBOX).stage(link.pullEvents());
|
|
302
|
-
});
|
|
349
|
+
}); // -> flush [save, ...stage] as ONE batch()
|
|
303
350
|
```
|
|
304
351
|
|
|
305
352
|
A consuming context's D1 aggregate repository takes both the `db` (for immediate `load` reads) and
|
|
@@ -315,7 +362,7 @@ The D1 `DrizzleOutbox` mirrors the Postgres surface (`stage` / `pullUnpublished(
|
|
|
315
362
|
(so it commits with the aggregate write), or executes immediately when constructed standalone (relay
|
|
316
363
|
use). `outboxTable` columns: `id` (uuid v7), `type` (event discriminator), `payload` (text - SQLite
|
|
317
364
|
has no `jsonb`, so the relay `JSON.parse`s), `occurredAt`, and `publishedAt` (NULL until the relay
|
|
318
|
-
sends it
|
|
365
|
+
sends it), with the same two indexes over the unsent rows as the Postgres table.
|
|
319
366
|
|
|
320
367
|
> **Limitation.** D1's `batch()` is atomic but non-interactive, so the D1 UoW gives all-or-nothing
|
|
321
368
|
> write atomicity without a transaction-scoped read snapshot. A handler that loads, decides, then
|
|
@@ -325,140 +372,62 @@ sends it; indexed).
|
|
|
325
372
|
|
|
326
373
|
## Utilities
|
|
327
374
|
|
|
328
|
-
Import from `@zeroxsolutions/db/utils`.
|
|
329
|
-
|
|
330
|
-
### Filter Builder
|
|
331
|
-
|
|
332
|
-
Converts typed filter descriptors into Drizzle `SQL` conditions:
|
|
333
|
-
|
|
334
|
-
Takes one options object, so a parsed wire query spreads straight in. Returns `undefined` when the
|
|
335
|
-
tree contributes no condition, and throws `UnknownQueryField` when a clause names a column the table
|
|
336
|
-
does not hold:
|
|
337
|
-
|
|
338
|
-
```ts
|
|
339
|
-
import { buildFilterConditions } from '@zeroxsolutions/db/utils';
|
|
340
|
-
|
|
341
|
-
const condition = buildFilterConditions({
|
|
342
|
-
table: users,
|
|
343
|
-
filter: [
|
|
344
|
-
{ field: 'name', operator: 'contains', value: 'alice' },
|
|
345
|
-
{ field: 'createdAt', operator: 'gte', value: '2024-01-01' },
|
|
346
|
-
],
|
|
347
|
-
});
|
|
348
|
-
```
|
|
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.
|
|
349
377
|
|
|
350
378
|
#### Supported Operators
|
|
351
379
|
|
|
352
380
|
All 15, as `filterOperatorSchema` declares them:
|
|
353
381
|
|
|
354
|
-
| Operator
|
|
355
|
-
|
|
|
356
|
-
| `equals` / `notEquals`
|
|
357
|
-
| `in` / `notIn`
|
|
382
|
+
| Operator | Description |
|
|
383
|
+
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
384
|
+
| `equals` / `notEquals` | Exact match / not equal |
|
|
385
|
+
| `in` / `notIn` | Array membership; an empty array contributes no condition |
|
|
358
386
|
| `contains` / `startsWith` / `endsWith` | Case-insensitive text match: `ILIKE` on Postgres, `LIKE` on SQLite, which is already case-insensitive for ASCII |
|
|
359
|
-
| `gt` / `gte` / `lt` / `lte`
|
|
360
|
-
| `between`
|
|
361
|
-
| `notBetween`
|
|
362
|
-
| `isNull` / `isNotNull`
|
|
387
|
+
| `gt` / `gte` / `lt` / `lte` | Numeric/date comparisons |
|
|
388
|
+
| `between` | Range (`from` / `to`, both optional) |
|
|
389
|
+
| `notBetween` | Range exclusion (`from` and `to`, both required) |
|
|
390
|
+
| `isNull` / `isNotNull` | Null checks; carry no `value` key at all |
|
|
363
391
|
|
|
364
392
|
A `%` or `_` in a text value is escaped, so it matches literally rather than as a wildcard.
|
|
365
393
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
A relationship named in a permit list is no column - its id sits in a foreign-key column instead, which
|
|
369
|
-
`buildFilterConditions` cannot resolve on its own. Declare the permit through `permitsFor`'s own
|
|
370
|
-
`relationship(name, operators)` (`@zeroxsolutions/query`), then hand `columnResolver` the foreign-key
|
|
371
|
-
column under that same name in `resolvers`:
|
|
372
|
-
|
|
373
|
-
```ts
|
|
374
|
-
import { permitsFor } from '@zeroxsolutions/query';
|
|
375
|
-
import { buildFilterConditions, columnResolver } from '@zeroxsolutions/db/utils';
|
|
376
|
-
|
|
377
|
-
const placesQueryPermits = permitsFor(placeAttributes, { relationships: places.relationships }).relationship(
|
|
378
|
-
'region',
|
|
379
|
-
['equals', 'in'],
|
|
380
|
-
);
|
|
381
|
-
|
|
382
|
-
const condition = buildFilterConditions({
|
|
383
|
-
table: places,
|
|
384
|
-
filter: query.filter,
|
|
385
|
-
resolvers: { region: columnResolver(places.regionId) },
|
|
386
|
-
});
|
|
387
|
-
```
|
|
388
|
-
|
|
389
|
-
`columnResolver` answers `equals` and `in` - the only two operators a relationship filter grants -
|
|
390
|
-
comparing the id against the column it closes over, and throws on any other operator.
|
|
391
|
-
|
|
392
|
-
- `relationshipsOf(schema, '<table export>')` - the relations the schema declares on that table, as the map
|
|
393
|
-
`permitsFor(schema, { relationships })` takes, so a relationship filter names a relation the schema holds.
|
|
394
|
-
Throws on a key that is no table export (a table's SQL name is not one).
|
|
394
|
+
### relationsOf
|
|
395
395
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const clauses = buildOrderBy(users, [
|
|
402
|
-
{ field: 'createdAt', direction: 'desc' },
|
|
403
|
-
{ field: 'name', direction: 'asc' },
|
|
404
|
-
]);
|
|
405
|
-
```
|
|
406
|
-
|
|
407
|
-
`buildOrderBy` translates exactly the clauses it is given and appends no key, so it can build part of
|
|
408
|
-
an order one clause at a time. A read that pages over its result owes itself a unique last clause.
|
|
409
|
-
|
|
410
|
-
### Field Whitelist Guard
|
|
411
|
-
|
|
412
|
-
Reject a `filter`/`sort` on any field outside a listable entity's whitelist **before** building SQL, so a
|
|
413
|
-
client can never filter or order by a hidden column (e.g. an access code). `QueryableFields<TSelect>` is keyed
|
|
414
|
-
to the row's `$inferSelect` type, so a renamed/removed column fails typecheck at the whitelist. The guard is
|
|
415
|
-
transport-agnostic - it throws a plain `Error` subclass (`InvalidQueryFilter` / `InvalidQuerySort`) carrying
|
|
416
|
-
only the offending field name; the calling service maps each error **type** to its HTTP status/code at its
|
|
417
|
-
own transport.
|
|
418
|
-
|
|
419
|
-
```ts
|
|
420
|
-
import {
|
|
421
|
-
assertQueryableFields,
|
|
422
|
-
InvalidQueryFilter,
|
|
423
|
-
type QueryableFields,
|
|
424
|
-
} from '@zeroxsolutions/db/utils';
|
|
425
|
-
|
|
426
|
-
const USERS_QUERYABLE = {
|
|
427
|
-
filterable: ['name', 'createdAt'],
|
|
428
|
-
sortable: ['name', 'createdAt'],
|
|
429
|
-
} as const satisfies QueryableFields<typeof users.$inferSelect>;
|
|
430
|
-
|
|
431
|
-
// In a repository finder, before building SQL:
|
|
432
|
-
assertQueryableFields(query, USERS_QUERYABLE); // throws InvalidQueryFilter / InvalidQuerySort on a hidden field
|
|
433
|
-
```
|
|
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).
|
|
434
401
|
|
|
435
402
|
### Reading the query off a request
|
|
436
403
|
|
|
437
|
-
This package ships **no** query schema. Parse the request with
|
|
438
|
-
[`@zeroxsolutions/query`](../query) and
|
|
439
|
-
|
|
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):
|
|
440
407
|
|
|
441
408
|
```ts
|
|
442
409
|
import { collectionQuerySchema } from '@zeroxsolutions/query';
|
|
410
|
+
import { findOptionsOf } from '@zeroxsolutions/jsonapi';
|
|
443
411
|
|
|
444
412
|
createRoute({ request: { query: collectionQuerySchema(usersQueryPermits) } });
|
|
445
|
-
const
|
|
446
|
-
|
|
413
|
+
const [rows, total] = await usersRepository.findAllAndCountByTenantId(
|
|
414
|
+
tenantId,
|
|
415
|
+
findOptionsOf(c.req.valid('query'), usersQueryPermits),
|
|
416
|
+
);
|
|
447
417
|
```
|
|
448
418
|
|
|
449
|
-
Pass `filterItem: filterValueSchema` in the
|
|
450
|
-
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.
|
|
451
420
|
|
|
452
421
|
### Omit Constants
|
|
453
422
|
|
|
454
423
|
Three column masks, each a plain `{ column: true }` object to hand to a schema builder's `omit` or to
|
|
455
424
|
`Omit<...>`. This package exports the masks only - it ships no `omit` helper of its own.
|
|
456
425
|
|
|
457
|
-
| Mask
|
|
458
|
-
|
|
|
459
|
-
| `defaultSelectOmit`
|
|
426
|
+
| Mask | Drops |
|
|
427
|
+
| --------------------- | --------------------------------------- |
|
|
428
|
+
| `defaultSelectOmit` | `deletedAt` |
|
|
460
429
|
| `defaultResponseOmit` | the above plus `createdAt`, `updatedAt` |
|
|
461
|
-
| `defaultInsertOmit`
|
|
430
|
+
| `defaultInsertOmit` | the above plus `id` |
|
|
462
431
|
|
|
463
432
|
```ts
|
|
464
433
|
import { defaultInsertOmit } from '@zeroxsolutions/db/utils';
|
|
@@ -466,15 +435,6 @@ import { defaultInsertOmit } from '@zeroxsolutions/db/utils';
|
|
|
466
435
|
type InsertUser = Omit<typeof users.$inferInsert, keyof typeof defaultInsertOmit>;
|
|
467
436
|
```
|
|
468
437
|
|
|
469
|
-
### Pagination Constants
|
|
470
|
-
|
|
471
|
-
```ts
|
|
472
|
-
import { DEFAULT_PAGE_SIZE, DEFAULT_OFFSET, MAX_PAGE_SIZE } from '@zeroxsolutions/db/utils';
|
|
473
|
-
// DEFAULT_PAGE_SIZE = 20
|
|
474
|
-
// DEFAULT_OFFSET = 0
|
|
475
|
-
// MAX_PAGE_SIZE = 100
|
|
476
|
-
```
|
|
477
|
-
|
|
478
438
|
---
|
|
479
439
|
|
|
480
440
|
## Testing Seam
|
|
@@ -520,11 +480,11 @@ This package names no engine for you, and exports no constant standing in as tha
|
|
|
520
480
|
major you run is your deployment's own fact, so `image` takes no default: a forgotten argument is a
|
|
521
481
|
typecheck failure rather than a suite that quietly passed on a major you do not deploy.
|
|
522
482
|
|
|
523
|
-
| Export
|
|
524
|
-
|
|
|
483
|
+
| Export | What it is |
|
|
484
|
+
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
525
485
|
| `startDisposablePostgres(schema, image)` | Starts the container, creates `schema` in it, and returns a Drizzle client typed against it, the `connectionString` it dialled, and the `stop()` that tears it down. A server `WARNING` prints as one line on `console.warn`; lower severities are dropped |
|
|
526
|
-
| `CONTAINER_TIMEOUT_MS`
|
|
527
|
-
| `fakeUuidV7`
|
|
486
|
+
| `CONTAINER_TIMEOUT_MS` | 180s, for the hook that starts a container: an image pull on a cold machine outruns vitest's 5s default |
|
|
487
|
+
| `fakeUuidV7` | Random bits in a v7 layout: it passes a v7 check, and is not time-ordered like a minted id |
|
|
528
488
|
|
|
529
489
|
### A container you started yourself
|
|
530
490
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"outbox-table.d.ts","sourceRoot":"","sources":["../../../src/base/d1/outbox-table.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"outbox-table.d.ts","sourceRoot":"","sources":["../../../src/base/d1/outbox-table.ts"],"names":[],"mappings":"AAIA;;;;;GAKG;AACH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAoBvB,CAAC;AAEF,sGAAsG;AACtG,MAAM,MAAM,SAAS,GAAG,OAAO,WAAW,CAAC,YAAY,CAAC"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isNull } from 'drizzle-orm';
|
|
1
2
|
import { index, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
|
2
3
|
import { v7 as uuidv7 } from 'uuid';
|
|
3
4
|
/**
|
|
@@ -16,4 +17,11 @@ export const outboxTable = sqliteTable('outbox', {
|
|
|
16
17
|
.notNull()
|
|
17
18
|
.$defaultFn(() => new Date().toISOString()),
|
|
18
19
|
publishedAt: text('published_at'),
|
|
19
|
-
},
|
|
20
|
+
},
|
|
21
|
+
// Both hold the unpublished rows alone, so a backlog of published rows never slows a relay. The
|
|
22
|
+
// first answers a relay that publishes every type, the second one that publishes only some, each in
|
|
23
|
+
// `occurredAt` order without a sort.
|
|
24
|
+
(table) => [
|
|
25
|
+
index('idx_outbox_unpublished_occurred_at').on(table.occurredAt).where(isNull(table.publishedAt)),
|
|
26
|
+
index('idx_outbox_unpublished_type_occurred_at').on(table.type, table.occurredAt).where(isNull(table.publishedAt)),
|
|
27
|
+
]);
|