@zeroxsolutions/db 9.4.0 → 9.5.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
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
## 9.5.0 (2026-10-16)
|
|
2
|
+
|
|
3
|
+
### 🚀 Features
|
|
4
|
+
|
|
5
|
+
- **db:** index the outbox's unpublished rows alone, by type and by time ([6f9dc0b](https://github.com/zeroxsolutions/zeroxsolutions/commit/6f9dc0b))
|
|
6
|
+
|
|
7
|
+
### ❤️ Thank You
|
|
8
|
+
|
|
9
|
+
- Claude Opus 5.5
|
|
10
|
+
- Lương Văn Tú
|
|
11
|
+
|
|
1
12
|
## 9.4.0 (2026-10-14)
|
|
2
13
|
|
|
3
14
|
### 🚀 Features
|
package/README.md
CHANGED
|
@@ -33,13 +33,13 @@ Import from the subpath that matches your driver. **There is no root entry**: `p
|
|
|
33
33
|
declares `"."`, but no `src/index.ts` exists for the build to emit it from, so
|
|
34
34
|
`from '@zeroxsolutions/db'` does not resolve.
|
|
35
35
|
|
|
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
|
|
36
|
+
| Export path | Description |
|
|
37
|
+
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
38
|
+
| `@zeroxsolutions/db/postgres` | PostgreSQL entity, repository, view repository, unit of work, outbox, and types |
|
|
39
|
+
| `@zeroxsolutions/db/d1` | Cloudflare D1 (SQLite) entity, repository, unit of work, outbox, and types |
|
|
40
|
+
| `@zeroxsolutions/db/utils` | Filter and order builders, `TableQuery` and the relational-read builder, the field-whitelist guard, the filter-value schema + operator enum, omit masks, pagination constants |
|
|
41
|
+
| `@zeroxsolutions/db/testing` | A throwaway Postgres container carrying your schema, on the engine you name, plus the container timeout and a fake uuid v7 |
|
|
42
|
+
| `@zeroxsolutions/db/containers` | The forced remove that tears down a container you started yourself |
|
|
43
43
|
|
|
44
44
|
---
|
|
45
45
|
|
|
@@ -99,23 +99,23 @@ export class UserRepository extends BaseRepository<typeof users, typeof schema,
|
|
|
99
99
|
|
|
100
100
|
#### Available Methods
|
|
101
101
|
|
|
102
|
-
| Method
|
|
103
|
-
|
|
|
104
|
-
| `findAll(where, options?)`
|
|
105
|
-
| `findAllAndCount(where, options?)`
|
|
106
|
-
| `findOne(where, options?)`
|
|
107
|
-
| `findOneById(id, options?)`
|
|
108
|
-
| `create(data, options?)`
|
|
109
|
-
| `createMany(data[], options?)`
|
|
110
|
-
| `update(where, data, options?)`
|
|
111
|
-
| `updateById(id, data, options?)`
|
|
112
|
-
| `delete(where, options?)`
|
|
113
|
-
| `deleteById(id, options?)`
|
|
114
|
-
| `count(where, options?)`
|
|
115
|
-
| `exists(where, options?)`
|
|
116
|
-
| `softDelete(where, options?)`
|
|
117
|
-
| `softDeleteById(id, options?)`
|
|
118
|
-
| `nullifyBy(field, value, options?)` | Set field to `null` where field equals value
|
|
102
|
+
| Method | Description |
|
|
103
|
+
| ----------------------------------- | -------------------------------------------------- |
|
|
104
|
+
| `findAll(where, options?)` | Paginated list query |
|
|
105
|
+
| `findAllAndCount(where, options?)` | Paginated list + total count in parallel |
|
|
106
|
+
| `findOne(where, options?)` | First matching record or `null` |
|
|
107
|
+
| `findOneById(id, options?)` | Find by primary key or `null` |
|
|
108
|
+
| `create(data, options?)` | Insert one record, returns inserted row |
|
|
109
|
+
| `createMany(data[], options?)` | Bulk insert, returns inserted rows |
|
|
110
|
+
| `update(where, data, options?)` | Update matching records, returns first updated row |
|
|
111
|
+
| `updateById(id, data, options?)` | Update by primary key |
|
|
112
|
+
| `delete(where, options?)` | Hard delete matching records |
|
|
113
|
+
| `deleteById(id, options?)` | Hard delete by primary key |
|
|
114
|
+
| `count(where, options?)` | Count matching records |
|
|
115
|
+
| `exists(where, options?)` | Returns `true` if any record matches |
|
|
116
|
+
| `softDelete(where, options?)` | Sets `deletedAt` to now |
|
|
117
|
+
| `softDeleteById(id, options?)` | Soft delete by primary key |
|
|
118
|
+
| `nullifyBy(field, value, options?)` | Set field to `null` where field equals value |
|
|
119
119
|
|
|
120
120
|
#### FindAll Options
|
|
121
121
|
|
|
@@ -141,13 +141,13 @@ skipping another. With no `sort`, a page is ordered by the key alone.
|
|
|
141
141
|
`fields` and `include` are `TableQuery<typeof schema, '<export name>'>`, the shape `@zeroxsolutions/query` guards, so
|
|
142
142
|
a guarded query spreads straight into the options:
|
|
143
143
|
|
|
144
|
-
| The query says
|
|
145
|
-
|
|
|
146
|
-
| no fieldset for a table
|
|
147
|
-
| a fieldset
|
|
144
|
+
| The query says | The finder loads |
|
|
145
|
+
| ----------------------------- | ------------------------------------------------------------------- |
|
|
146
|
+
| no fieldset for a table | every column of that table |
|
|
147
|
+
| a fieldset | those columns and the table's primary key |
|
|
148
148
|
| a relation name in a fieldset | that relation's primary keys - the linkage a relationship publishes |
|
|
149
|
-
| an empty fieldset
|
|
150
|
-
| an include path
|
|
149
|
+
| an empty fieldset | the primary key alone |
|
|
150
|
+
| an include path | each relation on it, under its own table's fieldset |
|
|
151
151
|
|
|
152
152
|
`with` and `include` reach the same relational query; where both name one relation, `with` wins. A read loading no
|
|
153
153
|
relation takes a plain `select` of the columns it narrowed to, so a repository given no `queryKey` still narrows its
|
|
@@ -210,7 +210,7 @@ import { DrizzleUnitOfWork, DrizzleOutbox } from '@zeroxsolutions/db/postgres';
|
|
|
210
210
|
|
|
211
211
|
// built in the transport per invocation (the db client comes from the connection string)
|
|
212
212
|
const uow = new DrizzleUnitOfWork(db, (tx) => {
|
|
213
|
-
const scope = container.createChildContainer();
|
|
213
|
+
const scope = container.createChildContainer(); // bind the transaction to a scoped container
|
|
214
214
|
scope.register(TOKENS.TX, { useValue: tx });
|
|
215
215
|
scope.register(TOKENS.OUTBOX, { useValue: new DrizzleOutbox(tx) });
|
|
216
216
|
return scope;
|
|
@@ -224,9 +224,14 @@ await uow.run(async (tx) => {
|
|
|
224
224
|
```
|
|
225
225
|
|
|
226
226
|
`outboxTable` columns: `id` (uuid v7), `type` (event discriminator), `payload` (`jsonb`), `occurredAt`,
|
|
227
|
-
and `publishedAt` (NULL until the relay sends it
|
|
227
|
+
and `publishedAt` (NULL until the relay sends it). The relay reads unsent rows with
|
|
228
228
|
`outbox.pullUnpublished(limit)` and acknowledges them with `outbox.markPublished(ids, publishedAt)`.
|
|
229
229
|
|
|
230
|
+
Two indexes hold the unsent rows alone, so the published ones never slow a relay down:
|
|
231
|
+
`idx_outbox_unpublished_occurred_at` on `occurredAt`, for a relay that sends every type, and
|
|
232
|
+
`idx_outbox_unpublished_type_occurred_at` on `(type, occurredAt)`, for one that sends only some. Both
|
|
233
|
+
answer in `occurredAt` order without a sort.
|
|
234
|
+
|
|
230
235
|
---
|
|
231
236
|
|
|
232
237
|
## Cloudflare D1 (SQLite)
|
|
@@ -289,7 +294,7 @@ import { DrizzleD1UnitOfWork, DrizzleOutbox } from '@zeroxsolutions/db/d1';
|
|
|
289
294
|
|
|
290
295
|
// built in the transport per invocation (the db client comes from the Hyperdrive/D1 binding)
|
|
291
296
|
const uow = new DrizzleD1UnitOfWork(db, ({ db, collector }) => {
|
|
292
|
-
const scope = container.createChildContainer();
|
|
297
|
+
const scope = container.createChildContainer(); // reads use db; writes push to collector
|
|
293
298
|
scope.register(TOKENS.TX, { useValue: { db, collector } });
|
|
294
299
|
scope.register(TOKENS.OUTBOX, { useValue: new DrizzleOutbox(db, collector) });
|
|
295
300
|
return scope;
|
|
@@ -299,7 +304,7 @@ const uow = new DrizzleD1UnitOfWork(db, ({ db, collector }) => {
|
|
|
299
304
|
await uow.run(async (tx) => {
|
|
300
305
|
await tx.resolve<ILinkAggregateRepository>(TOKENS.LINK_AGGREGATE_REPOSITORY).save(link);
|
|
301
306
|
await tx.resolve<IOutboxWriter>(TOKENS.OUTBOX).stage(link.pullEvents());
|
|
302
|
-
});
|
|
307
|
+
}); // -> flush [save, ...stage] as ONE batch()
|
|
303
308
|
```
|
|
304
309
|
|
|
305
310
|
A consuming context's D1 aggregate repository takes both the `db` (for immediate `load` reads) and
|
|
@@ -315,7 +320,7 @@ The D1 `DrizzleOutbox` mirrors the Postgres surface (`stage` / `pullUnpublished(
|
|
|
315
320
|
(so it commits with the aggregate write), or executes immediately when constructed standalone (relay
|
|
316
321
|
use). `outboxTable` columns: `id` (uuid v7), `type` (event discriminator), `payload` (text - SQLite
|
|
317
322
|
has no `jsonb`, so the relay `JSON.parse`s), `occurredAt`, and `publishedAt` (NULL until the relay
|
|
318
|
-
sends it
|
|
323
|
+
sends it), with the same two indexes over the unsent rows as the Postgres table.
|
|
319
324
|
|
|
320
325
|
> **Limitation.** D1's `batch()` is atomic but non-interactive, so the D1 UoW gives all-or-nothing
|
|
321
326
|
> write atomicity without a transaction-scoped read snapshot. A handler that loads, decides, then
|
|
@@ -351,15 +356,15 @@ const condition = buildFilterConditions({
|
|
|
351
356
|
|
|
352
357
|
All 15, as `filterOperatorSchema` declares them:
|
|
353
358
|
|
|
354
|
-
| Operator
|
|
355
|
-
|
|
|
356
|
-
| `equals` / `notEquals`
|
|
357
|
-
| `in` / `notIn`
|
|
359
|
+
| Operator | Description |
|
|
360
|
+
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
361
|
+
| `equals` / `notEquals` | Exact match / not equal |
|
|
362
|
+
| `in` / `notIn` | Array membership; an empty array contributes no condition |
|
|
358
363
|
| `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`
|
|
364
|
+
| `gt` / `gte` / `lt` / `lte` | Numeric/date comparisons |
|
|
365
|
+
| `between` | Range (`from` / `to`, both optional) |
|
|
366
|
+
| `notBetween` | Range exclusion (`from` and `to`, both required) |
|
|
367
|
+
| `isNull` / `isNotNull` | Null checks; carry no `value` key at all |
|
|
363
368
|
|
|
364
369
|
A `%` or `_` in a text value is escaped, so it matches literally rather than as a wildcard.
|
|
365
370
|
|
|
@@ -374,10 +379,10 @@ column under that same name in `resolvers`:
|
|
|
374
379
|
import { permitsFor } from '@zeroxsolutions/query';
|
|
375
380
|
import { buildFilterConditions, columnResolver } from '@zeroxsolutions/db/utils';
|
|
376
381
|
|
|
377
|
-
const placesQueryPermits = permitsFor(placeAttributes, { relationships: places.relationships }).relationship(
|
|
378
|
-
'
|
|
379
|
-
|
|
380
|
-
);
|
|
382
|
+
const placesQueryPermits = permitsFor(placeAttributes, { relationships: places.relationships }).relationship('region', [
|
|
383
|
+
'equals',
|
|
384
|
+
'in',
|
|
385
|
+
]);
|
|
381
386
|
|
|
382
387
|
const condition = buildFilterConditions({
|
|
383
388
|
table: places,
|
|
@@ -417,11 +422,7 @@ only the offending field name; the calling service maps each error **type** to i
|
|
|
417
422
|
own transport.
|
|
418
423
|
|
|
419
424
|
```ts
|
|
420
|
-
import {
|
|
421
|
-
assertQueryableFields,
|
|
422
|
-
InvalidQueryFilter,
|
|
423
|
-
type QueryableFields,
|
|
424
|
-
} from '@zeroxsolutions/db/utils';
|
|
425
|
+
import { assertQueryableFields, InvalidQueryFilter, type QueryableFields } from '@zeroxsolutions/db/utils';
|
|
425
426
|
|
|
426
427
|
const USERS_QUERYABLE = {
|
|
427
428
|
filterable: ['name', 'createdAt'],
|
|
@@ -443,7 +444,10 @@ import { collectionQuerySchema } from '@zeroxsolutions/query';
|
|
|
443
444
|
|
|
444
445
|
createRoute({ request: { query: collectionQuerySchema(usersQueryPermits) } });
|
|
445
446
|
const query = c.req.valid('query');
|
|
446
|
-
const [rows, total] = await usersRepository.findAllAndCount(
|
|
447
|
+
const [rows, total] = await usersRepository.findAllAndCount(
|
|
448
|
+
buildFilterConditions({ table: users, filter: query.filter }),
|
|
449
|
+
query,
|
|
450
|
+
);
|
|
447
451
|
```
|
|
448
452
|
|
|
449
453
|
Pass `filterItem: filterValueSchema` in the spec so the parsed clauses are the ones this package's
|
|
@@ -454,11 +458,11 @@ builders accept. A test in `utils/filter.spec.ts` pins the absence of a second q
|
|
|
454
458
|
Three column masks, each a plain `{ column: true }` object to hand to a schema builder's `omit` or to
|
|
455
459
|
`Omit<...>`. This package exports the masks only - it ships no `omit` helper of its own.
|
|
456
460
|
|
|
457
|
-
| Mask
|
|
458
|
-
|
|
|
459
|
-
| `defaultSelectOmit`
|
|
461
|
+
| Mask | Drops |
|
|
462
|
+
| --------------------- | --------------------------------------- |
|
|
463
|
+
| `defaultSelectOmit` | `deletedAt` |
|
|
460
464
|
| `defaultResponseOmit` | the above plus `createdAt`, `updatedAt` |
|
|
461
|
-
| `defaultInsertOmit`
|
|
465
|
+
| `defaultInsertOmit` | the above plus `id` |
|
|
462
466
|
|
|
463
467
|
```ts
|
|
464
468
|
import { defaultInsertOmit } from '@zeroxsolutions/db/utils';
|
|
@@ -520,11 +524,11 @@ This package names no engine for you, and exports no constant standing in as tha
|
|
|
520
524
|
major you run is your deployment's own fact, so `image` takes no default: a forgotten argument is a
|
|
521
525
|
typecheck failure rather than a suite that quietly passed on a major you do not deploy.
|
|
522
526
|
|
|
523
|
-
| Export
|
|
524
|
-
|
|
|
527
|
+
| Export | What it is |
|
|
528
|
+
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
525
529
|
| `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`
|
|
530
|
+
| `CONTAINER_TIMEOUT_MS` | 180s, for the hook that starts a container: an image pull on a cold machine outruns vitest's 5s default |
|
|
531
|
+
| `fakeUuidV7` | Random bits in a v7 layout: it passes a v7 check, and is not time-ordered like a minted id |
|
|
528
532
|
|
|
529
533
|
### A container you started yourself
|
|
530
534
|
|
|
@@ -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
|
+
]);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"outbox-table.d.ts","sourceRoot":"","sources":["../../../src/base/postgres/outbox-table.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"outbox-table.d.ts","sourceRoot":"","sources":["../../../src/base/postgres/outbox-table.ts"],"names":[],"mappings":"AAIA;;;GAGG;AACH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAkBvB,CAAC;AAEF,sDAAsD;AACtD,MAAM,MAAM,SAAS,GAAG,OAAO,WAAW,CAAC,YAAY,CAAC"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isNull } from 'drizzle-orm';
|
|
1
2
|
import { index, jsonb, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
|
2
3
|
import { v7 as uuidv7 } from 'uuid';
|
|
3
4
|
/**
|
|
@@ -12,4 +13,11 @@ export const outboxTable = pgTable('outbox', {
|
|
|
12
13
|
payload: jsonb('payload').notNull(),
|
|
13
14
|
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().defaultNow(),
|
|
14
15
|
publishedAt: timestamp('published_at', { withTimezone: true }),
|
|
15
|
-
},
|
|
16
|
+
},
|
|
17
|
+
// Both hold the unpublished rows alone, so a backlog of published rows never slows a relay. The
|
|
18
|
+
// first answers a relay that publishes every type, the second one that publishes only some, each in
|
|
19
|
+
// `occurredAt` order without a sort.
|
|
20
|
+
(table) => [
|
|
21
|
+
index('idx_outbox_unpublished_occurred_at').on(table.occurredAt).where(isNull(table.publishedAt)),
|
|
22
|
+
index('idx_outbox_unpublished_type_occurred_at').on(table.type, table.occurredAt).where(isNull(table.publishedAt)),
|
|
23
|
+
]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeroxsolutions/db",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.5.0",
|
|
4
4
|
"description": "Generic Drizzle toolkit - the id column and audit timestamps a table takes, a base repository, the unit-of-work and transactional outbox impls, and the filter/sort/pagination query helpers, for PostgreSQL (postgres-js) and Cloudflare D1; ships no domain schema. Shared across ZeroXSolutions services.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"tslib": "^2.3.0",
|
|
44
44
|
"uuid": "^14.0.1",
|
|
45
|
-
"@zeroxsolutions/
|
|
46
|
-
"@zeroxsolutions/
|
|
45
|
+
"@zeroxsolutions/query": "0.7.3",
|
|
46
|
+
"@zeroxsolutions/cosmic": "0.3.4"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"@testcontainers/postgresql": "^12.1.0",
|