@ultimat3/entity 24.0.0 → 25.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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` (tier 1 — `columns.ts` and `columns-data.ts` read its `isValidTimeZone`, so a zone this package accepts is one `@ultimat3/time` can do arithmetic in). Nothing else — `http`,
8
- `policy` and `auth` are the same tier.
9
- - `db` is tier 1 (it imports only `core`), which is what lets the Postgres driver live **here**
10
- rather than in a tier-3 package: `Driver` and its production implementation stay in one place.
11
- See [`docs/architecture/01-package-map.md`](../../docs/architecture/01-package-map.md).
12
- - No `drizzle-orm` dependency, and none is the production backing — `postgresDriver()`
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`, `Page`, `FindManyArgs`, `Transactor`); `memory-repo.ts` is
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": "24.0.0",
3
+ "version": "25.1.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.0"
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": "24.0.0",
37
- "@ultimat3/db": "24.0.0",
38
- "@ultimat3/schema": "24.0.0",
39
- "@ultimat3/time": "24.0.0"
36
+ "@ultimat3/core": "25.1.0",
37
+ "@ultimat3/db": "25.1.0",
38
+ "@ultimat3/schema": "25.1.0",
39
+ "@ultimat3/time": "25.1.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 { uuid as uuidV7 } from '@ultimat3/core';
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
  };
@@ -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. Only the two sealed-column refusals:
84
- * both are the app's own declaration or call, so attempt five answers what attempt one did — and
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 { Expr, InvariantColumns, Resolve } from './expr';
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(createContext({ 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`
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(createContext({ actor }), fn); an ordinary request scopes to its own tenant instead and needs no crossTenant()`,
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
  /**
@@ -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
- fix: `add .searchable() to a text() column of ${entityName}, then: x db gen "search ${entityName}"`,
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, IndexInit } from './entity';
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,
@@ -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`, `Page`, `FindManyArgs`, `Transactor` — which `postgresRepo` implements too
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
- rows: page,
224
- nextCursor:
225
- more && last !== undefined ? cursorFor(entity, plan, last, storeKey(last)) : null,
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
- return sealedRepo<Row, MemoryRepo<Row>>(entity, repo);
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 `createRecordingClient()` here or install one
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
- nextCursor:
253
- found.length > plan.limit && last !== undefined
254
- ? // Minted from the PHYSICAL row as well as the decoded one: a `timestamptz` decodes
255
- // to a `Date`, and the microseconds that drops are the difference between a position
256
- // the `order by` agrees with and one that cuts between two rows.
257
- cursorFor(
258
- entity,
259
- plan,
260
- last,
261
- idOf(last),
262
- sortPrecision(entity, plan.orderBy, page.at(-1)),
263
- )
264
- : null,
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
- return sealedRepo(entity, repo);
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 { Page, Repo, RepoOptions, UpsertArgs } from './repo';
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 { rows: await attach(result.rows), nextCursor: result.nextCursor };
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 { rows: await attach(result.rows), nextCursor: result.nextCursor };
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
  /**
@@ -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 `PgLogicalReplicationFeed`); PGlite has no
6
- // walsender and the memory driver has no log at all, so `InMemoryChangeFeed` — which that package
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.
@@ -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 { rows: await openRows(fields, page.rows, keys), nextCursor: page.nextCursor };
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
+ >;