@ultimat3/entity 22.15.0 → 23.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -87,37 +87,13 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
87
87
  (`close()` = `AsyncGenerator.return()`), `.cursor` is advanced before the yield, no empty batch, and
88
88
  three refusals on the chain (size, `limit()` alongside, an order no cursor can carry — judged by
89
89
  exported `totalOrder`).
90
- - **A grouped count means one thing in both drivers, and `count-by.ts` is where that one thing is
91
- written.** `countBy(column)` is the aggregate a `count()` per row is the N+1 of, so both drivers
92
- call `groupColumnOf` before their statement exists and `countsFrom` after their rows are in — a
93
- rule added to `pg-driver.ts` or to `memory-repo.ts` alone is exactly the drift that file exists
94
- to prevent. **Groupable kinds are a closed set**: `uuid`, `text`, `char`, `boolean`, `integer`,
95
- `bigint`. A `timestamptz` is a `Date`, a `jsonb` is an object and `money` is two physical columns
96
- — a `Map` compares a non-primitive key by identity, so any of those would file rows under a key
97
- no caller can look up again and the result would be a map that only ever answers `undefined`. The
98
- refusal is `X_INVARIANT_VIOLATED` naming a column of *this* entity that is groupable, never
99
- `x entity explain`: what repairs it is one edit to the call, and the entity is the only place the
100
- replacement column lives. **The bound is a refusal, not a truncation.** The statement asks for
101
- `MAX_GROUPS + 1` groups — the trick a page already uses when it reads one row past its limit — and
102
- that extra group is what says the answer was never going to fit, so `countsFrom` throws with the
103
- `andWhere(…, 'in', <values>)` that bounds it. Truncating would hand back a map that reads exactly
104
- like a complete one, and a caller recounting from it would write the wrong number to every row it
105
- missed. **Absent is not `0`**: a value nothing matched has no entry, because that is what
106
- `group by` returns and it is the only way a caller can tell "none" from "never asked" — the
107
- `?? 0` is theirs to write, and inventing it here would answer for keys the table has never seen.
108
- **NULL is one group**, keyed `null`: the memory driver reads the property as `?? null` so it lands
109
- where Postgres puts its NULL rows, while `0`, `''` and `false` stay the values they are. **The
110
- order is applied after the rows are in, never in SQL** — a hash aggregate returns groups in
111
- whatever order it built them and a `Map` filled row by row returns insertion order, so an
112
- `order by` in the statement would let the two drivers disagree about a result they agree on;
113
- sorting groups (never rows) costs nothing at this size and is what puts the largest bucket at the
114
- front. **Both output names are fixed aliases** — `group_value` and `group_count` in
115
- `countByStatement` (`pg-sql.ts`) — because an entity is free to declare a column called `count`,
116
- and the un-aliased form would then return two outputs of one name; the grouped value is re-parsed
117
- by the column that declared it, since `int8` arrives as a string and would otherwise key the map
118
- by text where memory keys it by a `bigint`. **Nothing new to declare**: no `groupBy()` builder and
119
- no error code of its own — it is a terminal on the chain that already exists, over exactly the
120
- rows `count()` counts.
90
+ - **A grouped count means one thing in both drivers, written in `count-by.ts`**: both call
91
+ `groupColumnOf` before the statement and `countsFrom` after the rows. Groupable kinds are a closed
92
+ set (`uuid`, `text`, `char`, `boolean`, `integer`, `bigint`); the bound is a REFUSAL at
93
+ `MAX_GROUPS + 1`, never a truncation; absent is not `0`; NULL is one group keyed `null`; the order
94
+ is applied after the rows are in, never in SQL; `group_value` / `group_count` are fixed aliases.
95
+ The refusal names a groupable column of THIS entity, never `x entity explain`.
96
+ The argument for each: [`docs/history/entity.md`](../../docs/history/entity.md).
121
97
  - **The codec is `@ultimat3/core`'s**, reached through `cursorFor(entity, plan, row, id)` and
122
98
  `seekFrom(entity, plan)`; **`assertSeekable` runs in `planFor`**, before a statement exists. A
123
99
  `timestamptz` sort key is refused when its `<column>$US` alias would pass 63 bytes. A cursor is bound
@@ -248,6 +224,35 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
248
224
  after the refusal from a tenant-scoped `findById`; no DDL beyond `enumerated()`'s CHECK; a machine
249
225
  column may not be nullable; `whyNot` asks unknown → terminal → legal list.
250
226
 
227
+ ## Do not regress — sealed columns
228
+
229
+ - **`sealed-repo.ts` is the ONE seam**: `memoryRepo()` and `postgresRepo()` both return through
230
+ `sealedRepo(entity, repo)`, which seals rows and patches going in, opens rows coming out and
231
+ rewrites or refuses every predicate. A driver never seals for itself; an entity with no sealed
232
+ column gets its repository back untouched. The cipher is core's (`scripts/seal-calls.ts`).
233
+ - **A sealed column's `$parse` passes a sealed string and judges a plaintext**; `decodeRow` parses
234
+ none. `sealValue` runs the plaintext parser BEFORE sealing — nothing after can.
235
+ - **No reading of an unsealed value.** Not sealed is `X_SEAL_INVALID`; existing plaintext is
236
+ expand → `backfill()` → contract (README). No `legacy` option.
237
+ - **Purpose is `entity:<table>.<column>`, physical names** (`sealed.ts`). **Sealing is not on
238
+ `$describe()`**: it changes no DDL, so it must not move the drift hash — the manifest and
239
+ `x doctor` read `sealedFields(entity)`. Only `check`/`length` leave the description.
240
+ - **A sealed property on a repository row is SERVER-ONLY** — own, readable, writable, NOT
241
+ enumerable (`serverOnly`, `sealed.ts`; set by `openRow`). Every generic serialiser omits it, which
242
+ is what holds a `query`'s rows, a loose `output`, a log line, an island prop, a job payload and a
243
+ cache tier — none of them parses a row. So ABOVE the seam a row is copied with `copyRow` /
244
+ `pickRow` (descriptors), never `{ ...row }`, `Object.assign({}, row)` or `out[k] = row[k]`:
245
+ `preload.ts`, `select()` and `touch()` in `query.ts`. `sealRow` reads each sealed property BY NAME,
246
+ so a row passed back whole is written whole. A required one missing on insert is `sealedMissing`
247
+ (`X_INVARIANT_VIOLATED`, from `$parse` and from the seam), naming the spread. `sealed.test.ts`.
248
+ - **Nothing sealed leaves**: `$schema` (node and parsed value; typed `WireRow` on `Entity`,
249
+ `unknown` on `EntityCore`), `$view` (`X_ENTITY_SEALED_IN_VIEW`), `rowsOf`, the row observer, and
250
+ the repository row itself (above). `sealed-egress.test.ts` here and one per consuming package.
251
+ - **Opaque: NULL tests only. `lookup`: also `eq` / `in`, `.unique()`, an unordered index.**
252
+ Refused at the type (`SealedKeysOf<C>` on `ReadBuilder`, `type-pins.ts`) AND at runtime
253
+ (`X_ENTITY_SEALED_PREDICATE`). A filtered write's FILTER refuses both kinds. Declaration rules
254
+ are `sealed-column.ts` (the chain) and `sealed-declaration.ts` (the entity).
255
+
251
256
  ## Do not regress — records and tests
252
257
 
253
258
  - **An entity row on the client is a RECORD, derived** (`row-schema.ts`, `ENTITY_BRAND` =
package/README.md CHANGED
@@ -113,10 +113,121 @@ never runs, while the subpath retains the projection, the key and the registry a
113
113
  | `text({ max })`, `integer()`, `boolean()`, `url()` | `text`/`integer`/`boolean` + CHECK | format is enforced by the database too |
114
114
 
115
115
  Chain: `.primaryKey()` · `.nullable()` · `.unique()` · `.default(v)` · `.defaultNow()` ·
116
- `.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()` · `.column(name)`.
116
+ `.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()` · `.column(name)` ·
117
+ `.sealed()` (`text()` only).
117
118
  Physical names are derived from the property key (`orgId` → `org_id`); a name is written once, or
118
119
  never — `.column()` is the exception, and it exists for tables this framework did not create.
119
120
 
121
+ ## A sealed column
122
+
123
+ A credential column is declared `.sealed()`. The row type stays `string`; the database never sees
124
+ the plaintext.
125
+
126
+ ```ts
127
+ import { database, entity, text, uuid } from '@ultimat3/entity';
128
+
129
+ export const connections = entity('connections', {
130
+ columns: {
131
+ id: uuid().primaryKey(),
132
+ label: text({ max: 80 }),
133
+ password: text({ max: 200 }).sealed(), // opaque: read and written, never compared
134
+ token: text().nullable().sealed(),
135
+ email: text().sealed({ lookup: true }).unique(), // deterministic: equality only
136
+ },
137
+ });
138
+
139
+ const db = database({ connections });
140
+
141
+ export async function passwordFor(email: string): Promise<string | null> {
142
+ const row = await db.connections.where({ email }).one();
143
+ return row?.password ?? null; // the plaintext — on the server, and nowhere else
144
+ }
145
+ ```
146
+
147
+ | Fact | |
148
+ |---|---|
149
+ | Builder | `text()` only — `text().sealed()`, in any order with `.nullable()`, `.unique()`, `.column()`. A sealed number or date does not compile |
150
+ | Stored | `x1.<keyId>.<iv>.<ciphertext+tag>` from `@ultimat3/core`'s `seal()`, in a plain `text` column. No new SQL type; a `text({ max })` bound is checked against the plaintext before sealing and emits no CHECK |
151
+ | Key | the master key `x secrets` manages. No key is `X_SEAL_KEY_MISSING` — the plaintext is never stored instead |
152
+ | Purpose | derived: `entity:<table>.<column>`, physical names. Never stated, so two columns cannot share one; a value copied between sealed columns does not open. Renaming the table or the column is a re-seal — pin the name with `.column()` |
153
+ | Both drivers | `memoryDriver()` seals too; `memoryRepo(entity, seed)` refuses a seed for a sealed entity (`insertAll` instead) |
154
+ | The seam | `sealedRepo(entity, repo)` — both driver factories return through it, so `database()`'s handle and a hand-held `postgresRepo(entity)` agree. A driver written outside this package returns through it too |
155
+ | Cost | the key ring is resolved ONCE per repository call, however many values it seals or opens — a 500-row page is one resolution, not a thousand |
156
+ | Retry | `X_ENTITY_SEALED_PREDICATE` and `X_ENTITY_SEALED_IN_VIEW` are registered `terminal` (`ENTITY_ERROR_RETRY`): a job stops on its first attempt |
157
+
158
+ **What leaves the server: nothing.**
159
+
160
+ | Path | Sealed column |
161
+ |---|---|
162
+ | `entity.$schema` (an action's or query's `output`) | absent from the node and from the parsed value; the type is `WireRow` — the row without it |
163
+ | `entity.$view([...])` | `X_ENTITY_SEALED_IN_VIEW` |
164
+ | a record (`rowsOf`, `recordProjection`, the `persist: true` browser store) | dropped from every collected row; `projection.sealed` lists them |
165
+ | a change reported to `setRowObserver` | dropped from `before` and `after` |
166
+ | `decodeRow` on a write-ahead-log row | the stored string, never the plaintext (`@ultimat3/realtime` drops the property before a change leaves) |
167
+ | a repository row serialised whole — a `query`'s rows, a loose `output`, a log line, an error's `meta`, an island prop, a job payload, a cache tier | absent: the property is **server-only** (below) |
168
+ | `{ token: row.token }` — the secret named by hand | leaves. Naming it is the one read there is |
169
+ | `$parse`, `$row`, the repository | the whole row, plaintext included — server code only |
170
+
171
+ **A sealed property is server-only**, `As of 2026-10`: own, readable and writable, **not
172
+ enumerable**. `row.password` is the plaintext; nothing that walks the row sees it.
173
+
174
+ | | |
175
+ |---|---|
176
+ | Reads it | `row.password`, `const { password } = row`, `update(id, row)` and `insert(row)` with the row passed whole |
177
+ | Does not | `JSON.stringify(row)`, `{ ...row }`, `Object.keys` / `entries` / `assign`, `structuredClone(row)` |
178
+ | Prints it | `console.log(row)` / `Bun.inspect(row)` — log through `logger` |
179
+ | A spread patch | `update(id, { ...row, label })` leaves the stored secret alone |
180
+ | A spread insert, required column | `X_INVARIANT_VIOLATED` naming the column and the rewrite: `insert({ ...row, password: row.password })` |
181
+ | A spread insert, **nullable** column | stores NULL — silent; an absent nullable column is a legal insert. Name it: `{ ...row, token: row.token }` |
182
+ | `select({ password: true })`, `preload()` | the row keeps it, still server-only |
183
+
184
+ **Testing a sealed row.** `toEqual` walks enumerable properties, so it cannot see the secret:
185
+ `expect(row).toEqual({ …, password })` fails with an empty diff, and `expect(a).toEqual(b)` passes
186
+ over a wrong one. Write one of:
187
+
188
+ | Assertion | Compares |
189
+ |---|---|
190
+ | `expect(row).toEqualRow({ id, label: 'primary', password: 'hunter2' })` | every own property (`@ultimat3/testing`) |
191
+ | `expect(row.password).toBe('hunter2')` | the one property, by name |
192
+ | `expect(row).toMatchObject({ password: 'hunter2' })` | the named subset |
193
+
194
+ `toEqualRow` compares every own property and names what differs — a server-only one by name,
195
+ never by value. No key is needed in a test: `@ultimat3/testing/preload` installs a throwaway one
196
+ when the app supplied none.
197
+
198
+ **What the database may compare: nothing, or equality.**
199
+
200
+ | Use | `.sealed()` | `.sealed({ lookup: true })` |
201
+ |---|---|---|
202
+ | `where` / `andWhere` `eq`, `in` | compile error; `X_ENTITY_SEALED_PREDICATE` at runtime | allowed — rewritten to the stored string |
203
+ | `andWhere(column, 'is-null')` / `'is-not-null'` | allowed — `null` is stored as NULL, never sealed, so presence is not a secret | allowed |
204
+ | any other operator, `orderBy`, `countBy`, an aggregate, a cursor | refused | refused |
205
+ | `deleteWhere` / `updateWhere` **filter** | refused | refused — find the row, then write by id |
206
+ | `.unique()`, a unique index, an upsert conflict target | refused | allowed |
207
+ | a plain index | refused | allowed, unordered |
208
+ | an invariant other than `unique` | refused | refused — a CHECK would read ciphertext |
209
+ | `.default()`, `.primaryKey()`, `.tenant()`, `.references()`, `.searchable()` | refused on the chain | refused on the chain |
210
+
211
+ `lookup` **reveals equality**: two rows holding one value store one string. Never for a low-entropy
212
+ value — a boolean, a status, a PIN.
213
+
214
+ **During a key rotation** a lookup value has one stored form per declared key, so equality reads
215
+ through `in (…)` over `sealAll()` and `.unique()` holds per key. `x doctor` reports
216
+ `X_SEAL_RESEAL_PENDING` until every row is rewritten (`update(id, { column: row.column })` seals
217
+ under the current key) and the retired key is dropped (`x secrets rotate --drop <keyId>`).
218
+
219
+ **A column that already holds plaintext is migrated, never tolerated.** A stored value that is not
220
+ a sealed value is `X_SEAL_INVALID`, always. Expand, backfill, contract:
221
+
222
+ | # | Step | Edit |
223
+ |---|---|---|
224
+ | 1 | expand | add `passwordSealed: text().nullable().sealed()` beside `password`; `x db gen "add sealed password"`; `x db migrate` |
225
+ | 2 | backfill | a `backfill()` over `db.connections.andWhere('passwordSealed', 'is-null')` — only the rows still to seal — whose `handle` runs `update(row.id, { passwordSealed: row.password })` per row. Idempotent, so a replayed page is harmless |
226
+ | 3 | switch | readers and writers use `passwordSealed` |
227
+ | 4 | contract | delete `password`; declare `password: text().sealed().column('password_sealed')` — the purpose follows the physical name, so nothing is re-sealed; `x db gen "drop plaintext password"` |
228
+
229
+ The full recipe with the `backfill()` declaration: [Entities and migrations](../../wiki/Entities-And-Migrations.md#sealing-a-column-that-already-holds-data).
230
+
120
231
  ## Wide columns
121
232
 
122
233
  The vocabulary an EXISTING schema needs. The blessed set above is a decision the framework made
@@ -888,7 +999,7 @@ database from its boot code has decided to, and a library that overruled that wo
888
999
  `X_TENANCY_ACTOR_MISMATCH` · `X_TENANCY_ACTOR_ORG_REQUIRED` · `X_TENANCY_CROSS_DENIED` ·
889
1000
  `X_DB_DRIFT` · `X_NOT_FOUND` · `X_WRITE_UNFILTERED` · `X_PATCH_EMPTY` ·
890
1001
  `X_PRELOAD_UNKNOWN_RELATION` · `X_N_PLUS_ONE_QUERY` · `X_N_PLUS_ONE_WRITE` ·
891
- `X_RECORD_KEY_MISSING`
1002
+ `X_RECORD_KEY_MISSING` · `X_ENTITY_SEALED_PREDICATE` · `X_ENTITY_SEALED_IN_VIEW`
892
1003
 
893
1004
  ### Error classes
894
1005
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "22.15.0",
3
+ "version": "23.0.0",
4
4
  "description": "A table + its domain type + invariants the database also enforces",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,6 +20,7 @@
20
20
  "files": [
21
21
  "src",
22
22
  "!src/**/*.test.ts",
23
+ "!src/**/*-fixture.ts",
23
24
  "CLAUDE.md",
24
25
  "README.md",
25
26
  "LICENSE"
@@ -32,9 +33,9 @@
32
33
  "test": "bun test"
33
34
  },
34
35
  "dependencies": {
35
- "@ultimat3/core": "22.15.0",
36
- "@ultimat3/db": "22.15.0",
37
- "@ultimat3/schema": "22.15.0",
38
- "@ultimat3/time": "22.15.0"
36
+ "@ultimat3/core": "23.0.0",
37
+ "@ultimat3/db": "23.0.0",
38
+ "@ultimat3/schema": "23.0.0",
39
+ "@ultimat3/time": "23.0.0"
39
40
  }
40
41
  }
@@ -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
 
@@ -2,7 +2,8 @@
2
2
  // raises — split from `errors.ts` so a module the BROWSER loads (the record key, the projection,
3
3
  // the registry) can raise one without importing `@ultimat3/db`, which `errors.ts` needs for
4
4
  // `dbDrift`'s shell-inert fix line.
5
- import { registerErrorCodes, UltimateError } from '@ultimat3/core';
5
+ import type { ErrorRetry } from '@ultimat3/core';
6
+ import { registerErrorCodes, registerErrorRetry, UltimateError } from '@ultimat3/core';
6
7
 
7
8
  /** Codes this package declares and owns. */
8
9
  export const ENTITY_OWNED_ERROR_CODES = [
@@ -28,6 +29,8 @@ export const ENTITY_OWNED_ERROR_CODES = [
28
29
  'X_STATE_TRANSITION_ILLEGAL',
29
30
  'X_STATE_CONFLICT',
30
31
  'X_RECORD_KEY_MISSING',
32
+ 'X_ENTITY_SEALED_PREDICATE',
33
+ 'X_ENTITY_SEALED_IN_VIEW',
31
34
  ] as const;
32
35
 
33
36
  /**
@@ -71,6 +74,9 @@ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityOwnedErrorCode, string>>
71
74
  X_STATE_TRANSITION_ILLEGAL: 'the machine has no such transition',
72
75
  X_STATE_CONFLICT: 'the row is no longer in the state this transition named',
73
76
  X_RECORD_KEY_MISSING: 'a row reached its record key without a primary-key value',
77
+ X_ENTITY_SEALED_PREDICATE:
78
+ 'a sealed column is read by a predicate, an order or a rule the database evaluates',
79
+ X_ENTITY_SEALED_IN_VIEW: 'a sealed column is named in a view',
74
80
  };
75
81
 
76
82
  // Registered at module load, unconditionally, in one call. Without this the registry humanises the
@@ -80,6 +86,20 @@ registerErrorCodes(
80
86
  Object.fromEntries(Object.entries(ENTITY_ERROR_TITLES).map(([code, title]) => [code, { title }])),
81
87
  );
82
88
 
89
+ /**
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
92
+ * left unclassified, `classifyThrown` reads them as undecided and a job spends its whole retry
93
+ * policy re-proving it. Listed although `terminal` is the default, for the reason core's own table
94
+ * gives for `X_NOT_IMPLEMENTED`. The rest of this package's codes are unclassified, as they were.
95
+ */
96
+ export const ENTITY_ERROR_RETRY = {
97
+ X_ENTITY_SEALED_PREDICATE: 'terminal',
98
+ X_ENTITY_SEALED_IN_VIEW: 'terminal',
99
+ } as const satisfies Readonly<Partial<Record<EntityOwnedErrorCode, ErrorRetry>>>;
100
+
101
+ registerErrorRetry(ENTITY_ERROR_RETRY);
102
+
83
103
  /**
84
104
  * Base for every error this package throws. No `docs:` — `UltimateError` fills it from
85
105
  * `describeErrorCode(code).docs`, which is `@ultimat3/core`'s `ERROR_DOCS_URL`: one page for every
package/src/entity.ts CHANGED
@@ -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
 
@@ -341,6 +356,19 @@ export const entity = <const C extends ColumnMap>(
341
356
  declared.findIndex((other) => identity(other) === identity(index)) === position,
342
357
  );
343
358
 
359
+ const sealed = sealedFields({ $name: name, $table: table, $columns: init.columns });
360
+ assertSealedDeclaration({
361
+ name,
362
+ fields: sealed,
363
+ primaryKey,
364
+ tenantColumn,
365
+ indexes,
366
+ invariants,
367
+ });
368
+ const sealedKeys = new Set(sealed.map((field) => field.property));
369
+ /** The columns that leave the server: every one that is not sealed. */
370
+ const wireEntries = entries.filter(([property]) => !sealedKeys.has(property));
371
+
344
372
  const tags = [cacheTag, ...(init.tags ?? [])];
345
373
  const describe = (): EntityDescription =>
346
374
  describeEntity({
@@ -377,7 +405,11 @@ export const entity = <const C extends ColumnMap>(
377
405
  const given = raw === undefined ? defaultValue(column.$meta) : raw;
378
406
  if (given === undefined || given === null) {
379
407
  if (column.$meta.notNull) {
380
- throw invariantViolated(name, property, 'is required and has no default');
408
+ // A required sealed column ABSENT from a row is nearly always a spread of a repository
409
+ // row, which does not enumerate it — so the refusal names that, and the rewrite.
410
+ throw given === undefined && sealedKeys.has(property)
411
+ ? sealedMissing(name, property)
412
+ : invariantViolated(name, property, 'is required and has no default');
381
413
  }
382
414
  row[property] = null;
383
415
  continue;
@@ -388,6 +420,39 @@ export const entity = <const C extends ColumnMap>(
388
420
  return row as Row;
389
421
  };
390
422
 
423
+ /**
424
+ * The row as `$schema` validates it: the sealed columns are neither required nor returned, so a
425
+ * handler's row — which holds their plaintext — parses to a value that does not.
426
+ */
427
+ const parseWire = (value: unknown): WireRow<Row, C> => {
428
+ if (sealedKeys.size === 0) return parse(value) as WireRow<Row, C>;
429
+ if (typeof value !== 'object' || value === null) {
430
+ throw invariantViolated(name, 'row', `expected an object, got ${describeValue(value)}`);
431
+ }
432
+ const input = value as Readonly<Record<string, unknown>>;
433
+ const row: Record<string, unknown> = {};
434
+ for (const [property, column] of wireEntries) {
435
+ const raw = input[property];
436
+ const given = raw === undefined ? defaultValue(column.$meta) : raw;
437
+ if (given === undefined || given === null) {
438
+ if (column.$meta.notNull) {
439
+ throw invariantViolated(name, property, 'is required and has no default');
440
+ }
441
+ row[property] = null;
442
+ continue;
443
+ }
444
+ row[property] = column.$parse(given);
445
+ }
446
+ // Every wire property was validated by its own column, and no sealed one was copied.
447
+ return row as WireRow<Row, C>;
448
+ };
449
+
450
+ const schema = rowSchema<WireRow<Row, C>>(
451
+ { name, table, primaryKey, persist: init.persist === true, sealed: [...sealedKeys] },
452
+ wireEntries,
453
+ parseWire,
454
+ );
455
+
391
456
  const core: EntityCore<Row, C> = {
392
457
  $name: name,
393
458
  $table: table,
@@ -400,19 +465,19 @@ export const entity = <const C extends ColumnMap>(
400
465
  $softDelete: softDelete,
401
466
  $tenantColumn: tenantColumn,
402
467
  $search: search,
403
- $schema: rowSchema<Row>(
404
- { name, table, primaryKey, persist: init.persist === true },
405
- entries,
406
- parse,
407
- ),
468
+ $schema: schema,
408
469
  get $row(): Row {
409
470
  // Type-only. Reading it means someone expected a value where a type was meant.
410
471
  throw invariantViolated(name, '$row', '$row is a type, not a value — use typeof x.$row');
411
472
  },
412
473
  $tagFor: (id) => `${cacheTag}:${id}`,
413
474
  $parse: parse,
414
- $view: <K extends keyof Row & string>(keys: readonly K[]) =>
415
- viewFor<Row, K>(name, init.columns, keys),
475
+ $view: <K extends keyof Row & string>(keys: readonly K[]) => {
476
+ // Before the view exists: a view is an `output`, and a sealed column is in none.
477
+ const named = keys.find((key) => sealedKeys.has(key));
478
+ if (named !== undefined) throw sealedInView(name, named);
479
+ return viewFor<Row, K>(name, init.columns, keys);
480
+ },
416
481
  $assert: (row) => assertInvariants(name, invariants, row),
417
482
  $describe: describe,
418
483
  $references: references,
@@ -429,5 +494,6 @@ export const entity = <const C extends ColumnMap>(
429
494
  });
430
495
  // The columns land on the entity itself so `orgs.id` is a column reference; every framework
431
496
  // member is `$`-prefixed, which is why a column may be called `name`.
432
- return Object.assign(core, init.columns);
497
+ // `$schema` is restated so the result carries its real output type; it is the same object.
498
+ return Object.assign(core, init.columns, { $schema: schema });
433
499
  };
package/src/index.ts CHANGED
@@ -132,6 +132,11 @@ export type { RowBulkChange, RowChange, RowChangeOp, RowObserver } from './row-o
132
132
  export { observedRepo, rowObserver, setRowObserver } from './row-observer';
133
133
  export type { RecordsByKey, RecordsByType } from './rows-of';
134
134
  export { hasEntityRows, projectionsIn, rowsOf } from './rows-of';
135
+ // Sealed columns. `sealedFields` is what a manifest or `x doctor` reads to say which columns are
136
+ // sealed; `sealedRepo` is the seam a driver written outside this package returns through.
137
+ export type { SealedField } from './sealed';
138
+ export { sealedFields } from './sealed';
139
+ export { sealedRepo } from './sealed-repo';
135
140
  // Full-text search. The LANGUAGE set and the weights are values an app reads to build a form;
136
141
  // `SEARCH_PROPERTY` is what a `matches` predicate names, which a hand-built `QueryPlan` needs.
137
142
  export type { SearchInit, SearchLanguage, SearchSource, SearchVector } from './search';
@@ -194,10 +199,15 @@ export type {
194
199
  RowOf,
195
200
  RowPatch,
196
201
  RowWrite,
202
+ SealedColumn,
203
+ SealedMeta,
204
+ SealedOptions,
197
205
  SearchWeight,
206
+ TextColumn,
198
207
  TimestampColumn,
199
208
  TypeOf,
200
209
  UuidColumn,
210
+ WireRow,
201
211
  } from './types';
202
212
  export { COLUMN_KINDS } from './types';
203
213
  // `viewFor` stays internal: a view is reached through the entity, as `posts.$view([...])`.
package/src/instant.ts CHANGED
@@ -57,9 +57,88 @@ export const pgInstantMicros = (text: unknown): bigint | undefined => {
57
57
  };
58
58
 
59
59
  /**
60
- * The microsecond epoch of whatever a sort key is holding: a decoded row's `Date` (milliseconds,
61
- * so the last three digits are zero), a value already counted in microseconds, or the decimal a
62
- * cursor carries. `undefined` for anything else, so a caller decides rather than guessing at `0`.
60
+ * An instant as TEXT that names its own zone — what `toISOString()` writes and what a URL, a JSON
61
+ * body or a hand-kept keyset position holds: `2026-01-01T00:00:03.000Z`, `…+02:00`, `… 00:00:03+00`.
62
+ *
63
+ * The zone is REQUIRED. Postgres reads a zoneless text in the session's `TimeZone`, which this
64
+ * process cannot know, so such a text is not an instant here — the same rule `timestamp()` holds a
65
+ * written value to.
66
+ */
67
+ const ISO_INSTANT_TEXT =
68
+ /^(\d{4,})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?\s*(Z|[+-]\d{2}(?::?\d{2})?)$/i;
69
+
70
+ const MICROS_PER_MINUTE = 60_000_000n;
71
+
72
+ const MICROS_PER_DAY = 86_400_000_000;
73
+
74
+ /** Postgres's `MAX_TZDISP_HOUR`: a numeric offset of sixteen hours or more is an error there. */
75
+ const MAX_OFFSET_HOURS = 15;
76
+
77
+ /** `Z`, `+02`, `+02:00`, `-0430` → minutes east of UTC; `undefined` past what Postgres accepts. */
78
+ const offsetMinutes = (zone: string): bigint | undefined => {
79
+ if (zone.toUpperCase() === 'Z') return 0n;
80
+ const digits = zone.slice(1).replace(':', '');
81
+ const hours = Number(digits.slice(0, 2));
82
+ const minutes = Number(digits.slice(2) || '0');
83
+ if (hours > MAX_OFFSET_HOURS || minutes >= 60) return undefined;
84
+ const east = BigInt(hours * 60 + minutes);
85
+ return zone.startsWith('-') ? -east : east;
86
+ };
87
+
88
+ /**
89
+ * The fraction as Postgres reads it: `strtod` of the digits, times a million, `rint` — so past six
90
+ * digits it ROUNDS, half to even, on the double (`.0000005` is a hair under the half and goes
91
+ * down), and `.9999996` is a whole second. JavaScript's `Number` is the same IEEE double.
92
+ */
93
+ const fractionMicros = (digits: string | undefined): number => {
94
+ if (digits === undefined) return 0;
95
+ if (digits.length <= FRACTION_DIGITS) return Number(digits.padEnd(FRACTION_DIGITS, '0'));
96
+ const scaled = Number(`0.${digits}`) * 1_000_000;
97
+ const floor = Math.floor(scaled);
98
+ const above = scaled - floor;
99
+ if (above !== 0.5) return above > 0.5 ? floor + 1 : floor;
100
+ return floor % 2 === 0 ? floor : floor + 1;
101
+ };
102
+
103
+ /**
104
+ * The exact microsecond epoch an ISO instant text names, or `undefined` when Postgres would refuse
105
+ * it — no zone, a date the calendar does not have (`2026-13-45` is an error there, never the 14th
106
+ * of February), an offset of sixteen hours, or a time past the end of its day.
107
+ *
108
+ * Postgres's `time_overflows`: hour ≤ 24, minute ≤ 59, second ≤ 60, and the whole time of day at
109
+ * most 24:00:00 — so `24:00:00` is the next midnight and `:60` the next minute, while
110
+ * `24:00:00.000001` and `23:59:60.5` are errors.
111
+ */
112
+ const isoInstantMicros = (text: string): bigint | undefined => {
113
+ const match = ISO_INSTANT_TEXT.exec(text);
114
+ if (match === null) return undefined;
115
+ const [, year = '', month = '', day = '', hour = '', minute = '', second = '', fraction] = match;
116
+ const [y = 0, m = 0, d = 0] = [year, month, day].map(Number);
117
+ const [h = 0, min = 0, sec = 0] = [hour, minute, second].map(Number);
118
+ const midnight = utcSecondMillis([y, m, d, 0, 0, 0]);
119
+ const built = new Date(midnight);
120
+ // A field the calendar rolled over was never that date: 13 months, 45 days.
121
+ if (built.getUTCMonth() + 1 !== m || built.getUTCDate() !== d) return undefined;
122
+ const fsec = fractionMicros(fraction);
123
+ const ofDay = ((h * 60 + min) * 60 + sec) * 1_000_000 + fsec;
124
+ if (h > 24 || min > 59 || sec > 60 || ofDay > MICROS_PER_DAY) return undefined;
125
+ const offset = offsetMinutes(match[8] ?? 'Z');
126
+ if (offset === undefined) return undefined;
127
+ const local = BigInt(midnight) * MICROS_PER_MILLI + BigInt(ofDay);
128
+ // The text is wall-clock time AT its offset, so the instant is that many minutes earlier.
129
+ return local - offset * MICROS_PER_MINUTE;
130
+ };
131
+
132
+ /**
133
+ * The microsecond epoch of whatever a sort key or an operand is holding: a decoded row's `Date`
134
+ * (milliseconds, so the last three digits are zero), a value already counted in microseconds, the
135
+ * decimal a cursor carries, or an ISO instant as text. `undefined` for anything else, so a caller
136
+ * decides rather than guessing at `0`.
137
+ *
138
+ * The ISO text is the half Postgres always had: a `"created_at" < $1` bound to
139
+ * `2026-01-01T00:00:03.000Z` is parsed there as an instant, while the in-memory driver compared it
140
+ * to the stored `Date` by CHARACTERS — so a hand-kept keyset answered an empty page two in memory
141
+ * and the right one in production.
63
142
  */
64
143
  export const instantMicros = (value: unknown): bigint | undefined => {
65
144
  if (typeof value === 'bigint') return value;
@@ -67,8 +146,8 @@ export const instantMicros = (value: unknown): bigint | undefined => {
67
146
  const millis = value.getTime();
68
147
  return Number.isNaN(millis) ? undefined : BigInt(millis) * MICROS_PER_MILLI;
69
148
  }
70
- if (typeof value === 'string' && /^-?\d+$/.test(value)) return BigInt(value);
71
- return undefined;
149
+ if (typeof value !== 'string') return undefined;
150
+ return /^-?\d+$/.test(value) ? BigInt(value) : isoInstantMicros(value);
72
151
  };
73
152
 
74
153
  /**
@@ -91,6 +91,13 @@ export const sameValueOfKind = (
91
91
  right: unknown,
92
92
  ): boolean => {
93
93
  if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime();
94
+ // An instant handed over as ISO text is the instant it names, as Postgres parses the bound
95
+ // parameter: `eq` against a stored `Date` compared an object to a string and matched no row.
96
+ if (kind === 'timestamptz') {
97
+ const stored = instantMicros(left);
98
+ const given = instantMicros(right);
99
+ if (stored !== undefined && given !== undefined) return stored === given;
100
+ }
94
101
  if (kind === 'uuid' && typeof left === 'string' && typeof right === 'string') {
95
102
  return keyOf('uuid', left) === keyOf('uuid', right);
96
103
  }