@ultimat3/entity 22.15.0 → 24.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/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',
@@ -3,17 +3,18 @@
3
3
  // store a value production answers 23514, 22003 or 22021 (a NUL in text) for. Split from `columns.ts` at its ceiling.
4
4
 
5
5
  import { charCount } from '@ultimat3/schema';
6
- import { column } from './column';
6
+ import { BARE, column } from './column';
7
7
  import { got, refuseNul } from './column-values';
8
8
  import { refuseColumn } from './refuse';
9
- import type { Column } from './types';
9
+ import { makeTextColumn } from './sealed-column';
10
+ import type { Column, TextColumn } from './types';
10
11
 
11
12
  export interface TextOptions {
12
13
  /** Emits `char_length(<column>) <= max`, so Postgres refuses an over-long string too. */
13
14
  readonly max?: number;
14
15
  }
15
16
 
16
- export const text = (options: TextOptions = {}): Column<string> => {
17
+ export const text = (options: TextOptions = {}): TextColumn => {
17
18
  const { max } = options;
18
19
  // Refused where it is declared: a `NaN` or fractional max emitted `char_length(x) <= NaN` into
19
20
  // the DDL, which Postgres refuses one migration later, far from the line that wrote it.
@@ -24,8 +25,14 @@ export const text = (options: TextOptions = {}): Column<string> => {
24
25
  'text({ max: 200 }) — a whole count of characters, or text() for no bound',
25
26
  );
26
27
  }
27
- return column<string>(
28
- 'text',
28
+ return makeTextColumn<string, false>(
29
+ {
30
+ ...BARE,
31
+ kind: 'text',
32
+ ...(max === undefined
33
+ ? {}
34
+ : { length: max, check: (name) => `char_length(${name}) <= ${max}` }),
35
+ },
29
36
  (value) => {
30
37
  if (typeof value !== 'string') {
31
38
  return refuseColumn(
@@ -45,7 +52,7 @@ export const text = (options: TextOptions = {}): Column<string> => {
45
52
  }
46
53
  return refuseNul(value);
47
54
  },
48
- max === undefined ? {} : { length: max, check: (name) => `char_length(${name}) <= ${max}` },
55
+ false,
49
56
  );
50
57
  };
51
58
 
package/src/columns.ts CHANGED
@@ -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
 
@@ -1,11 +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.
5
- import { registerErrorCodes, UltimateError } from '@ultimat3/core';
3
+ // the registry) can raise one without importing `@ultimat3/db`.
4
+ import type { ErrorRetry } from '@ultimat3/core';
5
+ import {
6
+ isFixShellSafe,
7
+ registerErrorCodes,
8
+ registerErrorRetry,
9
+ UltimateError,
10
+ } from '@ultimat3/core';
6
11
 
7
- /** Codes this package declares and owns. */
8
- 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 = [
9
17
  'X_ENTITY_DUPLICATE',
10
18
  'X_INVARIANT_VIOLATED',
11
19
  'X_TENANCY_UNSCOPED',
@@ -28,25 +36,13 @@ export const ENTITY_OWNED_ERROR_CODES = [
28
36
  'X_STATE_TRANSITION_ILLEGAL',
29
37
  'X_STATE_CONFLICT',
30
38
  'X_RECORD_KEY_MISSING',
39
+ 'X_ENTITY_SEALED_PREDICATE',
40
+ 'X_ENTITY_SEALED_IN_VIEW',
31
41
  ] as const;
32
42
 
33
- /**
34
- * `X_DB_DRIFT` is `@ultimat3/db`'s — drift is a fact about migrations, and this package imports db
35
- * rather than the other way round. `dbDrift()` below throws it; nothing here titles it, because a
36
- * second copy of the title is what lets the two packages disagree about what the code means.
37
- */
38
- export const ENTITY_BORROWED_ERROR_CODES = ['X_DB_DRIFT'] as const;
39
-
40
- /** Every code entity can throw: the ones it owns plus the one it borrows. */
41
- export const ENTITY_ERROR_CODES = [
42
- ...ENTITY_OWNED_ERROR_CODES,
43
- ...ENTITY_BORROWED_ERROR_CODES,
44
- ] as const;
45
-
46
- export type EntityOwnedErrorCode = (typeof ENTITY_OWNED_ERROR_CODES)[number];
47
43
  export type EntityErrorCode = (typeof ENTITY_ERROR_CODES)[number];
48
44
 
49
- export const ENTITY_ERROR_TITLES: Readonly<Record<EntityOwnedErrorCode, string>> = {
45
+ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityErrorCode, string>> = {
50
46
  X_ENTITY_DUPLICATE: 'two entities claim the same name',
51
47
  X_INVARIANT_VIOLATED: 'a domain invariant rejected this row',
52
48
  X_TENANCY_UNSCOPED: 'a tenant-scoped query has no org predicate',
@@ -71,6 +67,9 @@ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityOwnedErrorCode, string>>
71
67
  X_STATE_TRANSITION_ILLEGAL: 'the machine has no such transition',
72
68
  X_STATE_CONFLICT: 'the row is no longer in the state this transition named',
73
69
  X_RECORD_KEY_MISSING: 'a row reached its record key without a primary-key value',
70
+ X_ENTITY_SEALED_PREDICATE:
71
+ 'a sealed column is read by a predicate, an order or a rule the database evaluates',
72
+ X_ENTITY_SEALED_IN_VIEW: 'a sealed column is named in a view',
74
73
  };
75
74
 
76
75
  // Registered at module load, unconditionally, in one call. Without this the registry humanises the
@@ -80,6 +79,20 @@ registerErrorCodes(
80
79
  Object.fromEntries(Object.entries(ENTITY_ERROR_TITLES).map(([code, title]) => [code, { title }])),
81
80
  );
82
81
 
82
+ /**
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
+ * left unclassified, `classifyThrown` reads them as undecided and a job spends its whole retry
86
+ * policy re-proving it. Listed although `terminal` is the default, for the reason core's own table
87
+ * gives for `X_NOT_IMPLEMENTED`. The rest of this package's codes are unclassified, as they were.
88
+ */
89
+ export const ENTITY_ERROR_RETRY = {
90
+ X_ENTITY_SEALED_PREDICATE: 'terminal',
91
+ X_ENTITY_SEALED_IN_VIEW: 'terminal',
92
+ } as const satisfies Readonly<Partial<Record<EntityErrorCode, ErrorRetry>>>;
93
+
94
+ registerErrorRetry(ENTITY_ERROR_RETRY);
95
+
83
96
  /**
84
97
  * Base for every error this package throws. No `docs:` — `UltimateError` fills it from
85
98
  * `describeErrorCode(code).docs`, which is `@ultimat3/core`'s `ERROR_DOCS_URL`: one page for every
@@ -100,6 +113,18 @@ export class EntityError extends UltimateError {
100
113
  }
101
114
  }
102
115
 
116
+ /**
117
+ * The one command a BOUND refusal hands back — a page size, a preload ceiling, a judged write: it
118
+ * runs, and it names the real entity. The edit itself is code on a chain whose variable no error
119
+ * can know (`db.members`, not `preload_test_members`), so it is spelled in the `cause` as the call
120
+ * to write, never in the `fix` as though the entity name were that variable. A name a shell would
121
+ * read is not spliced.
122
+ */
123
+ export const describeCommand = (entityName: string): string =>
124
+ isFixShellSafe(entityName)
125
+ ? `x entities describe ${entityName} --json`
126
+ : 'x entities list --json';
127
+
103
128
  /**
104
129
  * The entity name is a VALUE, never a literal — `entity.$name`, `table`, the `name` `entity()` was
105
130
  * given. A literal is an entity that does not exist, and this fix then hands the reader
package/src/entity.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  import type { IndexMethod } from '@ultimat3/db';
7
7
  import { describeValue, type Schema } from '@ultimat3/schema';
8
8
  import { entityNow } from './clock';
9
- import { assertColumnName, bindColumn, columnName, moneyColumns } from './column';
9
+ import { assertTableName, bindColumn, columnName, moneyColumns } from './column';
10
10
  import { newId } from './columns';
11
11
  import { describeEntity, describeReferences } from './describe';
12
12
  import { invariantViolated } from './errors';
@@ -19,10 +19,13 @@ import { recordProjection } from './record-projection';
19
19
  import type { EntityDescription, ReferenceDescription } from './registry';
20
20
  import { registerEntity } from './registry';
21
21
  import { rowSchema } from './row-schema';
22
+ import { sealedFields } from './sealed';
23
+ import { assertSealedDeclaration } from './sealed-declaration';
24
+ import { sealedInView, sealedMissing } from './sealed-errors';
22
25
  import type { SearchInit, SearchSource, SearchVector } from './search';
23
26
  import { searchVectorOf } from './search';
24
27
  import { resolveTenantColumn } from './tenancy';
25
- import type { AnyColumn, ColumnMap, ColumnMeta, IndexDef, RowOf } from './types';
28
+ import type { AnyColumn, ColumnMap, ColumnMeta, IndexDef, RowOf, WireRow } from './types';
26
29
  import type { EntityView } from './view';
27
30
  import { viewFor } from './view';
28
31
 
@@ -123,8 +126,13 @@ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
123
126
  * (bare or wrapped: `t.array(posts.$schema)`) is a row the client store adopts, because its node
124
127
  * carries the `recordProjection` brand. A `$view` or a `.pick()` carries none: a partial row is
125
128
  * never a record.
129
+ *
130
+ * Typed `unknown` HERE and precisely on `Entity`: the parsed value is the row WITHOUT its sealed
131
+ * columns (`WireRow`), which is not `Row`, and a conditional over `C` in this interface would
132
+ * stop every `EntityCore<Row, C>` being an `EntityCore<Row>`. A row-agnostic consumer never read
133
+ * the output type; the one that does holds the `entity()` result.
126
134
  */
127
- readonly $schema: Schema<unknown, Row>;
135
+ readonly $schema: Schema<unknown, unknown>;
128
136
  /** `entity:<name>:<id>` — row-level invalidation for live queries. */
129
137
  $tagFor(id: string): string;
130
138
  /** Fills declared defaults, then validates every column. Throws on a bad value. */
@@ -149,7 +157,14 @@ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
149
157
  $references(): readonly ReferenceDescription[];
150
158
  }
151
159
 
152
- export type Entity<Row, C extends ColumnMap = ColumnMap> = EntityCore<Row, C> & C;
160
+ /**
161
+ * What `entity()` returns. `$schema` is restated with its real output: the row as it leaves the
162
+ * server, without the sealed columns — so `output: users.$schema` types a client that cannot read
163
+ * `password`, which is the value it will not be sent. `$parse` and `$row` are the whole row.
164
+ */
165
+ export type Entity<Row, C extends ColumnMap = ColumnMap> = Omit<EntityCore<Row, C>, '$schema'> & {
166
+ readonly $schema: Schema<unknown, WireRow<Row, C>>;
167
+ } & C;
153
168
 
154
169
  const MONEY_PARTS = new Set(['minor', 'currency']);
155
170
 
@@ -171,7 +186,7 @@ export const entity = <const C extends ColumnMap>(
171
186
  // Both branches. The declared table was checked and the fallback — which is every entity that
172
187
  // does not rename its table — was not, so an entity NAME closed the identifier in the same way a
173
188
  // column name could: `entity('t" (x int); drop table u; --')` emitted that `drop table` verbatim.
174
- const table = assertColumnName(init.table ?? name);
189
+ const table = assertTableName(name, init.table ?? name);
175
190
  const cacheTag = `entity:${name}`;
176
191
  const softDelete = Object.hasOwn(init.columns, SOFT_DELETE_COLUMN);
177
192
  const tenantColumn = resolveTenantColumn(name, init.columns, init.tenant);
@@ -227,10 +242,7 @@ export const entity = <const C extends ColumnMap>(
227
242
  return part === 'minor' ? parts.minor : parts.currency;
228
243
  };
229
244
 
230
- const columnsExpr = invariantColumns<C>(
231
- name,
232
- entries.map(([property]) => property),
233
- );
245
+ const columnsExpr = invariantColumns<C>(name, init.columns);
234
246
  // Through the one resolver, so a soft-delete column the table spells differently is still the
235
247
  // column every partial index excludes rows by.
236
248
  const partialWhere = softDelete ? `${resolve([SOFT_DELETE_COLUMN])} is null` : undefined;
@@ -341,6 +353,19 @@ export const entity = <const C extends ColumnMap>(
341
353
  declared.findIndex((other) => identity(other) === identity(index)) === position,
342
354
  );
343
355
 
356
+ const sealed = sealedFields({ $name: name, $table: table, $columns: init.columns });
357
+ assertSealedDeclaration({
358
+ name,
359
+ fields: sealed,
360
+ primaryKey,
361
+ tenantColumn,
362
+ indexes,
363
+ invariants,
364
+ });
365
+ const sealedKeys = new Set(sealed.map((field) => field.property));
366
+ /** The columns that leave the server: every one that is not sealed. */
367
+ const wireEntries = entries.filter(([property]) => !sealedKeys.has(property));
368
+
344
369
  const tags = [cacheTag, ...(init.tags ?? [])];
345
370
  const describe = (): EntityDescription =>
346
371
  describeEntity({
@@ -377,7 +402,11 @@ export const entity = <const C extends ColumnMap>(
377
402
  const given = raw === undefined ? defaultValue(column.$meta) : raw;
378
403
  if (given === undefined || given === null) {
379
404
  if (column.$meta.notNull) {
380
- throw invariantViolated(name, property, 'is required and has no default');
405
+ // A required sealed column ABSENT from a row is nearly always a spread of a repository
406
+ // row, which does not enumerate it — so the refusal names that, and the rewrite.
407
+ throw given === undefined && sealedKeys.has(property)
408
+ ? sealedMissing(name, property)
409
+ : invariantViolated(name, property, 'is required and has no default');
381
410
  }
382
411
  row[property] = null;
383
412
  continue;
@@ -388,6 +417,39 @@ export const entity = <const C extends ColumnMap>(
388
417
  return row as Row;
389
418
  };
390
419
 
420
+ /**
421
+ * The row as `$schema` validates it: the sealed columns are neither required nor returned, so a
422
+ * handler's row — which holds their plaintext — parses to a value that does not.
423
+ */
424
+ const parseWire = (value: unknown): WireRow<Row, C> => {
425
+ if (sealedKeys.size === 0) return parse(value) as WireRow<Row, C>;
426
+ if (typeof value !== 'object' || value === null) {
427
+ throw invariantViolated(name, 'row', `expected an object, got ${describeValue(value)}`);
428
+ }
429
+ const input = value as Readonly<Record<string, unknown>>;
430
+ const row: Record<string, unknown> = {};
431
+ for (const [property, column] of wireEntries) {
432
+ const raw = input[property];
433
+ const given = raw === undefined ? defaultValue(column.$meta) : raw;
434
+ if (given === undefined || given === null) {
435
+ if (column.$meta.notNull) {
436
+ throw invariantViolated(name, property, 'is required and has no default');
437
+ }
438
+ row[property] = null;
439
+ continue;
440
+ }
441
+ row[property] = column.$parse(given);
442
+ }
443
+ // Every wire property was validated by its own column, and no sealed one was copied.
444
+ return row as WireRow<Row, C>;
445
+ };
446
+
447
+ const schema = rowSchema<WireRow<Row, C>>(
448
+ { name, table, primaryKey, persist: init.persist === true, sealed: [...sealedKeys] },
449
+ wireEntries,
450
+ parseWire,
451
+ );
452
+
391
453
  const core: EntityCore<Row, C> = {
392
454
  $name: name,
393
455
  $table: table,
@@ -400,19 +462,19 @@ export const entity = <const C extends ColumnMap>(
400
462
  $softDelete: softDelete,
401
463
  $tenantColumn: tenantColumn,
402
464
  $search: search,
403
- $schema: rowSchema<Row>(
404
- { name, table, primaryKey, persist: init.persist === true },
405
- entries,
406
- parse,
407
- ),
465
+ $schema: schema,
408
466
  get $row(): Row {
409
467
  // Type-only. Reading it means someone expected a value where a type was meant.
410
468
  throw invariantViolated(name, '$row', '$row is a type, not a value — use typeof x.$row');
411
469
  },
412
470
  $tagFor: (id) => `${cacheTag}:${id}`,
413
471
  $parse: parse,
414
- $view: <K extends keyof Row & string>(keys: readonly K[]) =>
415
- viewFor<Row, K>(name, init.columns, keys),
472
+ $view: <K extends keyof Row & string>(keys: readonly K[]) => {
473
+ // Before the view exists: a view is an `output`, and a sealed column is in none.
474
+ const named = keys.find((key) => sealedKeys.has(key));
475
+ if (named !== undefined) throw sealedInView(name, named);
476
+ return viewFor<Row, K>(name, init.columns, keys);
477
+ },
416
478
  $assert: (row) => assertInvariants(name, invariants, row),
417
479
  $describe: describe,
418
480
  $references: references,
@@ -429,5 +491,6 @@ export const entity = <const C extends ColumnMap>(
429
491
  });
430
492
  // The columns land on the entity itself so `orgs.id` is a column reference; every framework
431
493
  // member is `$`-prefixed, which is why a column may be called `name`.
432
- return Object.assign(core, init.columns);
494
+ // `$schema` is restated so the result carries its real output type; it is the same object.
495
+ return Object.assign(core, init.columns, { $schema: schema });
433
496
  };
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,
@@ -165,20 +163,18 @@ export const crossTenantDenied = (init: {
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',