@ultimat3/entity 24.0.0 → 25.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/CLAUDE.md +12 -10
- package/README.md +44 -1
- package/package.json +6 -6
- package/src/append-only-errors.ts +53 -0
- package/src/append-only.ts +82 -0
- package/src/batch.ts +1 -1
- package/src/columns.ts +1 -1
- package/src/describe.ts +5 -0
- package/src/entity-error.ts +5 -2
- package/src/entity.ts +15 -24
- package/src/errors.ts +2 -2
- package/src/feature-errors.ts +3 -1
- package/src/index-init.ts +29 -0
- package/src/index.ts +6 -2
- package/src/memory-repo.ts +14 -9
- package/src/pg-driver.ts +19 -17
- package/src/query.ts +5 -3
- package/src/registry.ts +2 -0
- package/src/repo.ts +5 -6
- package/src/row-observer.ts +2 -2
- package/src/sealed-repo.ts +2 -1
- package/src/seed.ts +16 -1
- package/src/type-pins.ts +25 -0
package/CLAUDE.md
CHANGED
|
@@ -4,19 +4,16 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
4
4
|
|
|
5
5
|
## Boundary
|
|
6
6
|
|
|
7
|
-
- May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/db` and `@ultimat3/time` (
|
|
8
|
-
`
|
|
9
|
-
- `db` is tier 1
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
(`pg-driver.ts`/`pg-sql.ts`) is a hand-written SQL driver. `types.ts` declares the narrow
|
|
14
|
-
structural column vocabulary this package consumes so the generated SQL stays readable and
|
|
15
|
-
an agent can self-correct against it.
|
|
7
|
+
- May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/db` and `@ultimat3/time` (its
|
|
8
|
+
`isValidTimeZone`). Nothing else.
|
|
9
|
+
- `db` is tier 1, which lets the Postgres driver live **here**: `Driver` and its production
|
|
10
|
+
implementation in one place ([`01-package-map.md`](../../docs/architecture/01-package-map.md)).
|
|
11
|
+
- No `drizzle-orm`: `postgresDriver()` (`pg-driver.ts`/`pg-sql.ts`) is hand-written SQL;
|
|
12
|
+
`types.ts` declares the narrow column vocabulary it consumes.
|
|
16
13
|
|
|
17
14
|
## Do not regress — two drivers, one meaning
|
|
18
15
|
|
|
19
|
-
- **`repo.ts` is the contract** (`Repo`, `
|
|
16
|
+
- **`repo.ts` is the contract** (`Repo`, `FindManyArgs`, `Transactor`); `memory-repo.ts` is
|
|
20
17
|
`memoryRepo()`.
|
|
21
18
|
- **Two drivers, one meaning.** `memoryDriver()` and `postgresDriver()` share `plan.ts`, `cursor.ts`
|
|
22
19
|
and the `Repo` contract. Every bulk or read feature carries BOTH a `*-parity.test.ts` (identical rows
|
|
@@ -231,6 +228,11 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
231
228
|
- **`setRowObserver` reports committed row changes above the driver** — one per process (returns the
|
|
232
229
|
replaced one), applied by `database()`, one comparison per write when unset, `before` only when the PK
|
|
233
230
|
is `id`, a filtered write is `onBulk`. Not a second change-feed path.
|
|
231
|
+
- **`appendOnly: true`** (`append-only.ts`): `appendOnlyRepo` wraps OUTSIDE `sealedRepo` in both
|
|
232
|
+
drivers (seed `upsert` appends, never rewrites); `update`/`delete`/`*Where` and an
|
|
233
|
+
`upsertAll` not `onMatch: 'nothing'` reject before any statement (`X_ENTITY_APPEND_ONLY`); a soft-delete,
|
|
234
|
+
`onUpdateNow()` or machine column is refused at declaration; `$describe().appendOnly` is `true` or
|
|
235
|
+
absent. No bypass, backfills included. `append-only{,-parity}.test.ts`.
|
|
234
236
|
- **A state machine is the MECHANISM only** (`.transitions()` on `enumerated()`, a mapped
|
|
235
237
|
`TransitionTable<S>`): a terminal state is one with no outgoing moves; the move is ONE statement with
|
|
236
238
|
`from` in the predicate (`pg-transition.live.test.ts`: 1 winner of 20); `X_STATE_CONFLICT` is read
|
package/README.md
CHANGED
|
@@ -348,13 +348,17 @@ database, and `x verify` reports the other as a rule the database does not know.
|
|
|
348
348
|
```ts
|
|
349
349
|
export const db = database({ orgs, posts });
|
|
350
350
|
|
|
351
|
-
db.posts.where({ orgId }).orderBy('createdAt').limit(50).page(); // { rows, nextCursor }
|
|
351
|
+
db.posts.where({ orgId }).orderBy('createdAt').limit(50).page(); // { rows, nextCursor, hasMore }
|
|
352
352
|
```
|
|
353
353
|
|
|
354
354
|
`db.posts` exists because `posts` was declared. Pagination is **cursor-only**: `OFFSET` is wrong
|
|
355
355
|
under concurrent writes, because an insert before the offset shifts every later page and the
|
|
356
356
|
client silently skips and repeats rows.
|
|
357
357
|
|
|
358
|
+
The page is `@ultimat3/core`'s `Page` — the one page shape, the same a `query`'s `.page()` answers
|
|
359
|
+
(this package's own `Page`, `{ rows, nextCursor }` with no `hasMore`, left in 25.0.0). `nextCursor` is `null`
|
|
360
|
+
exactly when `hasMore` is false, so `while (page.hasMore)` stops on the last page.
|
|
361
|
+
|
|
358
362
|
`nextCursor` is signed by `@ultimat3/core` and scoped to the plan that produced it — this entity,
|
|
359
363
|
these filters, this sort order. A tampered cursor, or one taken from another listing, is
|
|
360
364
|
`X_CURSOR_INVALID` rather than a silent page one. The page size is deliberately outside the scope:
|
|
@@ -665,6 +669,45 @@ and never unwritten.
|
|
|
665
669
|
| `onUpdateNow()` | stamped by `touch()`, the same helper `update(id, patch)` uses — one place, so the two can never disagree about `updatedAt` |
|
|
666
670
|
| Rows read back | **only when something can still refuse them.** A `check` or a `unique` invariant is a constraint Postgres enforced on the statement, so the answer is a count and the statement carries no `returning *`. Only a JS-only rule (`kind: 'assert'`, `sql: null`) has to be judged on the result — and then the match is counted first and refused past `MAX_ASSERTED_ROWS` (50,000), naming `inBatches(1000)`, because a refusal issued after `returning *` is already holding what it is refusing. On Postgres the statement and that judgement share one transaction (a SAVEPOINT inside an open one), for `update(id, patch)` too, so a refused write is rolled back rather than left committed |
|
|
667
671
|
|
|
672
|
+
## Append-only entities
|
|
673
|
+
|
|
674
|
+
`appendOnly: true` declares a table whose rows are written once and never changed or removed — a
|
|
675
|
+
ledger, an audit trail, an event log. Held twice, like an invariant: the repository refuses, and
|
|
676
|
+
`x db gen` emits a trigger that refuses the same statement in raw SQL.
|
|
677
|
+
|
|
678
|
+
```ts
|
|
679
|
+
import { database, entity, integer, newId, text, timestamp, uuid } from '@ultimat3/entity';
|
|
680
|
+
|
|
681
|
+
export const entries = entity('ledger_entries', {
|
|
682
|
+
columns: {
|
|
683
|
+
id: uuid().primaryKey(),
|
|
684
|
+
account: text({ max: 40 }),
|
|
685
|
+
amountMinor: integer(),
|
|
686
|
+
reverses: uuid().nullable(), // a correction is a NEW row naming the one it reverses
|
|
687
|
+
createdAt: timestamp().defaultNow(),
|
|
688
|
+
},
|
|
689
|
+
appendOnly: true,
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
const db = database({ entries });
|
|
693
|
+
const first = await db.entries.insert({ id: newId(), account: 'cash', amountMinor: 100 });
|
|
694
|
+
// Skips a row already stored, never overwrites it — the one upsert an append-only table takes.
|
|
695
|
+
await db.entries.upsertAll([first], { onConflict: ['id'], onMatch: 'nothing' });
|
|
696
|
+
// Rejects with X_ENTITY_APPEND_ONLY, as do delete, updateWhere and deleteWhere.
|
|
697
|
+
await db.entries.update(first.id, { amountMinor: 0 }).catch((refused: unknown) => refused);
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
| | |
|
|
701
|
+
|---|---|
|
|
702
|
+
| Refused | `update`, `delete`, `updateWhere`, `deleteWhere`, and `upsertAll` unless `onMatch: 'nothing'` (the default is `'update'`). Rejected before any statement exists, in both drivers, so memory and Postgres answer the same code. `entries.$appendOnly` says which entities carry it |
|
|
703
|
+
| Refused at declaration | a `deletedAt` column (a soft delete is an UPDATE), an `onUpdateNow()` column (nothing would ever stamp it), a `.transitions()` column (a transition is an UPDATE) — `X_INVARIANT_VIOLATED` naming the column |
|
|
704
|
+
| The database | `x db gen` writes `create trigger "ultimate_append_only" before update or delete … for each row` calling one shared `ultimate_refuse_append_only()`; raw SQL, an `on conflict do update` and a second app on the same schema get `X_ENTITY_APPEND_ONLY: UPDATE on public.<table> is refused`, SQLSTATE `23001`. PGlite runs the same trigger. `truncate` is not refused — it is no row write, a migration already marks it destructive, and the test harness empties tables with it; revoke `TRUNCATE` from the web role if that matters |
|
|
705
|
+
| Drift | a trigger missing or disabled in the live database is `X_APPEND_ONLY_TRIGGER_MISSING` from `x db migrate`'s post-migrate check, with the two statements that restore it as the fix |
|
|
706
|
+
| Turning it on or off | a migration: `x db gen` adds the trigger or drops it, and `down` reverses it. Dropping the table drops the trigger |
|
|
707
|
+
| Backfills and seeds | refused too — no bypass. A `backfill()` that must rewrite existing rows is a two-migration change: drop `appendOnly`, backfill, put it back. A seed's `ctx.insert` is `onMatch: 'nothing'` and works; so is `ctx.upsert` of a row not yet stored — it appends with `onMatch: 'nothing'` (a replay is `'skipped'`, two boots racing settle on one row). Only an `upsert` that would CHANGE a stored row is refused (`X_ENTITY_APPEND_ONLY`) |
|
|
708
|
+
| Tenancy | unchanged — the refusal comes first and judges no tenant; an insert is scoped exactly as on any entity |
|
|
709
|
+
| The seam | `appendOnlyRepo(entity, repo)`, wrapped outside `sealedRepo` by both drivers — so `database()`, a seed's `driver.repo()` and a hand-held `postgresRepo(entity)` agree. A driver written outside this package returns through `appendOnlyRepo(entity, sealedRepo(entity, repo))` |
|
|
710
|
+
|
|
668
711
|
## Writing many rows
|
|
669
712
|
|
|
670
713
|
```ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.0.0",
|
|
4
4
|
"description": "A table + its domain type + invariants the database also enforces",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -26,16 +26,16 @@
|
|
|
26
26
|
"LICENSE"
|
|
27
27
|
],
|
|
28
28
|
"engines": {
|
|
29
|
-
"bun": ">=1.4.
|
|
29
|
+
"bun": ">=1.4.2"
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
32
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
33
33
|
"test": "bun test"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@ultimat3/core": "
|
|
37
|
-
"@ultimat3/db": "
|
|
38
|
-
"@ultimat3/schema": "
|
|
39
|
-
"@ultimat3/time": "
|
|
36
|
+
"@ultimat3/core": "25.0.0",
|
|
37
|
+
"@ultimat3/db": "25.0.0",
|
|
38
|
+
"@ultimat3/schema": "25.0.0",
|
|
39
|
+
"@ultimat3/time": "25.0.0"
|
|
40
40
|
}
|
|
41
41
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The refusals `entity({ appendOnly: true })` raises: a repository call that would change or remove
|
|
2
|
+
// a stored row, and a declaration that only means something under UPDATE. Each fix names the
|
|
3
|
+
// append that replaces the rewrite, or the edit that lifts the declaration.
|
|
4
|
+
import { EntityError, type EntityErrorCode } from './entity-error';
|
|
5
|
+
|
|
6
|
+
/** The refusal's code, exported so every test asserts the one literal. */
|
|
7
|
+
export const APPEND_ONLY_CODE: EntityErrorCode = 'X_ENTITY_APPEND_ONLY';
|
|
8
|
+
|
|
9
|
+
/** The repository calls that rewrite or remove a row — every one a `Repo` has. */
|
|
10
|
+
export type AppendOnlyRefusedCall =
|
|
11
|
+
| 'update'
|
|
12
|
+
| 'delete'
|
|
13
|
+
| 'updateWhere'
|
|
14
|
+
| 'deleteWhere'
|
|
15
|
+
| 'upsertAll';
|
|
16
|
+
|
|
17
|
+
/** How to lift the rule, said once: the trigger goes with the flag, in a migration. */
|
|
18
|
+
const LIFT =
|
|
19
|
+
'or, if these rows must change, remove appendOnly: true from the entity and run x db gen "allow row rewrites"';
|
|
20
|
+
|
|
21
|
+
const fixFor = (entityName: string, call: AppendOnlyRefusedCall): string => {
|
|
22
|
+
if (call === 'upsertAll') {
|
|
23
|
+
return `${entityName}.upsertAll(rows, { onConflict, onMatch: 'nothing' }) # a row already stored is skipped, never overwritten — ${LIFT}`;
|
|
24
|
+
}
|
|
25
|
+
const what = call === 'update' || call === 'updateWhere' ? 'the correction' : 'the reversal';
|
|
26
|
+
return `${entityName}.insert({ … }) # append ${what} as a new row that names the one it supersedes — ${LIFT}`;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const appendOnlyRefused = (entityName: string, call: AppendOnlyRefusedCall): EntityError =>
|
|
30
|
+
new EntityError({
|
|
31
|
+
code: APPEND_ONLY_CODE,
|
|
32
|
+
cause:
|
|
33
|
+
`${entityName}.${call}() would ${call.startsWith('delete') ? 'remove' : 'rewrite'} a stored row, ` +
|
|
34
|
+
`and ${entityName} is declared appendOnly: true — its rows are written once and never ` +
|
|
35
|
+
'changed or removed; the table trigger refuses the same statement in raw SQL',
|
|
36
|
+
fix: fixFor(entityName, call),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A column that only means something when a row is UPDATED, on an entity whose rows never are.
|
|
41
|
+
* Raised while the entity is being declared, so the fix is the edit, not a lookup of an entity
|
|
42
|
+
* that does not exist yet.
|
|
43
|
+
*/
|
|
44
|
+
export const appendOnlyColumnRefused = (
|
|
45
|
+
entityName: string,
|
|
46
|
+
property: string,
|
|
47
|
+
why: string,
|
|
48
|
+
): EntityError =>
|
|
49
|
+
new EntityError({
|
|
50
|
+
code: 'X_INVARIANT_VIOLATED',
|
|
51
|
+
cause: `${entityName}.${property}: ${why}, and ${entityName} is declared appendOnly: true — no row of it is ever updated`,
|
|
52
|
+
fix: `entity('${entityName}', { columns, appendOnly: true }) # with ${property} removed from columns — or keep ${property} and drop appendOnly: true`,
|
|
53
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// `entity({ appendOnly: true })`: what the declaration may not hold, and the repository seam that
|
|
2
|
+
// refuses every write changing or removing a stored row. Applied by BOTH drivers, beside
|
|
3
|
+
// `sealedRepo`, so `database()`, a seed and anything else holding `driver.repo(entity)` are refused
|
|
4
|
+
// alike — and before any statement exists, so memory and Postgres answer the same code.
|
|
5
|
+
|
|
6
|
+
import { appendOnlyColumnRefused, appendOnlyRefused } from './append-only-errors';
|
|
7
|
+
import type { Repo, UpsertArgs } from './repo';
|
|
8
|
+
import type { AnyColumn } from './types';
|
|
9
|
+
|
|
10
|
+
/** The two members the seam reads — so a driver written outside this package can pass its entity. */
|
|
11
|
+
export interface AppendOnlySource {
|
|
12
|
+
readonly $name: string;
|
|
13
|
+
readonly $appendOnly: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Refused at declaration: each is a column whose whole meaning is an UPDATE. A soft delete IS one
|
|
18
|
+
* (`deleted_at = now()`), an `onUpdateNow()` stamp is written by one, and a state machine moves by
|
|
19
|
+
* one — on an append-only table all three would be declared and never able to happen.
|
|
20
|
+
*/
|
|
21
|
+
export const assertAppendOnlyColumns = (
|
|
22
|
+
entityName: string,
|
|
23
|
+
entries: readonly (readonly [string, AnyColumn])[],
|
|
24
|
+
softDeleteColumn: string,
|
|
25
|
+
): void => {
|
|
26
|
+
for (const [property, column] of entries) {
|
|
27
|
+
const meta = column.$meta;
|
|
28
|
+
if (property === softDeleteColumn) {
|
|
29
|
+
throw appendOnlyColumnRefused(
|
|
30
|
+
entityName,
|
|
31
|
+
property,
|
|
32
|
+
'makes the entity soft-deletable, and a soft delete is an UPDATE',
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
if (meta.onUpdate !== undefined) {
|
|
36
|
+
throw appendOnlyColumnRefused(entityName, property, 'is onUpdateNow(), stamped by an UPDATE');
|
|
37
|
+
}
|
|
38
|
+
if (meta.machine !== undefined) {
|
|
39
|
+
throw appendOnlyColumnRefused(
|
|
40
|
+
entityName,
|
|
41
|
+
property,
|
|
42
|
+
'declares .transitions(), and a transition is an UPDATE',
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `repo`, refusing. Inserts and reads pass through untouched; the five calls that rewrite or remove
|
|
50
|
+
* a row reject — `upsertAll` unless `onMatch: 'nothing'`, which only ever appends. Every refusal is
|
|
51
|
+
* `async`, so it rejects rather than throws, as the contract requires; a spread over the inner
|
|
52
|
+
* repository keeps a member this file does not know (`MemoryRepo.reset()`).
|
|
53
|
+
*
|
|
54
|
+
* A backfill is refused too, deliberately: there is no bypass, because a ledger an author's job can
|
|
55
|
+
* rewrite is not append-only. Changing existing rows is lifting the flag, in a migration.
|
|
56
|
+
*/
|
|
57
|
+
export const appendOnlyRepo = <Row, R extends Repo<Row>>(entity: AppendOnlySource, repo: R): R => {
|
|
58
|
+
if (!entity.$appendOnly) return repo;
|
|
59
|
+
const name = entity.$name;
|
|
60
|
+
const refusing: Pick<
|
|
61
|
+
Repo<Row>,
|
|
62
|
+
'update' | 'delete' | 'updateWhere' | 'deleteWhere' | 'upsertAll'
|
|
63
|
+
> = {
|
|
64
|
+
update: async () => {
|
|
65
|
+
throw appendOnlyRefused(name, 'update');
|
|
66
|
+
},
|
|
67
|
+
delete: async () => {
|
|
68
|
+
throw appendOnlyRefused(name, 'delete');
|
|
69
|
+
},
|
|
70
|
+
updateWhere: async () => {
|
|
71
|
+
throw appendOnlyRefused(name, 'updateWhere');
|
|
72
|
+
},
|
|
73
|
+
deleteWhere: async () => {
|
|
74
|
+
throw appendOnlyRefused(name, 'deleteWhere');
|
|
75
|
+
},
|
|
76
|
+
async upsertAll(rows, args: UpsertArgs<Row>) {
|
|
77
|
+
if (args.onMatch !== 'nothing') throw appendOnlyRefused(name, 'upsertAll');
|
|
78
|
+
return repo.upsertAll(rows, args);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
return { ...repo, ...refusing };
|
|
82
|
+
};
|
package/src/batch.ts
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
// exactly what they mean in `page()`. What this file owns is the loop, the refusals that belong on
|
|
7
7
|
// the chain rather than one batch later, and what closing means.
|
|
8
8
|
|
|
9
|
+
import type { Page } from '@ultimat3/core';
|
|
9
10
|
import { assertSeekable } from './cursor';
|
|
10
11
|
import type { EntityCore } from './entity';
|
|
11
12
|
import { EntityError } from './errors';
|
|
12
13
|
import { MAX_PAGE_SIZE, totalOrder } from './plan';
|
|
13
|
-
import type { Page } from './repo';
|
|
14
14
|
import type { SortKey } from './tenancy';
|
|
15
15
|
|
|
16
16
|
/**
|
package/src/columns.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// locale and a time zone — the alternatives (float money, naive timestamps, a single implied
|
|
3
3
|
// currency) are the bugs this file exists to make unreachable.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { uuidV7 } from '@ultimat3/core';
|
|
6
6
|
import {
|
|
7
7
|
CURRENCY_CODE_PATTERN,
|
|
8
8
|
isCurrencyCode,
|
package/src/describe.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface DescribeInput<Row> {
|
|
|
26
26
|
readonly tenantColumn: string | null;
|
|
27
27
|
/** The generated `tsvector`, when any column is `.searchable()`. */
|
|
28
28
|
readonly search?: SearchVector | null;
|
|
29
|
+
/** `entity({ appendOnly: true })` — projected only when true. */
|
|
30
|
+
readonly appendOnly?: boolean;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/**
|
|
@@ -234,5 +236,8 @@ export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescriptio
|
|
|
234
236
|
cacheTag: input.cacheTag,
|
|
235
237
|
softDelete: input.softDelete,
|
|
236
238
|
orgScoped: input.tenantColumn !== null,
|
|
239
|
+
// `true` or absent, never `false`: `x verify`'s source drift hashes this projection as JSON,
|
|
240
|
+
// and an entity that never said `appendOnly` must hash exactly what it hashed before.
|
|
241
|
+
...(input.appendOnly === true ? { appendOnly: true as const } : {}),
|
|
237
242
|
};
|
|
238
243
|
};
|
package/src/entity-error.ts
CHANGED
|
@@ -38,6 +38,7 @@ export const ENTITY_ERROR_CODES = [
|
|
|
38
38
|
'X_RECORD_KEY_MISSING',
|
|
39
39
|
'X_ENTITY_SEALED_PREDICATE',
|
|
40
40
|
'X_ENTITY_SEALED_IN_VIEW',
|
|
41
|
+
'X_ENTITY_APPEND_ONLY',
|
|
41
42
|
] as const;
|
|
42
43
|
|
|
43
44
|
export type EntityErrorCode = (typeof ENTITY_ERROR_CODES)[number];
|
|
@@ -70,6 +71,7 @@ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityErrorCode, string>> = {
|
|
|
70
71
|
X_ENTITY_SEALED_PREDICATE:
|
|
71
72
|
'a sealed column is read by a predicate, an order or a rule the database evaluates',
|
|
72
73
|
X_ENTITY_SEALED_IN_VIEW: 'a sealed column is named in a view',
|
|
74
|
+
X_ENTITY_APPEND_ONLY: 'an append-only entity was asked to rewrite or remove a row',
|
|
73
75
|
};
|
|
74
76
|
|
|
75
77
|
// Registered at module load, unconditionally, in one call. Without this the registry humanises the
|
|
@@ -80,8 +82,8 @@ registerErrorCodes(
|
|
|
80
82
|
);
|
|
81
83
|
|
|
82
84
|
/**
|
|
83
|
-
* How the codes here that are worth classifying are retried.
|
|
84
|
-
*
|
|
85
|
+
* How the codes here that are worth classifying are retried. The two sealed-column refusals and
|
|
86
|
+
* the append-only refusal: each is the app's own declaration or call, so attempt five answers what attempt one did — and
|
|
85
87
|
* left unclassified, `classifyThrown` reads them as undecided and a job spends its whole retry
|
|
86
88
|
* policy re-proving it. Listed although `terminal` is the default, for the reason core's own table
|
|
87
89
|
* gives for `X_NOT_IMPLEMENTED`. The rest of this package's codes are unclassified, as they were.
|
|
@@ -89,6 +91,7 @@ registerErrorCodes(
|
|
|
89
91
|
export const ENTITY_ERROR_RETRY = {
|
|
90
92
|
X_ENTITY_SEALED_PREDICATE: 'terminal',
|
|
91
93
|
X_ENTITY_SEALED_IN_VIEW: 'terminal',
|
|
94
|
+
X_ENTITY_APPEND_ONLY: 'terminal',
|
|
92
95
|
} as const satisfies Readonly<Partial<Record<EntityErrorCode, ErrorRetry>>>;
|
|
93
96
|
|
|
94
97
|
registerErrorRetry(ENTITY_ERROR_RETRY);
|
package/src/entity.ts
CHANGED
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
|
|
6
6
|
import type { IndexMethod } from '@ultimat3/db';
|
|
7
7
|
import { describeValue, type Schema } from '@ultimat3/schema';
|
|
8
|
+
import { assertAppendOnlyColumns } from './append-only';
|
|
8
9
|
import { entityNow } from './clock';
|
|
9
10
|
import { assertTableName, bindColumn, columnName, moneyColumns } from './column';
|
|
10
11
|
import { newId } from './columns';
|
|
11
12
|
import { describeEntity, describeReferences } from './describe';
|
|
12
13
|
import { invariantViolated } from './errors';
|
|
13
|
-
import type {
|
|
14
|
+
import type { InvariantColumns, Resolve } from './expr';
|
|
14
15
|
import { invariantColumns } from './expr';
|
|
16
|
+
import type { IndexInit } from './index-init';
|
|
15
17
|
import { indexName } from './index-name';
|
|
16
18
|
import type { Invariant, InvariantDef } from './invariants';
|
|
17
19
|
import { assertInvariants, bindInvariant } from './invariants';
|
|
@@ -32,29 +34,6 @@ import { viewFor } from './view';
|
|
|
32
34
|
/** Presence of this column is what makes an entity soft-deletable — not a flag. */
|
|
33
35
|
export const SOFT_DELETE_COLUMN = 'deletedAt';
|
|
34
36
|
|
|
35
|
-
export interface IndexInit<C extends ColumnMap> {
|
|
36
|
-
readonly on: readonly (keyof C & string)[];
|
|
37
|
-
readonly order?: 'asc' | 'desc';
|
|
38
|
-
readonly unique?: boolean;
|
|
39
|
-
/** Partial index predicate, written in the same language as an invariant. */
|
|
40
|
-
readonly where?: (columns: InvariantColumns<C>) => Expr;
|
|
41
|
-
/**
|
|
42
|
-
* The access method. Omitted is `btree`, which is Postgres' own default and what every index
|
|
43
|
-
* declared before this existed is — so an entity that names none emits the statement it always
|
|
44
|
-
* emitted and nothing regenerates.
|
|
45
|
-
*
|
|
46
|
-
* `'gin'` is the one with a caller, and it is the whole point of the containment operators:
|
|
47
|
-
* measured on Postgres 16 over 20,000 rows, `tags @> …`, `tags <@ …`, `tags && …` and
|
|
48
|
-
* `data @> …` are each a Bitmap Index Scan with one and a Seq Scan without. The set is
|
|
49
|
-
* `@ultimat3/db`'s `INDEX_METHODS`, imported rather than restated — one declaration of one fact.
|
|
50
|
-
*
|
|
51
|
-
* Two Postgres rules ride with it and both are refused HERE, where the author is, rather than at
|
|
52
|
-
* `x db gen` or inside `ROLE=migrate` as the server's own syntax error: a GIN index cannot be
|
|
53
|
-
* unique and cannot order its keys.
|
|
54
|
-
*/
|
|
55
|
-
readonly using?: IndexMethod;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
37
|
export interface EntityInit<C extends ColumnMap> {
|
|
59
38
|
readonly columns: C;
|
|
60
39
|
/**
|
|
@@ -94,6 +73,12 @@ export interface EntityInit<C extends ColumnMap> {
|
|
|
94
73
|
* and disk is a decision. Read off `recordProjection(entity).persist` by realtime's persister.
|
|
95
74
|
*/
|
|
96
75
|
readonly persist?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Rows are written once and never changed or removed — a ledger, an audit trail, an event log.
|
|
78
|
+
* The repository refuses every rewrite (`append-only.ts`) and `x db gen` emits the trigger that
|
|
79
|
+
* refuses the same in raw SQL. A soft-delete, `onUpdateNow()` or state-machine column is refused.
|
|
80
|
+
*/
|
|
81
|
+
readonly appendOnly?: boolean;
|
|
97
82
|
}
|
|
98
83
|
|
|
99
84
|
/**
|
|
@@ -112,6 +97,8 @@ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
|
|
|
112
97
|
/** `entity:<name>`. `@ultimat3/cache` invalidates by this string. */
|
|
113
98
|
readonly $cacheTag: string;
|
|
114
99
|
readonly $softDelete: boolean;
|
|
100
|
+
/** `entity({ appendOnly: true })`: update and delete are refused, here and by the table. */
|
|
101
|
+
readonly $appendOnly: boolean;
|
|
115
102
|
/** Property key of the tenant column, or `null`. Presence is what turns tenancy on. */
|
|
116
103
|
readonly $tenantColumn: string | null;
|
|
117
104
|
/**
|
|
@@ -190,6 +177,8 @@ export const entity = <const C extends ColumnMap>(
|
|
|
190
177
|
const cacheTag = `entity:${name}`;
|
|
191
178
|
const softDelete = Object.hasOwn(init.columns, SOFT_DELETE_COLUMN);
|
|
192
179
|
const tenantColumn = resolveTenantColumn(name, init.columns, init.tenant);
|
|
180
|
+
const appendOnly = init.appendOnly === true;
|
|
181
|
+
if (appendOnly) assertAppendOnlyColumns(name, entries, SOFT_DELETE_COLUMN);
|
|
193
182
|
|
|
194
183
|
const searchSources: readonly SearchSource[] = entries.flatMap(([property, column]) => {
|
|
195
184
|
const weight = column.$meta.searchable;
|
|
@@ -380,6 +369,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
380
369
|
softDelete,
|
|
381
370
|
tenantColumn,
|
|
382
371
|
search,
|
|
372
|
+
appendOnly,
|
|
383
373
|
});
|
|
384
374
|
const references = (): readonly ReferenceDescription[] => describeReferences(name, entries);
|
|
385
375
|
|
|
@@ -460,6 +450,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
460
450
|
$tags: tags,
|
|
461
451
|
$cacheTag: cacheTag,
|
|
462
452
|
$softDelete: softDelete,
|
|
453
|
+
$appendOnly: appendOnly,
|
|
463
454
|
$tenantColumn: tenantColumn,
|
|
464
455
|
$search: search,
|
|
465
456
|
$schema: schema,
|
package/src/errors.ts
CHANGED
|
@@ -70,7 +70,7 @@ export const tenancyUnscoped = (
|
|
|
70
70
|
: `${entityName}.${operation}() was checked against a plan with no org predicate, though the acting actor's tenant is ${renderValue(actorOrg)} — a plan is verified here, never rewritten, so the tenant had to be on it already`,
|
|
71
71
|
fix:
|
|
72
72
|
actorOrg === undefined
|
|
73
|
-
? `run it inside runWithContext(
|
|
73
|
+
? `run it inside runWithContext(ctxOf({ actor: userActor({ id, orgId }) }), fn) — the actor's org scopes the plan — or name the tenant: ${entityName}.${operation}({ orgId }), which orgScoped(plan, orgId) is the plan-level form of`
|
|
74
74
|
: `build the plan with scopedPlan('${entityName}', tenantColumn, '${operation}', plan) — it applies the actor's tenant — or add the predicate first: orgScoped(plan, ${asLiteral(actorOrg, "'<org>'")})`,
|
|
75
75
|
});
|
|
76
76
|
|
|
@@ -159,7 +159,7 @@ export const crossTenantDenied = (init: {
|
|
|
159
159
|
new EntityError({
|
|
160
160
|
code: 'X_TENANCY_CROSS_DENIED',
|
|
161
161
|
cause: `crossTenant(${JSON.stringify(init.reason)}) was entered by ${init.actor}, which does not carry the ${JSON.stringify(init.scope)} scope`,
|
|
162
|
-
fix: `grant the capability where the actor is minted — serviceActor({ id: 'reconciler', scopes: ['${init.scope}'] }) — and run the sweep inside runWithContext(
|
|
162
|
+
fix: `grant the capability where the actor is minted — serviceActor({ id: 'reconciler', scopes: ['${init.scope}'] }) — and run the sweep inside runWithContext(ctxOf({ actor }), fn); an ordinary request scopes to its own tenant instead and needs no crossTenant()`,
|
|
163
163
|
});
|
|
164
164
|
|
|
165
165
|
/**
|
package/src/feature-errors.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// machine. Split from `errors.ts` at the 500-line ceiling; the codes and their titles stay there,
|
|
3
3
|
// because a registry with two homes is a registry that disagrees with itself.
|
|
4
4
|
|
|
5
|
+
import { renderFixShellArg } from '@ultimat3/core';
|
|
5
6
|
import { EntityError } from './errors';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -15,7 +16,8 @@ export const searchUndeclared = (entityName: string): EntityError =>
|
|
|
15
16
|
new EntityError({
|
|
16
17
|
code: 'X_SEARCH_UNDECLARED',
|
|
17
18
|
cause: `${entityName} has no searchable column, so there is no tsvector to match against`,
|
|
18
|
-
|
|
19
|
+
// Screened where it enters the command: the line is pasted, and `$(…)` runs inside "…".
|
|
20
|
+
fix: `add .searchable() to a text() column of ${entityName}, then: x db gen "search ${renderFixShellArg(entityName, '<entity>')}"`,
|
|
19
21
|
});
|
|
20
22
|
|
|
21
23
|
/**
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// What one `indexes: [...]` entry of `entity(name, { indexes })` may say. Its own file so the
|
|
2
|
+
// declaration's shape is readable apart from `entity.ts`, which turns it into an `IndexDef`.
|
|
3
|
+
|
|
4
|
+
import type { IndexMethod } from '@ultimat3/db';
|
|
5
|
+
import type { Expr, InvariantColumns } from './expr';
|
|
6
|
+
import type { ColumnMap } from './types';
|
|
7
|
+
|
|
8
|
+
export interface IndexInit<C extends ColumnMap> {
|
|
9
|
+
readonly on: readonly (keyof C & string)[];
|
|
10
|
+
readonly order?: 'asc' | 'desc';
|
|
11
|
+
readonly unique?: boolean;
|
|
12
|
+
/** Partial index predicate, written in the same language as an invariant. */
|
|
13
|
+
readonly where?: (columns: InvariantColumns<C>) => Expr;
|
|
14
|
+
/**
|
|
15
|
+
* The access method. Omitted is `btree`, which is Postgres' own default and what every index
|
|
16
|
+
* declared before this existed is — so an entity that names none emits the statement it always
|
|
17
|
+
* emitted and nothing regenerates.
|
|
18
|
+
*
|
|
19
|
+
* `'gin'` is the one with a caller, and it is the whole point of the containment operators:
|
|
20
|
+
* measured on Postgres 16 over 20,000 rows, `tags @> …`, `tags <@ …`, `tags && …` and
|
|
21
|
+
* `data @> …` are each a Bitmap Index Scan with one and a Seq Scan without. The set is
|
|
22
|
+
* `@ultimat3/db`'s `INDEX_METHODS`, imported rather than restated — one declaration of one fact.
|
|
23
|
+
*
|
|
24
|
+
* Two Postgres rules ride with it and both are refused HERE, where the author is, rather than at
|
|
25
|
+
* `x db gen` or inside `ROLE=migrate` as the server's own syntax error: a GIN index cannot be
|
|
26
|
+
* unique and cannot order its keys.
|
|
27
|
+
*/
|
|
28
|
+
readonly using?: IndexMethod;
|
|
29
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,10 @@ export type { Infer } from '@ultimat3/schema';
|
|
|
5
5
|
export { t } from '@ultimat3/schema';
|
|
6
6
|
export type { AggregateFn } from './aggregate';
|
|
7
7
|
export { AVG_SCALE } from './aggregate';
|
|
8
|
+
// Append-only entities: the same kind of seam, wrapped OUTSIDE `sealedRepo` by both drivers — a
|
|
9
|
+
// driver written outside this package returns through `appendOnlyRepo(entity, sealedRepo(…))`.
|
|
10
|
+
export type { AppendOnlySource } from './append-only';
|
|
11
|
+
export { appendOnlyRepo } from './append-only';
|
|
8
12
|
export type { BatchIterator } from './batch';
|
|
9
13
|
export type { MoneyColumns } from './column';
|
|
10
14
|
export { columnName, moneyColumns, snake } from './column';
|
|
@@ -30,7 +34,7 @@ export { CROSS_TENANT_SCOPE, crossTenant } from './cross-tenant';
|
|
|
30
34
|
export type { Database, DatabaseOptions, Driver, EntitySet } from './database';
|
|
31
35
|
export { database, defaultDriver, memoryDriver } from './database';
|
|
32
36
|
export type { DescribeInput } from './describe';
|
|
33
|
-
export type { Entity, EntityCore, EntityInit
|
|
37
|
+
export type { Entity, EntityCore, EntityInit } from './entity';
|
|
34
38
|
export { entity, SOFT_DELETE_COLUMN } from './entity';
|
|
35
39
|
// The vocabulary an EXISTING schema needs. Separate from the blessed builders on purpose: those
|
|
36
40
|
// are decisions this framework made for a table it was going to create, and these are the shapes
|
|
@@ -73,6 +77,7 @@ export {
|
|
|
73
77
|
stateTransitionIllegal,
|
|
74
78
|
stateUndeclared,
|
|
75
79
|
} from './feature-errors';
|
|
80
|
+
export type { IndexInit } from './index-init';
|
|
76
81
|
export type { Invariant, InvariantDef, InvariantKind } from './invariants';
|
|
77
82
|
export { assertInvariants, invariant, MAX_ASSERTED_ROWS } from './invariants';
|
|
78
83
|
// How Postgres compares two values of one column, decided by the column's declared KIND. Exported
|
|
@@ -126,7 +131,6 @@ export type {
|
|
|
126
131
|
FindByIdOptions,
|
|
127
132
|
FindManyArgs,
|
|
128
133
|
MemoryRepo,
|
|
129
|
-
Page,
|
|
130
134
|
Repo,
|
|
131
135
|
RepoOptions,
|
|
132
136
|
Transactor,
|
package/src/memory-repo.ts
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
// that passes here means something about Postgres.
|
|
4
4
|
//
|
|
5
5
|
// Split from `repo.ts` when that file passed the 500-line ceiling. What stays there is the
|
|
6
|
-
// CONTRACT — `Repo`, `
|
|
7
|
-
// and which nothing about storing rows in a `Map` belongs in.
|
|
6
|
+
// CONTRACT — `Repo`, `FindManyArgs`, `Transactor` (the page is core's `Page`) — which `postgresRepo`
|
|
7
|
+
// implements too and which nothing about storing rows in a `Map` belongs in.
|
|
8
8
|
|
|
9
|
+
import { pageOf } from '@ultimat3/core';
|
|
9
10
|
import { aggregateColumnOf } from './aggregate';
|
|
10
11
|
import { foldAggregate } from './aggregate-fold';
|
|
12
|
+
import { appendOnlyRepo } from './append-only';
|
|
11
13
|
import { keyOf } from './batch-read';
|
|
12
14
|
import { conflictKeyOf, conflictKeys, upsertPlan } from './bulk-write';
|
|
13
15
|
import { entityNow } from './clock';
|
|
@@ -219,11 +221,10 @@ export const memoryRepo = <Row>(
|
|
|
219
221
|
const page = found.slice(start, start + plan.limit);
|
|
220
222
|
const last = page.at(-1);
|
|
221
223
|
const more = start + page.length < found.length;
|
|
222
|
-
return
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
};
|
|
224
|
+
return pageOf(
|
|
225
|
+
page,
|
|
226
|
+
more && last !== undefined ? cursorFor(entity, plan, last, storeKey(last)) : null,
|
|
227
|
+
);
|
|
227
228
|
},
|
|
228
229
|
|
|
229
230
|
async insert(values, options) {
|
|
@@ -426,8 +427,12 @@ export const memoryRepo = <Row>(
|
|
|
426
427
|
},
|
|
427
428
|
};
|
|
428
429
|
// The memory driver seals too: it stores what Postgres would, so a test that passes here is not
|
|
429
|
-
// passing over plaintext production never holds.
|
|
430
|
-
|
|
430
|
+
// passing over plaintext production never holds. Append-only outermost, in both drivers: a
|
|
431
|
+
// refused rewrite seals nothing and sends nothing.
|
|
432
|
+
return appendOnlyRepo<Row, MemoryRepo<Row>>(
|
|
433
|
+
entity,
|
|
434
|
+
sealedRepo<Row, MemoryRepo<Row>>(entity, repo),
|
|
435
|
+
);
|
|
431
436
|
};
|
|
432
437
|
|
|
433
438
|
let txCounter = 0;
|
package/src/pg-driver.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// being told — which is how `ctx.jobs.enqueue()` lands its outbox row atomically with the write
|
|
8
8
|
// that caused it. `RepoOptions.tx` is the in-memory driver's undo hook and is ignored here.
|
|
9
9
|
|
|
10
|
+
import { pageOf } from '@ultimat3/core';
|
|
10
11
|
import {
|
|
11
12
|
currentTx,
|
|
12
13
|
type DbClient,
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
} from '@ultimat3/db';
|
|
17
18
|
import { aggregateColumnOf, aggregateMinor, assertOneUnit } from './aggregate';
|
|
18
19
|
import { decodeAggregate } from './aggregate-decode';
|
|
20
|
+
import { appendOnlyRepo } from './append-only';
|
|
19
21
|
import {
|
|
20
22
|
conflictKeys,
|
|
21
23
|
insertChunks,
|
|
@@ -64,7 +66,7 @@ import { taggedWrite } from './write-tag';
|
|
|
64
66
|
export interface PostgresDriverOptions {
|
|
65
67
|
/**
|
|
66
68
|
* Pin a client. Left out, every call resolves `db()` — the ambient pool, or the open
|
|
67
|
-
* transaction when one is in scope. Tests pass `
|
|
69
|
+
* transaction when one is in scope. Tests pass `recordingClient()` here or install one
|
|
68
70
|
* globally with `setDbClient()`.
|
|
69
71
|
*/
|
|
70
72
|
readonly client?: DbClient | undefined;
|
|
@@ -247,22 +249,21 @@ export const postgresRepo = <Row>(
|
|
|
247
249
|
// statement, not one per row — its `await` already ended the coalescing window.
|
|
248
250
|
if (config.jitPreload !== false) tagSiblings(entity, rows);
|
|
249
251
|
const last = rows.at(-1);
|
|
250
|
-
return
|
|
252
|
+
return pageOf(
|
|
251
253
|
rows,
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
};
|
|
254
|
+
found.length > plan.limit && last !== undefined
|
|
255
|
+
? // Minted from the PHYSICAL row as well as the decoded one: a `timestamptz` decodes
|
|
256
|
+
// to a `Date`, and the microseconds that drops are the difference between a position
|
|
257
|
+
// the `order by` agrees with and one that cuts between two rows.
|
|
258
|
+
cursorFor(
|
|
259
|
+
entity,
|
|
260
|
+
plan,
|
|
261
|
+
last,
|
|
262
|
+
idOf(last),
|
|
263
|
+
sortPrecision(entity, plan.orderBy, page.at(-1)),
|
|
264
|
+
)
|
|
265
|
+
: null,
|
|
266
|
+
);
|
|
266
267
|
},
|
|
267
268
|
|
|
268
269
|
async insert(values) {
|
|
@@ -477,7 +478,8 @@ export const postgresRepo = <Row>(
|
|
|
477
478
|
},
|
|
478
479
|
};
|
|
479
480
|
// The one sealing seam, shared with `memoryRepo()`: sealed on the way in, opened on the way out.
|
|
480
|
-
|
|
481
|
+
// Append-only outermost, as there: a refused rewrite seals nothing and sends no statement.
|
|
482
|
+
return appendOnlyRepo(entity, sealedRepo(entity, repo));
|
|
481
483
|
};
|
|
482
484
|
|
|
483
485
|
/**
|
package/src/query.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// concurrent writes an insert before the offset shifts every later page, so a client silently
|
|
5
5
|
// skips and repeats rows.
|
|
6
6
|
|
|
7
|
+
import type { Page } from '@ultimat3/core';
|
|
8
|
+
import { pageOf } from '@ultimat3/core';
|
|
7
9
|
import type { BatchIterator } from './batch';
|
|
8
10
|
import { assertBatchable, batchIterator } from './batch';
|
|
9
11
|
import { entityNow } from './clock';
|
|
@@ -12,7 +14,7 @@ import { searchUndeclared } from './feature-errors';
|
|
|
12
14
|
import { assertFinitePageSize, DEFAULT_PAGE_SIZE, namedColumns } from './plan';
|
|
13
15
|
import { preloaded, type RelatedTables } from './preload';
|
|
14
16
|
import { type PreloadOptions, type PreloadRelation, preloadsWith } from './preload-ceiling';
|
|
15
|
-
import type {
|
|
17
|
+
import type { Repo, RepoOptions, UpsertArgs } from './repo';
|
|
16
18
|
import { copyRow, pickRow } from './sealed';
|
|
17
19
|
import { SEARCH_PROPERTY } from './search';
|
|
18
20
|
import type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
|
|
@@ -380,14 +382,14 @@ const builder = <Source, Row>(
|
|
|
380
382
|
// in place of the chain's: one statement per batch, and the same one `page()` sends.
|
|
381
383
|
page: async (cursor) => {
|
|
382
384
|
const result = await repo.findMany({ ...args(), limit: size, cursor });
|
|
383
|
-
return
|
|
385
|
+
return pageOf(await attach(result.rows), result.nextCursor);
|
|
384
386
|
},
|
|
385
387
|
});
|
|
386
388
|
},
|
|
387
389
|
|
|
388
390
|
page: async () => {
|
|
389
391
|
const result = await repo.findMany(args());
|
|
390
|
-
return
|
|
392
|
+
return pageOf(await attach(result.rows), result.nextCursor);
|
|
391
393
|
},
|
|
392
394
|
|
|
393
395
|
all: async () => attach((await repo.findMany(args())).rows),
|
package/src/registry.ts
CHANGED
|
@@ -125,6 +125,8 @@ export interface EntityDescription {
|
|
|
125
125
|
readonly cacheTag: string;
|
|
126
126
|
readonly softDelete: boolean;
|
|
127
127
|
readonly orgScoped: boolean;
|
|
128
|
+
/** `entity({ appendOnly: true })`; absent on every entity that did not say it. `x db gen` reads it. */
|
|
129
|
+
readonly appendOnly?: true;
|
|
128
130
|
}
|
|
129
131
|
|
|
130
132
|
export interface RegistryEntry {
|
package/src/repo.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// table silently skips and repeats rows. A keyset cursor is stable because it names a
|
|
8
8
|
// position in the sort order, not a row count.
|
|
9
9
|
|
|
10
|
+
import type { Page } from '@ultimat3/core';
|
|
10
11
|
import type { AggregateFn } from './aggregate';
|
|
11
12
|
import type { Predicate, SortKey } from './tenancy';
|
|
12
13
|
import type { IdOf, RowPatch, RowWrite } from './types';
|
|
@@ -72,12 +73,6 @@ export interface FindManyArgs extends FindByIdOptions {
|
|
|
72
73
|
readonly select?: readonly string[];
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
export interface Page<T> {
|
|
76
|
-
readonly rows: readonly T[];
|
|
77
|
-
/** Pass back as `cursor`. `null` means this was the last page. */
|
|
78
|
-
readonly nextCursor: string | null;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
76
|
/**
|
|
82
77
|
* `T` defaults to `unknown` so a row-agnostic consumer (the generated admin, the manifest
|
|
83
78
|
* emitter) can name the shape without knowing the entity.
|
|
@@ -93,6 +88,10 @@ export interface Page<T> {
|
|
|
93
88
|
*/
|
|
94
89
|
export interface Repo<T = unknown> {
|
|
95
90
|
findById(id: IdOf<T>, options?: FindByIdOptions): Promise<T | null>;
|
|
91
|
+
/**
|
|
92
|
+
* One page: `@ultimat3/core`'s `Page`, the framework's one page shape. Pass `nextCursor` back as
|
|
93
|
+
* `cursor`; it is `null` exactly when `hasMore` is false — this was the last page.
|
|
94
|
+
*/
|
|
96
95
|
findMany(args?: FindManyArgs): Promise<Page<T>>;
|
|
97
96
|
insert(values: RowWrite<T>, options?: RepoOptions): Promise<T>;
|
|
98
97
|
/**
|
package/src/row-observer.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// so it reports the same changes whether rows live in memory or in Postgres.
|
|
3
3
|
//
|
|
4
4
|
// It exists because a change feed needs a source and only one of the two has one. Production
|
|
5
|
-
// decodes the write-ahead log (`@ultimat3/realtime`'s `
|
|
6
|
-
// walsender and the memory driver has no log at all, so `
|
|
5
|
+
// decodes the write-ahead log (`@ultimat3/realtime`'s `postgresChangeFeed()`); PGlite has no
|
|
6
|
+
// walsender and the memory driver has no log at all, so `memoryChangeFeed()` — which that package
|
|
7
7
|
// calls "the blessed development and test feed" — had nothing upstream of it. That is what left
|
|
8
8
|
// `@ultimat3/testing`'s `subscribe` fixture with no driver: a live query with no changes flowing
|
|
9
9
|
// into it is a snapshot, and a snapshot is not what those tests assert.
|
package/src/sealed-repo.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// it. Both driver factories return through this, which is why the typed handle and a hand-held
|
|
5
5
|
// `postgresRepo()` cannot disagree. An entity with no sealed column gets its repository back as is.
|
|
6
6
|
|
|
7
|
+
import { pageOf } from '@ultimat3/core';
|
|
7
8
|
import type { AggregateFn } from './aggregate';
|
|
8
9
|
import { namedColumns } from './plan';
|
|
9
10
|
import type { FindManyArgs, Repo, UpsertArgs } from './repo';
|
|
@@ -177,7 +178,7 @@ export const sealedRepo = <Row, R extends Repo<Row>>(entity: SealedSource, repo:
|
|
|
177
178
|
async findMany(args) {
|
|
178
179
|
const keys = sealKeysOnce();
|
|
179
180
|
const page = await repo.findMany(await readArgs(entity, fields, args, keys));
|
|
180
|
-
return
|
|
181
|
+
return pageOf(await openRows(fields, page.rows, keys), page.nextCursor);
|
|
181
182
|
},
|
|
182
183
|
async insert(values, options) {
|
|
183
184
|
const keys = sealKeysOnce();
|
package/src/seed.ts
CHANGED
|
@@ -113,7 +113,8 @@ export interface SeedContext {
|
|
|
113
113
|
* One row whose id the TABLE owns, matched on the natural key `by` names. Reads first so the
|
|
114
114
|
* answer can be `'skipped'`, then writes with a single `on conflict … do update`, which is what
|
|
115
115
|
* settles the race between two containers booting at once — the read is for the report, never
|
|
116
|
-
* for the decision.
|
|
116
|
+
* for the decision. On an `appendOnly` entity the write is `on conflict do nothing`: an absent
|
|
117
|
+
* row is appended, and a stored row the seed would CHANGE is refused (`X_ENTITY_APPEND_ONLY`).
|
|
117
118
|
*/
|
|
118
119
|
upsert<Row, C extends ColumnMap>(
|
|
119
120
|
entity: EntityCore<Row, C>,
|
|
@@ -320,6 +321,20 @@ export const defineSeed = (
|
|
|
320
321
|
metrics.skipped += 1;
|
|
321
322
|
return 'skipped';
|
|
322
323
|
}
|
|
324
|
+
if (stored === undefined && entity.$appendOnly) {
|
|
325
|
+
// An appendOnly table refuses `do update` outright, even with nothing to update; a
|
|
326
|
+
// do-nothing conflict still settles two boots racing on the key, and the loser's row
|
|
327
|
+
// was the winner's — the same seed — so it is reported skipped, never a refusal.
|
|
328
|
+
// A CHANGED stored row still falls through below, to the append-only refusal.
|
|
329
|
+
const written = await repo.upsertAll([row], {
|
|
330
|
+
...undo,
|
|
331
|
+
onConflict: key.by,
|
|
332
|
+
onMatch: 'nothing',
|
|
333
|
+
});
|
|
334
|
+
const outcome: SeedWrite = written.length > 0 ? 'inserted' : 'skipped';
|
|
335
|
+
metrics[outcome] += 1;
|
|
336
|
+
return outcome;
|
|
337
|
+
}
|
|
323
338
|
const write: SeedWrite = stored === undefined ? 'inserted' : 'updated';
|
|
324
339
|
await repo.upsertAll([stored === undefined ? row : withoutPreserved(row, preserve)], {
|
|
325
340
|
...undo,
|
package/src/type-pins.ts
CHANGED
|
@@ -451,3 +451,28 @@ export type _MarkerSurvivesNullable = Assert<
|
|
|
451
451
|
export type _SealedTableIsAReadBuilder = Assert<
|
|
452
452
|
typeof sealedTable extends ReadBuilder<SealedPinRow> ? true : false
|
|
453
453
|
>;
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Entity exports no `Page` (owner decision O-12, 25.0.0): `@ultimat3/core`'s is the ONE page
|
|
457
|
+
* shape, and `findMany` answers it — import it from core (or `@ultimat3/query`, which re-exports
|
|
458
|
+
* it). A second spelling here is a second door to one name; re-exporting it from this barrel
|
|
459
|
+
* makes this directive unused, which is the build error.
|
|
460
|
+
*/
|
|
461
|
+
// @ts-expect-error — `Page` is not an export of `@ultimat3/entity`.
|
|
462
|
+
export type _EntityExportsNoPage = import('./index').Page<unknown>;
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* `findMany` answers core's `Page`, both halves of the union: a repo page whose `nextCursor` could
|
|
466
|
+
* be set on the last page (entity's 24.x `Page` had no `hasMore` at all) fails here.
|
|
467
|
+
*/
|
|
468
|
+
export type _FindManyAnswersCorePage = Assert<
|
|
469
|
+
[Awaited<ReturnType<Repo<{ id: string }>['findMany']>>] extends [
|
|
470
|
+
import('@ultimat3/core').Page<{ id: string }>,
|
|
471
|
+
]
|
|
472
|
+
? [import('@ultimat3/core').Page<{ id: string }>] extends [
|
|
473
|
+
Awaited<ReturnType<Repo<{ id: string }>['findMany']>>,
|
|
474
|
+
]
|
|
475
|
+
? true
|
|
476
|
+
: false
|
|
477
|
+
: false
|
|
478
|
+
>;
|