@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 | 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 |
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 | 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 |
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 | 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 |
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 | the primary key alone |
150
- | an include path | each relation on it, under its own table's fieldset |
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(); // bind the transaction to a scoped container
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; indexed). The relay reads unsent rows with
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(); // reads use db; writes push to collector
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
- }); // -> flush [save, ...stage] as ONE batch()
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; indexed).
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 | Description |
355
- | -------- | ----------- |
356
- | `equals` / `notEquals` | Exact match / not equal |
357
- | `in` / `notIn` | Array membership; an empty array contributes no condition |
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` | Numeric/date comparisons |
360
- | `between` | Range (`from` / `to`, both optional) |
361
- | `notBetween` | Range exclusion (`from` and `to`, both required) |
362
- | `isNull` / `isNotNull` | Null checks; carry no `value` key at all |
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
- 'region',
379
- ['equals', 'in'],
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(buildFilterConditions({ table: users, filter: query.filter }), query);
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 | Drops |
458
- | ---- | ----- |
459
- | `defaultSelectOmit` | `deletedAt` |
461
+ | Mask | Drops |
462
+ | --------------------- | --------------------------------------- |
463
+ | `defaultSelectOmit` | `deletedAt` |
460
464
  | `defaultResponseOmit` | the above plus `createdAt`, `updatedAt` |
461
- | `defaultInsertOmit` | the above plus `id` |
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 | What it is |
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` | 180s, for the hook that starts a container: an image pull on a cold machine outruns vitest's 5s default |
527
- | `fakeUuidV7` | Random bits in a v7 layout: it passes a v7 check, and is not time-ordered like a minted id |
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":"AAGA;;;;;GAKG;AACH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAcvB,CAAC;AAEF,sGAAsG;AACtG,MAAM,MAAM,SAAS,GAAG,OAAO,WAAW,CAAC,YAAY,CAAC"}
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
- }, (table) => [index('idx_outbox_published_at').on(table.publishedAt)]);
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":"AAGA;;;GAGG;AACH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAYvB,CAAC;AAEF,sDAAsD;AACtD,MAAM,MAAM,SAAS,GAAG,OAAO,WAAW,CAAC,YAAY,CAAC"}
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
- }, (table) => [index('idx_outbox_published_at').on(table.publishedAt)]);
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.4.0",
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/cosmic": "0.3.4",
46
- "@zeroxsolutions/query": "0.7.3"
45
+ "@zeroxsolutions/query": "0.7.3",
46
+ "@zeroxsolutions/cosmic": "0.3.4"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@testcontainers/postgresql": "^12.1.0",