@ultimat3/entity 23.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/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/bulk-write.ts CHANGED
@@ -11,6 +11,7 @@ import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
11
11
  import { EntityError, invariantViolated } from './errors';
12
12
  import { columnsOf } from './pg-row';
13
13
  import type { RowPatch } from './types';
14
+ import { uniqueConstraints } from './unique-constraints';
14
15
 
15
16
  /**
16
17
  * Postgres binds at most 65535 parameters in one statement and a multi-row insert spends
@@ -134,22 +135,12 @@ const crossTenantUpsert = (
134
135
  });
135
136
 
136
137
  /**
137
- * Every conflict target Postgres could infer an index for. Three sources, because this framework
138
- * has three ways to declare one unique index and a target refused for being declared the "wrong"
139
- * way would send its author to add a SECOND declaration of a constraint they already wrote:
140
- * the primary key, `unique()`/`indexes: [{ unique: true }]` (both land in `$indexes`), and
141
- * `invariant(name, c.unique([…]))`, which emits its `create unique index` out of `$invariants`.
142
- *
143
- * A partial unique index is excluded from all three: its predicate would have to be repeated in
144
- * the `on conflict` clause, which this layer does not spell. `bindInvariant` stamps that `where`
145
- * on a soft-deleting entity, so the same one rule covers both lists.
138
+ * Every conflict target Postgres could infer an index for: the primary key, and every non-partial
139
+ * unique the entity declares by either spelling (`unique-constraints.ts`).
146
140
  */
147
141
  const uniqueTargets = <Row>(entity: EntityCore<Row>): readonly (readonly string[])[] => [
148
142
  insertColumns(entity, entity.$primaryKey),
149
- ...entity.$indexes.filter((i) => i.unique && i.where === undefined).map((i) => i.columns),
150
- ...entity.$invariants
151
- .filter((i) => i.kind === 'unique' && i.where === undefined)
152
- .map((i) => i.columns),
143
+ ...uniqueConstraints(entity).map((unique) => unique.columns),
153
144
  ];
154
145
 
155
146
  const sameColumns = (left: readonly string[], right: readonly string[]): boolean =>
package/src/column.ts CHANGED
@@ -240,8 +240,43 @@ export const makeColumn = <T, Optional extends boolean>(
240
240
  * the derived name is checked, because that runs once per column at `entity()` rather than on
241
241
  * every statement.
242
242
  */
243
+ const isPhysicalName = (name: string): boolean =>
244
+ /^[a-z_][a-z0-9_$]*$/.test(name) && name.length <= 63;
245
+
246
+ /** The nearest legal spelling of a name, so the fix hands back a value and never a placeholder. */
247
+ const physicalSpelling = (name: string): string => {
248
+ const snake = name
249
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
250
+ .toLowerCase()
251
+ .replace(/[^a-z0-9_$]+/g, '_')
252
+ .replace(/^_+|_+$/g, '');
253
+ return (/^[a-z_]/.test(snake) ? snake : `t_${snake}`).slice(0, 63);
254
+ };
255
+
256
+ /** A name as a TypeScript literal that parses whatever it holds — it is pasted into source. */
257
+ const asSource = (name: string): string =>
258
+ /^[\w-]+$/.test(name) ? `'${name}'` : JSON.stringify(name);
259
+
260
+ /**
261
+ * The TABLE's physical name — `init.table`, or the entity name when none is declared. The same
262
+ * rule as a column's, and its own refusal: it used to go through `assertColumnName`, which called
263
+ * a table "not a physical column name" and handed back `.column('created_at')`, an edit to a
264
+ * column that was never the problem. The repair is at `entity(name, { table })`, and the entity
265
+ * NAME stays what it was — cache tags and policies key on it.
266
+ */
267
+ export const assertTableName = (entityName: string, table: string): string => {
268
+ if (!isPhysicalName(table)) {
269
+ refuseColumn(
270
+ 'table-name',
271
+ `"${table}" is not a physical table name: lower-case letters, digits and underscores, at most 63 of them`,
272
+ `entity(${asSource(entityName)}, { table: '${physicalSpelling(table)}', columns }) # table is the physical name; the entity name is unchanged`,
273
+ );
274
+ }
275
+ return table;
276
+ };
277
+
243
278
  export const assertColumnName = (name: string): string => {
244
- if (!/^[a-z_][a-z0-9_$]*$/.test(name) || name.length > 63) {
279
+ if (!isPhysicalName(name)) {
245
280
  refuseColumn(
246
281
  'column-name',
247
282
  `"${name}" is not a physical column name: lower-case letters, digits and underscores, at most 63 of them`,
@@ -58,6 +58,25 @@ const canonicalDigits = (digits: string): string => {
58
58
  return negative && whole !== '0' ? `-${whole}` : whole;
59
59
  };
60
60
 
61
+ const INT8_MIN = -(2n ** 63n);
62
+ const INT8_MAX = 2n ** 63n - 1n;
63
+
64
+ /**
65
+ * Whole digits inside `int8`, in Postgres's spelling. Memory stored a 40-digit value the server
66
+ * answers `22003 — bigint out of range` for, so a row every app test wrote was one production
67
+ * refuses. The digits are never echoed: a column message reaches the log line.
68
+ */
69
+ const int8 = (digits: string): string => {
70
+ const value = BigInt(digits);
71
+ return value >= INT8_MIN && value <= INT8_MAX
72
+ ? canonicalDigits(digits)
73
+ : refuseColumn(
74
+ 'bigint',
75
+ 'the value is past the int8 range (-2^63 to 2^63 - 1), which Postgres answers 22003 for',
76
+ 'declare the column decimal({ precision: 40, scale: 0 }) — a numeric holds whole numbers past int8 — and run x db gen "widen the bigint to numeric"',
77
+ );
78
+ };
79
+
61
80
  /**
62
81
  * An ACCEPTED decimal in Postgres's spelling: leading zeros stripped, `-0` and `-0.00` unsigned,
63
82
  * and a short fraction padded to the column's scale (`numeric(8, 2)` answers `7.50` for `7.5`).
@@ -86,7 +105,7 @@ const canonicalDecimal = (text: string, scale: number | undefined): string => {
86
105
  */
87
106
  export const bigint = (): Column<string> =>
88
107
  column<string>('bigint', (value) => {
89
- if (typeof value === 'bigint') return value.toString();
108
+ if (typeof value === 'bigint') return int8(value.toString());
90
109
  if (typeof value === 'number') {
91
110
  return Number.isSafeInteger(value)
92
111
  ? String(value)
@@ -97,7 +116,7 @@ export const bigint = (): Column<string> =>
97
116
  );
98
117
  }
99
118
  return typeof value === 'string' && DIGITS.test(value)
100
- ? canonicalDigits(value)
119
+ ? int8(value)
101
120
  : refuseColumn(
102
121
  'bigint',
103
122
  `expected whole digits, ${got(value)}`,
@@ -167,7 +186,9 @@ export const decimal = (options: DecimalOptions = {}): Column<string> => {
167
186
  `Number(value).toFixed(${scale}) at the call site decides the rounding, or widen the column to decimal({ precision: ${(precision ?? fraction) + fraction - scale}, scale: ${fraction} }) and run x db gen "widen the numeric"`,
168
187
  );
169
188
  }
170
- const whole = (digits[0] ?? '').replace(/^0+(?=\d)/, '').length;
189
+ // A whole part of `0` is NO digit: `numeric(2, 2)` holds `0.50`, and counting the zero
190
+ // refused every legal value of a `numeric(p, p)` column.
191
+ const whole = (digits[0] ?? '').replace(/^0+/, '').length;
171
192
  if (precision !== undefined && whole > precision - (scale ?? 0)) {
172
193
  return refuseColumn(
173
194
  'numeric',
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,
@@ -109,6 +109,9 @@ const parseInstant = (value: unknown): Date => {
109
109
  export const timestamp = (): TimestampColumn =>
110
110
  makeTimestamp<false>({ ...BARE, kind: 'timestamptz' }, parseInstant, false);
111
111
 
112
+ /** The column's CHECK (`~ '^https?://'`), before the scheme is lower-cased. */
113
+ const STORED_HTTP = /^https?:\/\//i;
114
+
112
115
  /**
113
116
  * An absolute http(s) URL, validated on write rather than on render: a bad URL stored once is
114
117
  * served to every reader, and `<img src>` fails silently in the browser.
@@ -126,7 +129,11 @@ export const url = (): Column<string> =>
126
129
  // The scheme is stored in its canonical LOWER case: the CHECK is `~ '^https?://'`, so
127
130
  // `HTTPS://a.b` was stored by memory and refused by Postgres. Only the scheme is
128
131
  // rewritten — the rest of the URL is the caller's, byte for byte.
129
- if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
132
+ // And the STORED text must itself open with it: WHATWG strips leading whitespace and
133
+ // embedded tabs and mends `https:/a.b` before `protocol` is read, while the column keeps
134
+ // the caller's bytes — which the CHECK then refused as a raw 23514.
135
+ const http = parsed.protocol === 'http:' || parsed.protocol === 'https:';
136
+ if (http && STORED_HTTP.test(value)) {
130
137
  return value.replace(/^https?(?=:)/i, (scheme) => scheme.toLowerCase());
131
138
  }
132
139
  } catch {
@@ -28,6 +28,15 @@ const sameElement = (left: unknown, right: unknown): boolean => {
28
28
  return left === right;
29
29
  };
30
30
 
31
+ /**
32
+ * Two elements of a SQL ARRAY, equal — and a NULL element is equal to nothing, itself included.
33
+ * `array['a', null] @> array[null]` and `array[null] && array[null]` are both FALSE in Postgres:
34
+ * the element comparison is `=`, and `NULL = NULL` is unknown. Arrays only — a `jsonb` `null` is a
35
+ * VALUE and equals itself, which is why `jsonContains` keeps `sameElement`.
36
+ */
37
+ const sameArrayElement = (left: unknown, right: unknown): boolean =>
38
+ !isNull(left) && !isNull(right) && sameElement(left, right);
39
+
31
40
  /**
32
41
  * `left @> right` for `jsonb`, to the letter of Postgres' definition — each clause below measured
33
42
  * on Postgres 16 rather than read off a summary, because three of them are easy to state wrongly:
@@ -67,7 +76,7 @@ const contains = (left: unknown, right: unknown, top: boolean): boolean => {
67
76
  * by every array, which is what Postgres answers.
68
77
  */
69
78
  export const arrayContains = (left: readonly unknown[], right: readonly unknown[]): boolean =>
70
- right.every((item) => left.some((candidate) => sameElement(candidate, item)));
79
+ right.every((item) => left.some((candidate) => sameArrayElement(candidate, item)));
71
80
 
72
81
  /**
73
82
  * `left && right`: they share at least one element. Arrays only — `jsonb` has no `&&` — and the
@@ -75,7 +84,7 @@ export const arrayContains = (left: readonly unknown[], right: readonly unknown[
75
84
  * contained by everything.
76
85
  */
77
86
  export const arrayOverlaps = (left: readonly unknown[], right: readonly unknown[]): boolean =>
78
- right.some((item) => left.some((candidate) => sameElement(candidate, item)));
87
+ right.some((item) => left.some((candidate) => sameArrayElement(candidate, item)));
79
88
 
80
89
  /**
81
90
  * The memory half of `col ? key`. The SQL side emits the OPERATOR, schema-qualified —
@@ -88,6 +97,10 @@ export const arrayOverlaps = (left: readonly unknown[], right: readonly unknown[
88
97
  * Three shapes, all of them Postgres': a top-level key of an object, a string ELEMENT of an array,
89
98
  * and a string value equal to the key. A number never matches — `'[1]'::jsonb ? '1'` is false
90
99
  * there, and a `String(item) === key` here would have made it true.
100
+ *
101
+ * The KEY is a string or the test matches no row, in both drivers: `pg-sql.ts` compiles any other
102
+ * operand to no rows rather than binding `String(key)`, which found a key named `"null"` for a
103
+ * `null` operand and `"1"` for the number `1`.
91
104
  */
92
105
  export const jsonHasKey = (value: unknown, key: unknown): boolean => {
93
106
  if (typeof key !== 'string' || isNull(value)) return false;
package/src/database.ts CHANGED
@@ -2,11 +2,11 @@
2
2
  // writes a repository class per entity, and nobody can reach a table that is not in the set.
3
3
 
4
4
  import type { EntityCore } from './entity';
5
- import { memoryRepo } from './memory-repo';
5
+ import { memoryRepo, memoryTransactor } from './memory-repo';
6
6
  import type { RelatedTables } from './preload';
7
7
  import type { Table } from './query';
8
8
  import { tableFor } from './query';
9
- import type { Repo } from './repo';
9
+ import type { Repo, Transactor } from './repo';
10
10
  import { observedRepo } from './row-observer';
11
11
 
12
12
  export type EntitySet = Readonly<Record<string, EntityCore>>;
@@ -25,6 +25,12 @@ export interface Driver {
25
25
  * `reset()` eventually would. A harness therefore asks and does not assume: `driver.reset?.()`.
26
26
  */
27
27
  reset?(): void;
28
+ /**
29
+ * The transactor that undoes THIS driver's writes. Required: a seed dry run writes for real and
30
+ * rolls back through it, and a driver that could not undo would be found out at run time. A
31
+ * driver that wraps another delegates — `transactor: () => inner.transactor()`.
32
+ */
33
+ transactor(): Transactor;
28
34
  }
29
35
 
30
36
  export interface DatabaseOptions {
@@ -54,6 +60,7 @@ export const memoryDriver = (): Driver => {
54
60
  reset() {
55
61
  for (const reset of resets) reset();
56
62
  },
63
+ transactor: () => memoryTransactor(),
57
64
  };
58
65
  };
59
66
 
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
  };
@@ -1,12 +1,19 @@
1
1
  // The entity layer's code registry, its error class, and the two refusals the declaration path
2
2
  // raises — split from `errors.ts` so a module the BROWSER loads (the record key, the projection,
3
- // the registry) can raise one without importing `@ultimat3/db`, which `errors.ts` needs for
4
- // `dbDrift`'s shell-inert fix line.
3
+ // the registry) can raise one without importing `@ultimat3/db`.
5
4
  import type { ErrorRetry } from '@ultimat3/core';
6
- import { registerErrorCodes, registerErrorRetry, UltimateError } from '@ultimat3/core';
5
+ import {
6
+ isFixShellSafe,
7
+ registerErrorCodes,
8
+ registerErrorRetry,
9
+ UltimateError,
10
+ } from '@ultimat3/core';
7
11
 
8
- /** Codes this package declares and owns. */
9
- export const ENTITY_OWNED_ERROR_CODES = [
12
+ /**
13
+ * Every code this package throws, and it owns each one. `X_DB_DRIFT` is `@ultimat3/db`'s and is
14
+ * raised there — drift is a fact about migrations.
15
+ */
16
+ export const ENTITY_ERROR_CODES = [
10
17
  'X_ENTITY_DUPLICATE',
11
18
  'X_INVARIANT_VIOLATED',
12
19
  'X_TENANCY_UNSCOPED',
@@ -31,25 +38,12 @@ export const ENTITY_OWNED_ERROR_CODES = [
31
38
  'X_RECORD_KEY_MISSING',
32
39
  'X_ENTITY_SEALED_PREDICATE',
33
40
  'X_ENTITY_SEALED_IN_VIEW',
41
+ 'X_ENTITY_APPEND_ONLY',
34
42
  ] as const;
35
43
 
36
- /**
37
- * `X_DB_DRIFT` is `@ultimat3/db`'s — drift is a fact about migrations, and this package imports db
38
- * rather than the other way round. `dbDrift()` below throws it; nothing here titles it, because a
39
- * second copy of the title is what lets the two packages disagree about what the code means.
40
- */
41
- export const ENTITY_BORROWED_ERROR_CODES = ['X_DB_DRIFT'] as const;
42
-
43
- /** Every code entity can throw: the ones it owns plus the one it borrows. */
44
- export const ENTITY_ERROR_CODES = [
45
- ...ENTITY_OWNED_ERROR_CODES,
46
- ...ENTITY_BORROWED_ERROR_CODES,
47
- ] as const;
48
-
49
- export type EntityOwnedErrorCode = (typeof ENTITY_OWNED_ERROR_CODES)[number];
50
44
  export type EntityErrorCode = (typeof ENTITY_ERROR_CODES)[number];
51
45
 
52
- export const ENTITY_ERROR_TITLES: Readonly<Record<EntityOwnedErrorCode, string>> = {
46
+ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityErrorCode, string>> = {
53
47
  X_ENTITY_DUPLICATE: 'two entities claim the same name',
54
48
  X_INVARIANT_VIOLATED: 'a domain invariant rejected this row',
55
49
  X_TENANCY_UNSCOPED: 'a tenant-scoped query has no org predicate',
@@ -77,6 +71,7 @@ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityOwnedErrorCode, string>>
77
71
  X_ENTITY_SEALED_PREDICATE:
78
72
  'a sealed column is read by a predicate, an order or a rule the database evaluates',
79
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',
80
75
  };
81
76
 
82
77
  // Registered at module load, unconditionally, in one call. Without this the registry humanises the
@@ -87,8 +82,8 @@ registerErrorCodes(
87
82
  );
88
83
 
89
84
  /**
90
- * How the codes here that are worth classifying are retried. Only the two sealed-column refusals:
91
- * 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
92
87
  * left unclassified, `classifyThrown` reads them as undecided and a job spends its whole retry
93
88
  * policy re-proving it. Listed although `terminal` is the default, for the reason core's own table
94
89
  * gives for `X_NOT_IMPLEMENTED`. The rest of this package's codes are unclassified, as they were.
@@ -96,7 +91,8 @@ registerErrorCodes(
96
91
  export const ENTITY_ERROR_RETRY = {
97
92
  X_ENTITY_SEALED_PREDICATE: 'terminal',
98
93
  X_ENTITY_SEALED_IN_VIEW: 'terminal',
99
- } as const satisfies Readonly<Partial<Record<EntityOwnedErrorCode, ErrorRetry>>>;
94
+ X_ENTITY_APPEND_ONLY: 'terminal',
95
+ } as const satisfies Readonly<Partial<Record<EntityErrorCode, ErrorRetry>>>;
100
96
 
101
97
  registerErrorRetry(ENTITY_ERROR_RETRY);
102
98
 
@@ -120,6 +116,18 @@ export class EntityError extends UltimateError {
120
116
  }
121
117
  }
122
118
 
119
+ /**
120
+ * The one command a BOUND refusal hands back — a page size, a preload ceiling, a judged write: it
121
+ * runs, and it names the real entity. The edit itself is code on a chain whose variable no error
122
+ * can know (`db.members`, not `preload_test_members`), so it is spelled in the `cause` as the call
123
+ * to write, never in the `fix` as though the entity name were that variable. A name a shell would
124
+ * read is not spliced.
125
+ */
126
+ export const describeCommand = (entityName: string): string =>
127
+ isFixShellSafe(entityName)
128
+ ? `x entities describe ${entityName} --json`
129
+ : 'x entities list --json';
130
+
123
131
  /**
124
132
  * The entity name is a VALUE, never a literal — `entity.$name`, `table`, the `name` `entity()` was
125
133
  * given. A literal is an entity that does not exist, and this fix then hands the reader
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
- import { assertColumnName, bindColumn, columnName, moneyColumns } from './column';
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
  /**
@@ -186,10 +173,12 @@ export const entity = <const C extends ColumnMap>(
186
173
  // Both branches. The declared table was checked and the fallback — which is every entity that
187
174
  // does not rename its table — was not, so an entity NAME closed the identifier in the same way a
188
175
  // column name could: `entity('t" (x int); drop table u; --')` emitted that `drop table` verbatim.
189
- const table = assertColumnName(init.table ?? name);
176
+ const table = assertTableName(name, init.table ?? name);
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;
@@ -242,10 +231,7 @@ export const entity = <const C extends ColumnMap>(
242
231
  return part === 'minor' ? parts.minor : parts.currency;
243
232
  };
244
233
 
245
- const columnsExpr = invariantColumns<C>(
246
- name,
247
- entries.map(([property]) => property),
248
- );
234
+ const columnsExpr = invariantColumns<C>(name, init.columns);
249
235
  // Through the one resolver, so a soft-delete column the table spells differently is still the
250
236
  // column every partial index excludes rows by.
251
237
  const partialWhere = softDelete ? `${resolve([SOFT_DELETE_COLUMN])} is null` : undefined;
@@ -383,6 +369,7 @@ export const entity = <const C extends ColumnMap>(
383
369
  softDelete,
384
370
  tenantColumn,
385
371
  search,
372
+ appendOnly,
386
373
  });
387
374
  const references = (): readonly ReferenceDescription[] => describeReferences(name, entries);
388
375
 
@@ -463,6 +450,7 @@ export const entity = <const C extends ColumnMap>(
463
450
  $tags: tags,
464
451
  $cacheTag: cacheTag,
465
452
  $softDelete: softDelete,
453
+ $appendOnly: appendOnly,
466
454
  $tenantColumn: tenantColumn,
467
455
  $search: search,
468
456
  $schema: schema,
package/src/errors.ts CHANGED
@@ -1,16 +1,14 @@
1
- // The entity layer's stable error codes. Each factory produces the exact command
2
- // that fixes the situation — `X_DB_DRIFT` is the flagship: it names the table, the
3
- // column and the generator invocation. The code registry, the class, `invariantViolated` and
4
- // `entityDuplicate` live in `entity-error.ts`, which imports no `@ultimat3/db`; re-exported here.
5
- import { shellInertIdentifier } from '@ultimat3/db';
1
+ // The entity layer's stable error codes. Each factory produces the exact command that fixes the
2
+ // situation. The code registry, the class, `invariantViolated` and `entityDuplicate` live in
3
+ // `entity-error.ts`, which imports no `@ultimat3/db`; re-exported here. `X_DB_DRIFT` is not
4
+ // raised here at all: drift is `@ultimat3/db`'s, factory included.
6
5
  import { EntityError } from './entity-error';
7
6
 
8
- export type { EntityErrorCode, EntityOwnedErrorCode } from './entity-error';
7
+ export type { EntityErrorCode } from './entity-error';
9
8
  export {
10
- ENTITY_BORROWED_ERROR_CODES,
9
+ describeCommand,
11
10
  ENTITY_ERROR_CODES,
12
11
  ENTITY_ERROR_TITLES,
13
- ENTITY_OWNED_ERROR_CODES,
14
12
  EntityError,
15
13
  entityDuplicate,
16
14
  invariantViolated,
@@ -72,7 +70,7 @@ export const tenancyUnscoped = (
72
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`,
73
71
  fix:
74
72
  actorOrg === undefined
75
- ? `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`
76
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>'")})`,
77
75
  });
78
76
 
@@ -161,24 +159,22 @@ export const crossTenantDenied = (init: {
161
159
  new EntityError({
162
160
  code: 'X_TENANCY_CROSS_DENIED',
163
161
  cause: `crossTenant(${JSON.stringify(init.reason)}) was entered by ${init.actor}, which does not carry the ${JSON.stringify(init.scope)} scope`,
164
- 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()`,
165
163
  });
166
164
 
167
165
  /**
168
- * A repository built with an explicit `client`, used while a transaction is open.
166
+ * A repository built with an explicit `client`, used inside a transaction that was opened on some
167
+ * OTHER client.
169
168
  *
170
- * Refused rather than resolved, because neither answer is available. `withTransaction` reserves
171
- * ONE connection and runs `BEGIN` on it — the ambient pool's, or a reservation of its own
172
- * `client:` option — while a repository pinned through `postgresDriver({ client })` sends every
173
- * statement straight to that client, which takes a different connection out of the pool. So the
174
- * write commits immediately and survives the rollback, and the read cannot see the rows the
175
- * transaction has already written; both are silent. Joining the transaction instead would be the
176
- * worse half of the same guess: a `DbTx` does not name the client it was opened on, so "is this
177
- * even the same database" is not a question this layer can ask, and on a sharded app the answer
178
- * is no.
169
+ * Refused rather than resolved. `withTransaction` reserves one connection and runs `BEGIN` on it,
170
+ * while a pinned repository sends to its own client — another connection, and on a sharded app
171
+ * another database — so the write would commit immediately and survive the rollback, and the read
172
+ * would miss rows the transaction has written; both silent. A transaction opened ON the pinned
173
+ * client (`tx.origin`) is the same database and the same reservation, and the repository joins it:
174
+ * that case never reaches here.
179
175
  *
180
- * The fix names the ambient seam because that is the one path a repository joins a transaction
181
- * through: `db()` resolves `currentTx()` first, which is exactly what a pinned client skips.
176
+ * The fix names the ambient seam because that is the path a repository joins the AMBIENT
177
+ * transaction through: `db()` resolves `currentTx()` first, which is what a pinned client skips.
182
178
  */
183
179
  export const repoClientPinned = (entityName: string): EntityError =>
184
180
  new EntityError({
@@ -187,28 +183,6 @@ export const repoClientPinned = (entityName: string): EntityError =>
187
183
  fix: `setDbClient(client) at boot and build the repository with no client: — postgresDriver() then resolves the open transaction through db() — or run this call outside withTransaction()`,
188
184
  });
189
185
 
190
- /**
191
- * The contract's pinned wording. Mirror of `@ultimat3/db`'s `dbDrift()` (`drift-errors.ts`) —
192
- * keep in sync; both screen through the same `shellInertIdentifier`, so the two lines are one
193
- * text on both sides of the tier seam, and `errors.test.ts` asserts it rather than asking.
194
- *
195
- * The column is the CATALOG's, so it is data, and `x db gen "add C"` puts it inside SHELL DOUBLE
196
- * QUOTES where `$(…)` and a backtick substitute before `x` is reached at all. The argument is a
197
- * migration DESCRIPTION, not an identifier, so no quoted form makes a hostile name safe to pass —
198
- * a refused one is left OUT of the command rather than escaped into it, and read off `cause`.
199
- */
200
- export const dbDrift = (tableName: string, columnName: string): EntityError =>
201
- new EntityError({
202
- code: 'X_DB_DRIFT',
203
- cause: `table "${tableName}" has column "${columnName}" not present in any migration`,
204
- fix:
205
- shellInertIdentifier(columnName) === null
206
- ? 'x db gen "add the column named in this error" # its name carries a backtick, a ' +
207
- 'dollar sign, a quote, a backslash or whitespace, so it is in the cause and not in ' +
208
- 'this command'
209
- : `x db gen "add ${columnName}"`,
210
- });
211
-
212
186
  export const notFound = (entityName: string, id: string): EntityError =>
213
187
  new EntityError({
214
188
  code: 'X_NOT_FOUND',