@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.
@@ -15,12 +15,14 @@ import { narrowRow } from './columns';
15
15
  import { countsFrom, groupColumnOf } from './count-by';
16
16
  import { cursorFor, kindOf, seekFrom, valueAt } from './cursor';
17
17
  import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
18
- import { notFound } from './errors';
18
+ import { invariantViolated, notFound } from './errors';
19
19
  import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
20
20
  import { compareByKind, matchesPredicate } from './memory-match';
21
21
  import { uniqueClash, uniqueViolation } from './memory-unique';
22
22
  import { deletePlan, idPlan, readPlan, singleKeyOf, updatePlan } from './plan';
23
23
  import type { FindManyArgs, MemoryRepo, RepoOptions, Transactor, Tx } from './repo';
24
+ import { sealedFields } from './sealed';
25
+ import { sealedRepo } from './sealed-repo';
24
26
  import type { QueryPlan } from './tenancy';
25
27
  import { assertRowTenant } from './tenancy';
26
28
  import type { RowWrite } from './types';
@@ -95,6 +97,15 @@ export const memoryRepo = <Row>(
95
97
  /** The same key, from the id a caller named rather than from a row it has in hand. */
96
98
  const idStoreKey = (id: unknown, operation: string): string =>
97
99
  keyOf(kindOf(entity, singleKeyOf(entity, operation)) ?? '', id);
100
+ // Seed rows are stored as given, and sealing is asynchronous: a seeded sealed entity would hold
101
+ // plaintext every read then refuses to open. Refused here, with the call that seals.
102
+ if (seed.length > 0 && sealedFields(entity).length > 0) {
103
+ throw invariantViolated(
104
+ entity.$name,
105
+ 'seed',
106
+ 'declares a sealed column, and memoryRepo(entity, seed) stores seed rows unsealed — pass no seed and write them with insertAll(rows)',
107
+ );
108
+ }
98
109
  const rows = new Map<string, Row>(seed.map((row) => [storeKey(row), row]));
99
110
 
100
111
  const rowsOf = (plan: QueryPlan, args: FindManyArgs): Row[] => {
@@ -193,7 +204,7 @@ export const memoryRepo = <Row>(
193
204
 
194
205
  // Every method is async: a repository call that fails must reject, never throw
195
206
  // synchronously, or half the call sites would need two error paths.
196
- return {
207
+ const repo: MemoryRepo<Row> = {
197
208
  async findById(id, options) {
198
209
  const { found } = select(
199
210
  { ...options, where: [{ column: singleKeyOf(entity, 'findById'), op: 'eq', value: id }] },
@@ -414,6 +425,9 @@ export const memoryRepo = <Row>(
414
425
  for (const row of seed) rows.set(storeKey(row), row);
415
426
  },
416
427
  };
428
+ // The memory driver seals too: it stores what Postgres would, so a test that passes here is not
429
+ // passing over plaintext production never holds.
430
+ return sealedRepo<Row, MemoryRepo<Row>>(entity, repo);
417
431
  };
418
432
 
419
433
  let txCounter = 0;
package/src/pg-driver.ts CHANGED
@@ -53,6 +53,7 @@ import {
53
53
  } from './pg-write-sql';
54
54
  import { deletePlan, idPlan, readPlan, updatePlan } from './plan';
55
55
  import type { FindManyArgs, Repo, UpsertArgs } from './repo';
56
+ import { sealedRepo } from './sealed-repo';
56
57
  import type { QueryPlan } from './tenancy';
57
58
  import { assertRowTenant } from './tenancy';
58
59
  import type { RowWrite } from './types';
@@ -205,7 +206,7 @@ export const postgresRepo = <Row>(
205
206
  return written;
206
207
  };
207
208
 
208
- return {
209
+ const repo: Repo<Row> = {
209
210
  async findById(id, options) {
210
211
  const op = 'findById';
211
212
  const plan = idPlan(entity, id, options, op);
@@ -471,6 +472,8 @@ export const postgresRepo = <Row>(
471
472
  return Number.isFinite(estimate) && estimate >= 0 ? estimate : null;
472
473
  },
473
474
  };
475
+ // The one sealing seam, shared with `memoryRepo()`: sealed on the way in, opened on the way out.
476
+ return sealedRepo(entity, repo);
474
477
  };
475
478
 
476
479
  /**
package/src/pg-row.ts CHANGED
@@ -185,7 +185,10 @@ export const decodeRow = <Row>(entity: EntityCore<Row>, source: PhysicalRow): Ro
185
185
  ? moneyOf(source, head, currency, scale)
186
186
  : source[head];
187
187
  if (value !== null && value !== undefined) {
188
- row[property] = column.$parse(value);
188
+ // A sealed column's stored value is not the author's to parse: it goes up as it is, and the
189
+ // sealing seam opens it — so a row somebody left in the clear is `X_SEAL_INVALID` there
190
+ // rather than whatever the plaintext parser thinks of its length here.
191
+ row[property] = column.$meta.sealed === undefined ? column.$parse(value) : value;
189
192
  continue;
190
193
  }
191
194
  if (column.$meta.notNull) {
package/src/preload.ts CHANGED
@@ -13,6 +13,7 @@ import type { EntityCore } from './entity';
13
13
  import { EntityError } from './errors';
14
14
  import type { Relation } from './relations';
15
15
  import type { Repo } from './repo';
16
+ import { copyRow } from './sealed';
16
17
  import type { Predicate } from './tenancy';
17
18
 
18
19
  /** The other side of a relation: the entity, and where its rows live. */
@@ -170,7 +171,8 @@ export const preloaded = async <Source, Row>(
170
171
  );
171
172
  // A copy per row: the in-memory driver hands back the row it stores, and attaching to that one
172
173
  // would write a relation into the table itself.
173
- const attached = rows.map((row) => ({ ...row }) as Record<string, unknown>);
174
+ // `copyRow`, never a spread: a spread drops every server-only (sealed) property of the row.
175
+ const attached = rows.map((row) => copyRow(row as object) as Record<string, unknown>);
174
176
  for (const { relation, kind, keys, index } of resolved) {
175
177
  for (const [position, row] of attached.entries()) {
176
178
  const key = keys[position];
package/src/query.ts CHANGED
@@ -15,10 +15,21 @@ import { preloaded } from './preload';
15
15
  import type { Relation } from './relations';
16
16
  import { relationNamed } from './relations';
17
17
  import type { Page, Repo, RepoOptions, UpsertArgs } from './repo';
18
+ import { copyRow, pickRow } from './sealed';
18
19
  import { SEARCH_PROPERTY } from './search';
19
20
  import type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
20
21
  import { transitionRow } from './transition';
21
- import type { ColumnMap, IdOf, Insertable, MoneyValue, RowPatch } from './types';
22
+ import type {
23
+ ColumnMap,
24
+ Filterable,
25
+ IdOf,
26
+ Insertable,
27
+ MoneyValue,
28
+ NoSealedKeys,
29
+ RowPatch,
30
+ SealedKeySet,
31
+ SealedKeysOf,
32
+ } from './types';
22
33
 
23
34
  /**
24
35
  * What a preloaded relation adds to a row. `unknown` because the name is a string resolved at
@@ -27,10 +38,24 @@ import type { ColumnMap, IdOf, Insertable, MoneyValue, RowPatch } from './types'
27
38
  */
28
39
  export type Preloaded<Name extends string> = { readonly [K in Name]: unknown };
29
40
 
30
- export interface ReadBuilder<Row> {
41
+ /**
42
+ * `S` names the entity's sealed columns, so naming one in a filter or an order is a COMPILE error
43
+ * and not only `X_ENTITY_SEALED_PREDICATE` at runtime. It defaults to none.
44
+ */
45
+ export interface ReadBuilder<Row, S extends SealedKeySet = NoSealedKeys> {
31
46
  /** Equality on the columns given. `where({ orgId })` is what satisfies the tenancy guard. */
32
- where(filter: RowPatch<Row>): ReadBuilder<Row>;
33
- andWhere(column: keyof Row & string, op: Operator, value: unknown): ReadBuilder<Row>;
47
+ where(filter: RowPatch<Filterable<Row, S>>): ReadBuilder<Row, S>;
48
+ // NULL is never sealed, so its tests take ANY column. First: `Parameters<…>` reads the last.
49
+ andWhere(
50
+ column: keyof Row & string,
51
+ op: 'is-null' | 'is-not-null',
52
+ value?: unknown,
53
+ ): ReadBuilder<Row, S>;
54
+ andWhere(
55
+ column: Exclude<keyof Row & string, S['opaque']>,
56
+ op: Operator,
57
+ value: unknown,
58
+ ): ReadBuilder<Row, S>;
34
59
  /**
35
60
  * Full-text search over the entity's generated `tsvector` — every `.searchable()` column at
36
61
  * once, one GIN index, one predicate. `posts.where({ orgId }).search(input.q).limit(20).page()`.
@@ -47,14 +72,17 @@ export interface ReadBuilder<Row> {
47
72
  * stays the one the caller declared. An entity with no searchable column is `X_SEARCH_UNDECLARED`
48
73
  * and the in-memory driver is `X_SEARCH_IN_MEMORY` — never a different answer from the two.
49
74
  */
50
- search(term: string): ReadBuilder<Row>;
51
- orderBy(column: keyof Row & string, direction?: SortDirection): ReadBuilder<Row>;
52
- limit(rows: number): ReadBuilder<Row>;
75
+ search(term: string): ReadBuilder<Row, S>;
76
+ orderBy(
77
+ column: Exclude<keyof Row & string, S['sealed']>,
78
+ direction?: SortDirection,
79
+ ): ReadBuilder<Row, S>;
80
+ limit(rows: number): ReadBuilder<Row, S>;
53
81
  /** The cursor from the previous page. */
54
- after(cursor: string | null): ReadBuilder<Row>;
82
+ after(cursor: string | null): ReadBuilder<Row, S>;
55
83
  select<K extends keyof Row & string>(
56
84
  fields: { readonly [P in K]: true },
57
- ): ReadBuilder<Pick<Row, K>>;
85
+ ): ReadBuilder<Pick<Row, K>, S>;
58
86
  /**
59
87
  * One relation, read for the whole page in one extra `where <key> in (…)` and attached to every
60
88
  * row under its own name — the eager form of the batching a point lookup does for itself, and
@@ -66,7 +94,7 @@ export interface ReadBuilder<Row> {
66
94
  * author" never reads like "nobody preloaded the author". Attached after the projection: a
67
95
  * `select()` narrows the columns, never the relations.
68
96
  */
69
- preload<Name extends string>(relation: Name): ReadBuilder<Row & Preloaded<Name>>;
97
+ preload<Name extends string>(relation: Name): ReadBuilder<Row & Preloaded<Name>, S>;
70
98
  /**
71
99
  * Every row the chain matches, `size` at a time and one statement per batch — the terminal a
72
100
  * `for await` consumes instead of holding a whole table in memory. A batch is the page `page()`
@@ -106,7 +134,9 @@ export interface ReadBuilder<Row> {
106
134
  * Refused rather than answered: a column whose values a map cannot be keyed by (a timestamp, a
107
135
  * jsonb, money), and a chain matching more distinct values than one statement should answer with.
108
136
  */
109
- countBy<K extends keyof Row & string>(column: K): Promise<ReadonlyMap<Row[K], number>>;
137
+ countBy<K extends Exclude<keyof Row & string, S['sealed']>>(
138
+ column: K,
139
+ ): Promise<ReadonlyMap<Row[K], number>>;
110
140
  /**
111
141
  * The four SQL aggregates, over exactly the rows `count()` counts — the chain's filters, its
112
142
  * tenancy and its soft-delete visibility, never its page. "Total spend this month" is
@@ -147,7 +177,8 @@ export interface ReadBuilder<Row> {
147
177
  plan(): QueryPlan;
148
178
  }
149
179
 
150
- export interface Table<Row, C extends ColumnMap = ColumnMap> extends ReadBuilder<Row> {
180
+ export interface Table<Row, C extends ColumnMap = ColumnMap>
181
+ extends ReadBuilder<Row, SealedKeysOf<C>> {
151
182
  /**
152
183
  * Move one row through the state machine `column` declares, in ONE statement.
153
184
  *
@@ -285,7 +316,8 @@ const builder = <Source, Row>(
285
316
  ],
286
317
  }),
287
318
 
288
- andWhere: (column, op, value) => next({ where: [...state.where, { column, op, value }] }),
319
+ andWhere: (column: string, op: Operator, value?: unknown) =>
320
+ next({ where: [...state.where, { column, op, value }] }),
289
321
 
290
322
  // Refused HERE as well as at the statement, because this is the line the author wrote: a chain
291
323
  // over an entity that declares nothing searchable can never produce a match, and the repair is
@@ -318,12 +350,8 @@ const builder = <Source, Row>(
318
350
  entity,
319
351
  repo,
320
352
  { ...state, select: keys },
321
- (row) => {
322
- const source = pick(row);
323
- const picked = {} as Pick<Row, K>;
324
- for (const key of keys) picked[key] = source[key];
325
- return picked;
326
- },
353
+ // By DESCRIPTOR, never by assignment: a sealed column named here stays server-only.
354
+ (row) => pickRow(pick(row) as Row & object, keys),
327
355
  related,
328
356
  );
329
357
  },
@@ -425,7 +453,8 @@ const touch = <Row, Patch>(entity: EntityCore<Row>, patch: Patch): Patch => {
425
453
  for (const [property, column] of Object.entries(entity.$columns)) {
426
454
  if (column.$meta.onUpdate !== undefined) stamped[property] = entityNow();
427
455
  }
428
- return Object.assign({}, patch, stamped);
456
+ // `copyRow`, never `Object.assign({}, patch)`: a row passed back whole keeps its sealed properties.
457
+ return Object.assign(copyRow(patch as object), stamped) as Patch;
429
458
  };
430
459
 
431
460
  // Every write is `async`, matching the repository contract: a failing call rejects and never
@@ -437,7 +466,11 @@ export const tableFor = <Row, C extends ColumnMap>(
437
466
  /** How this table reaches another — `database()` passes it; a table built by hand has none. */
438
467
  related?: RelatedTables,
439
468
  ): Table<Row, C> => ({
440
- ...builder<Row, Row>(entity, repo, EMPTY, (row) => row, related),
469
+ // One cast: `sealed-repo.ts` refuses at runtime, `SealedKeysOf<C>` is the same rule as a type.
470
+ ...(builder<Row, Row>(entity, repo, EMPTY, (row) => row, related) as ReadBuilder<
471
+ Row,
472
+ SealedKeysOf<C>
473
+ >),
441
474
  insert: async (values, options) => repo.insert(entity.$parse(values), options),
442
475
  insertAll: async (rows, options) =>
443
476
  repo.insertAll(
@@ -31,6 +31,12 @@ export interface RecordProjection {
31
31
  * default `false`. Realtime's persister reads it here, never off the declaration.
32
32
  */
33
33
  readonly persist: boolean;
34
+ /**
35
+ * The row's SEALED properties — the ones `schema` omits. `rowsOf` drops them from every row it
36
+ * collects, so a record never carries one whatever object the handler returned. Empty for almost
37
+ * every entity.
38
+ */
39
+ readonly sealed: readonly string[];
34
40
  }
35
41
 
36
42
  /** What `entity()` hands the projection: everything but the row node, which the schema builds. */
@@ -39,14 +45,23 @@ export interface ProjectionIdentity {
39
45
  readonly table: string;
40
46
  readonly primaryKey: readonly string[];
41
47
  readonly persist: boolean;
48
+ /** Absent means none: a projection built before sealing existed reads the same. */
49
+ readonly sealed?: readonly string[];
42
50
  }
43
51
 
44
52
  /** Built once per `entity()`, frozen: the brand, `recordProjection` and `rowsOf` share one object. */
45
53
  export const createProjection = (
46
- { name, table, primaryKey, persist }: ProjectionIdentity,
54
+ { name, table, primaryKey, persist, sealed }: ProjectionIdentity,
47
55
  schema: SchemaNode,
48
56
  ): RecordProjection =>
49
- Object.freeze({ type: name, table, key: recordKeyOf(name, primaryKey), schema, persist });
57
+ Object.freeze({
58
+ type: name,
59
+ table,
60
+ key: recordKeyOf(name, primaryKey),
61
+ schema,
62
+ persist,
63
+ sealed: Object.freeze([...(sealed ?? [])]),
64
+ });
50
65
 
51
66
  /**
52
67
  * The projection a schema node is branded with, or `undefined`. Own and non-enumerable: an
@@ -18,6 +18,7 @@ import { currentTx, expectedQueryLoop } from '@ultimat3/db';
18
18
  import type { EntityCore } from './entity';
19
19
  import { MAX_PAGE_SIZE } from './plan';
20
20
  import type { Repo, RepoOptions, UpsertArgs } from './repo';
21
+ import { sealedFields } from './sealed';
21
22
  import type { IdOf, RowPatch } from './types';
22
23
 
23
24
  export type RowChangeOp = 'insert' | 'update' | 'delete';
@@ -183,6 +184,20 @@ const beforeAllOf = async <Row>(
183
184
  */
184
185
  export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Repo<Row> {
185
186
  const name = entity.$name;
187
+ const sealed = sealedFields(entity).map((field) => field.property);
188
+
189
+ /**
190
+ * A reported row WITHOUT its sealed properties. This wrapper sits above the sealing seam, so the
191
+ * rows it holds carry plaintext — and a change is what a live query delivers to a subscriber. A
192
+ * sealed column leaves the server through no path, so it is not on a change either.
193
+ */
194
+ const reported = (row: unknown): Readonly<Record<string, unknown>> | null => {
195
+ const record = asRecord(row);
196
+ if (record === null || sealed.length === 0) return record;
197
+ return Object.fromEntries(
198
+ Object.entries(record).filter(([property]) => !sealed.includes(property)),
199
+ );
200
+ };
186
201
 
187
202
  /**
188
203
  * Reported at COMMIT. The write may sit in a transaction — the caller's `options.tx`, or the
@@ -209,8 +224,8 @@ export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Rep
209
224
  const change: RowChange = {
210
225
  entity: name,
211
226
  op,
212
- before: asRecord(before),
213
- after: asRecord(after),
227
+ before: reported(before),
228
+ after: reported(after),
214
229
  ...(write === undefined ? {} : { write }),
215
230
  };
216
231
  afterCommit(options, () => observer.onChange(change));
package/src/rows-of.ts CHANGED
@@ -74,12 +74,24 @@ const fits = (projection: RecordProjection, value: Readonly<Record<string, unkno
74
74
 
75
75
  type Found = Map<string, Map<string, Row>>;
76
76
 
77
+ /**
78
+ * The row as a RECORD: without its sealed properties. The handler's own object is what reaches
79
+ * here — it holds the plaintext — and a record goes to a browser store, so the copy is made at the
80
+ * one place every record is collected. An entity with none keeps the same object, as before.
81
+ */
82
+ const withoutSealed = (projection: RecordProjection, row: Row): Row => {
83
+ if (projection.sealed.length === 0) return row;
84
+ return Object.fromEntries(
85
+ Object.entries(row).filter(([property]) => !projection.sealed.includes(property)),
86
+ );
87
+ };
88
+
77
89
  const collect = (found: Found, projection: RecordProjection, row: Row): void => {
78
90
  const byKey = found.get(projection.type) ?? new Map<string, Row>();
79
91
  found.set(projection.type, byKey);
80
92
  const key = projection.key(row);
81
93
  // First sighting wins: one record shown twice in a response is one record, not two writes.
82
- if (!byKey.has(key)) byKey.set(key, row);
94
+ if (!byKey.has(key)) byKey.set(key, withoutSealed(projection, row));
83
95
  };
84
96
 
85
97
  const walk = (node: SchemaNode, value: unknown, found: Found, inUnion: boolean): void => {
@@ -0,0 +1,119 @@
1
+ // Single responsibility: `text()`'s two builders — the text column, which keeps `.sealed()` across
2
+ // the links that may precede it, and the sealed column that link returns. What a sealed column may
3
+ // NOT be is refused here, where the chain was written; what an ENTITY may not do with one is
4
+ // `sealed-declaration.ts`.
5
+
6
+ import { isSealed } from '@ultimat3/core';
7
+ import { makeColumn } from './column';
8
+ import { refuseSealed, sealedUniqueOpaque } from './sealed-errors';
9
+ import type { Column, ColumnMeta, SealedColumn, SealedOptions, TextColumn } from './types';
10
+
11
+ type Text = string | null;
12
+
13
+ /**
14
+ * A sealed column's parser. Inside a driver the value is already the sealed string — longer than
15
+ * any `max`, and not the author's to validate — so it passes; everywhere else it is the plaintext
16
+ * and the column's own parser judges it, `max` included.
17
+ */
18
+ const sealedParse =
19
+ <T extends Text>(parse: (value: unknown) => T) =>
20
+ (value: unknown): T =>
21
+ isSealed(value) ? (value as T) : parse(value);
22
+
23
+ export const makeSealedColumn = <T extends Text, Optional extends boolean, Lookup extends boolean>(
24
+ meta: ColumnMeta,
25
+ parse: (value: unknown) => T,
26
+ optional: Optional,
27
+ ): SealedColumn<T, Optional, Lookup> => {
28
+ const base = makeColumn<T, Optional>(meta, parse, optional);
29
+ const lookup = meta.sealed?.lookup === true;
30
+ const again = <U extends Text>(next: Column<U, Optional>): SealedColumn<U, Optional, Lookup> =>
31
+ makeSealedColumn<U, Optional, Lookup>(next.$meta, next.$parse, optional);
32
+ return {
33
+ ...base,
34
+ // The runtime twin of the phantom: one cast, where a boolean becomes the literal it implies.
35
+ $sealed: (lookup ? 'lookup' : 'opaque') as SealedColumn<T, Optional, Lookup>['$sealed'],
36
+ nullable: () => again(base.nullable()),
37
+ column: (name) => again(base.column(name)),
38
+ unique: () => {
39
+ // Uniqueness is the database comparing ciphertexts, so it needs equal values to seal equal.
40
+ if (!lookup) throw sealedUniqueOpaque();
41
+ return again(base.unique());
42
+ },
43
+ default: () =>
44
+ refuseSealed(
45
+ 'given a default',
46
+ 'a default is a literal in the DDL, so the database would store it as plaintext',
47
+ 'text().sealed() with no .default(…) — supply the value in the insert, where it is sealed',
48
+ ),
49
+ primaryKey: () =>
50
+ refuseSealed(
51
+ 'a primary key',
52
+ 'a key is compared, ordered and referenced by the database, which holds ciphertext',
53
+ 'uuid().primaryKey() for the key, and text().sealed() for the secret beside it',
54
+ ),
55
+ tenant: () =>
56
+ refuseSealed(
57
+ 'the tenant column',
58
+ 'every query filters on the tenant, and a sealed value cannot be filtered on',
59
+ 'uuid().tenant() for the tenant, and text().sealed() for the secret beside it',
60
+ ),
61
+ references: () =>
62
+ refuseSealed(
63
+ 'a foreign key',
64
+ 'the database joins on the stored value, which is ciphertext',
65
+ 'uuid().references(() => other.id) for the key, and text().sealed() for the secret',
66
+ ),
67
+ searchable: () =>
68
+ refuseSealed(
69
+ 'searchable',
70
+ 'the search vector is generated by the database from the stored value, which is ciphertext',
71
+ 'text().searchable() on a column that is not sealed — a secret is not search text',
72
+ ),
73
+ };
74
+ };
75
+
76
+ /** What `.sealed(options)` turns the declared meta into. */
77
+ const sealedMeta = (meta: ColumnMeta, options: SealedOptions): ColumnMeta => {
78
+ if (meta.default !== undefined) {
79
+ refuseSealed(
80
+ 'given a default',
81
+ 'a default is a literal in the DDL, so the database would store it as plaintext',
82
+ 'text().sealed() with no .default(…) — supply the value in the insert, where it is sealed',
83
+ );
84
+ }
85
+ const lookup = options.lookup === true;
86
+ if (meta.unique && !lookup) throw sealedUniqueOpaque();
87
+ // `check` and `length` go: they are `char_length(col) <= max`, and the column now holds a sealed
88
+ // string longer than any plaintext bound. The bound still holds where it can — in `$parse`,
89
+ // against the plaintext, before it is sealed. Any OTHER check has no such home, so it is refused
90
+ // rather than dropped: the rule would vanish from the DDL with nothing to say it went.
91
+ if (meta.check !== undefined && meta.length === undefined) {
92
+ refuseSealed(
93
+ 'given a CHECK',
94
+ 'a CHECK runs in the database against the stored value, which is ciphertext',
95
+ 'text().sealed() with no CHECK — validate the plaintext in the action input schema',
96
+ );
97
+ }
98
+ const { check: _check, length: _length, ...rest } = meta;
99
+ return { ...rest, sealed: { lookup } };
100
+ };
101
+
102
+ export const makeTextColumn = <T extends Text, Optional extends boolean>(
103
+ meta: ColumnMeta,
104
+ parse: (value: unknown) => T,
105
+ optional: Optional,
106
+ ): TextColumn<T, Optional> => {
107
+ const base = makeColumn<T, Optional>(meta, parse, optional);
108
+ const again = <U extends Text, O extends boolean>(next: Column<U, O>): TextColumn<U, O> =>
109
+ makeTextColumn<U, O>(next.$meta, next.$parse, next.$optional);
110
+ return {
111
+ ...base,
112
+ nullable: () => again(base.nullable()),
113
+ unique: () => again(base.unique()),
114
+ default: (value) => again(base.default(value)),
115
+ column: (name) => again(base.column(name)),
116
+ sealed: (options = {}) =>
117
+ makeSealedColumn(sealedMeta(meta, options), sealedParse(parse), optional),
118
+ };
119
+ };
@@ -0,0 +1,64 @@
1
+ // Single responsibility: what an ENTITY may not do with a sealed column, refused while it is being
2
+ // declared. The column chain refuses what it can see (`sealed-column.ts`); these are the rules that
3
+ // need the whole entity — a composite key, the tenant, an index, an invariant — and each one is the
4
+ // database comparing a value it only holds as ciphertext.
5
+
6
+ import { invariantViolated } from './entity-error';
7
+ import type { SealedField } from './sealed';
8
+ import { sealedPredicate } from './sealed-errors';
9
+ import type { IndexDef } from './types';
10
+
11
+ export interface SealedDeclaration {
12
+ readonly name: string;
13
+ readonly fields: readonly SealedField[];
14
+ /** Property keys. */
15
+ readonly primaryKey: readonly string[];
16
+ readonly tenantColumn: string | null;
17
+ /** Physical columns, as `IndexDef` and `Invariant` carry them. */
18
+ readonly indexes: readonly IndexDef[];
19
+ readonly invariants: readonly { readonly kind: string; readonly columns: readonly string[] }[];
20
+ }
21
+
22
+ export const assertSealedDeclaration = (input: SealedDeclaration): void => {
23
+ const { name, fields } = input;
24
+ if (fields.length === 0) return;
25
+ for (const field of fields) {
26
+ if (input.primaryKey.includes(field.property)) {
27
+ throw invariantViolated(
28
+ name,
29
+ field.property,
30
+ 'is sealed and in the primary key — a key is compared and ordered by the database, which holds ciphertext; key the entity by a uuid() and keep the secret beside it',
31
+ );
32
+ }
33
+ if (input.tenantColumn === field.property) {
34
+ throw invariantViolated(
35
+ name,
36
+ field.property,
37
+ 'is sealed and the tenant column — every query filters on the tenant, and a sealed value cannot be filtered on',
38
+ );
39
+ }
40
+ }
41
+ const byColumn = new Map(fields.map((field) => [field.column, field]));
42
+ for (const index of input.indexes) {
43
+ for (const column of index.columns) {
44
+ const field = byColumn.get(column);
45
+ // An index over a lookup column is the point of one: it is what makes the equality fast,
46
+ // and a unique one is `.unique()` spelled on the entity.
47
+ if (field === undefined || (field.lookup && index.order === undefined)) continue;
48
+ throw sealedPredicate(name, field.property, 'an index', field.lookup);
49
+ }
50
+ }
51
+ for (const invariant of input.invariants) {
52
+ for (const column of invariant.columns) {
53
+ const field = byColumn.get(column);
54
+ if (field === undefined) continue;
55
+ if (invariant.kind === 'unique') {
56
+ if (!field.lookup) throw sealedPredicate(name, field.property, 'a uniqueness rule', false);
57
+ continue;
58
+ }
59
+ // A CHECK runs in the database against the stored string, and the app-side half runs inside
60
+ // the driver against the same one — neither ever sees the plaintext the rule was written for.
61
+ throw sealedPredicate(name, field.property, 'an invariant', field.lookup);
62
+ }
63
+ }
64
+ };
@@ -0,0 +1,112 @@
1
+ // The two refusals a sealed column raises: named where the database would have to read it, and
2
+ // named in a view. Split from `errors.ts` for `feature-errors.ts`'s reason — the codes and titles
3
+ // stay in `entity-error.ts`. Imports `entity-error.ts`, never `errors.ts`: `entity()` raises these
4
+ // while it is being declared, and that path is one a browser bundle evaluates.
5
+
6
+ import { EntityError } from './entity-error';
7
+
8
+ /**
9
+ * What the caller was doing with the column, as the cause words it. A closed list so every site
10
+ * says the same thing about the same use, and so the fix can tell an order from an equality.
11
+ */
12
+ export type SealedUse =
13
+ | 'a filter'
14
+ | 'an order'
15
+ | 'a grouped count'
16
+ | 'an aggregate'
17
+ | "a filtered write's filter"
18
+ | 'an upsert conflict target'
19
+ | 'an index'
20
+ | 'a uniqueness rule'
21
+ | 'an invariant';
22
+
23
+ /**
24
+ * A sealed column where the DATABASE would have to compare it. It holds ciphertext: an opaque
25
+ * column seals with a fresh IV, so two equal values never match and nothing can be found by it;
26
+ * a `lookup` column seals deterministically, so equality — and only equality — means something.
27
+ *
28
+ * Two fixes, because they are two different mistakes: asking an opaque column anything is repaired
29
+ * by declaring `lookup` (and the fix says what that costs), while asking a lookup column for an
30
+ * order or a range has no declaration that repairs it.
31
+ */
32
+ export const sealedPredicate = (
33
+ entityName: string,
34
+ column: string,
35
+ use: SealedUse,
36
+ lookup: boolean,
37
+ ): EntityError =>
38
+ // The opaque refusal FIRST: it is the one an author meets first, and the first literal in a
39
+ // factory is what `x errors explain` prints.
40
+ !lookup
41
+ ? new EntityError({
42
+ code: 'X_ENTITY_SEALED_PREDICATE',
43
+ cause: `${entityName}.${column} is sealed and was named in ${use} — the database holds ciphertext sealed with a fresh IV, so no two values match and nothing there can find, order or group by it`,
44
+ fix: `find the row by another column, or declare ${entityName}.${column} as text().sealed({ lookup: true }) to allow equality in where() and .unique() — equal plaintexts then store as visibly equal ciphertexts, so never for a low-entropy value, and rows already stored must be written again before they match`,
45
+ })
46
+ : new EntityError({
47
+ code: 'X_ENTITY_SEALED_PREDICATE',
48
+ cause: `${entityName}.${column} is sealed for lookup and was named in ${use} that is not an equality — the database holds ciphertext, and a deterministic seal preserves equality and nothing else: no order, no range, no like`,
49
+ fix: `match it by equality — where({ ${column}: value }) or andWhere('${column}', 'in', values) — and order or range by a column that is not sealed`,
50
+ });
51
+
52
+ /**
53
+ * An equality on a lookup column whose operand is not a string. The column stores ciphertext or
54
+ * NULL, so a number or an object would match no row — the same answer as "nobody has it". The
55
+ * value itself is never echoed: it is the secret's plaintext, or close to it.
56
+ */
57
+ export const sealedLookupValue = (
58
+ entityName: string,
59
+ column: string,
60
+ shape: 'value' | 'list',
61
+ ): EntityError =>
62
+ new EntityError({
63
+ code: 'X_ENTITY_SEALED_PREDICATE',
64
+ cause: `${entityName}.${column} is sealed for lookup and was matched against a ${shape === 'list' ? 'list holding a value' : 'value'} that is not a string — the column holds sealed strings or NULL, so the filter could only ever match nothing`,
65
+ fix: `pass the plaintext as a string — where({ ${column}: String(value) }) — or null to ask for the rows where ${entityName}.${column} is NULL`,
66
+ });
67
+
68
+ /**
69
+ * A row to insert that lacks a required sealed column. The driver would refuse it anyway ("is
70
+ * required and has no default"), but in words that send the reader to the entity — and the usual
71
+ * cause is one line above the call: a repository row does not ENUMERATE a sealed property, so
72
+ * `{ ...row }` leaves it behind. The rewrite names it, which is the one way to copy a secret.
73
+ */
74
+ export const sealedMissing = (entityName: string, column: string): EntityError =>
75
+ new EntityError({
76
+ code: 'X_INVARIANT_VIOLATED',
77
+ cause: `${entityName}.${column}: is required, sealed, and missing from the row to insert — a row read from the repository does not enumerate a sealed column, so a spread ({ ...row }) or an Object.assign copy leaves it behind`,
78
+ fix: `{ ...row, ${column}: row.${column} } # in the ${entityName} insert()/insertAll()/upsertAll() call: a sealed column is copied by name, never by spread`,
79
+ });
80
+
81
+ /** A sealed column listed in `$view([...])`. A view is what leaves the server. */
82
+ export const sealedInView = (entityName: string, column: string): EntityError =>
83
+ new EntityError({
84
+ code: 'X_ENTITY_SEALED_IN_VIEW',
85
+ cause: `${entityName}.$view([...]) names ${column}, which is sealed — a view is an action's output, an OpenAPI schema and an MCP tool result, and a sealed column leaves the server through none of them`,
86
+ fix: `remove '${column}' from the $view([...]) list — server code reads it off the row; a caller that must be shown it gets its own field on a t.object output, set by the handler`,
87
+ });
88
+
89
+ /**
90
+ * `.unique()` on a column sealed without `lookup`, refused on the chain — before any entity holds
91
+ * the column, so there is no entity to name and the fix is the rewritten chain.
92
+ */
93
+ export const sealedUniqueOpaque = (): EntityError =>
94
+ new EntityError({
95
+ code: 'X_ENTITY_SEALED_PREDICATE',
96
+ cause:
97
+ 'column.sealed: a sealed column cannot be unique without lookup — uniqueness is the database comparing ciphertexts, and an opaque seal draws a fresh IV, so two equal values never collide',
98
+ fix: 'text().sealed({ lookup: true }).unique() — equal plaintexts then store as visibly equal ciphertexts, so never for a low-entropy value',
99
+ });
100
+
101
+ /**
102
+ * A link a sealed column cannot take — a default, a key, the tenant, a foreign key, a search
103
+ * source. Raised on the chain, before any entity exists, so the caller supplies the EDIT, exactly
104
+ * as `refuse.ts` has it; it constructs the error itself so `fix-scan.ts` reads each call's fix.
105
+ */
106
+ export const refuseSealed = (link: string, why: string, fix: string): never => {
107
+ throw new EntityError({
108
+ code: 'X_INVARIANT_VIOLATED',
109
+ cause: `column.sealed: a sealed column cannot be ${link} — ${why}`,
110
+ fix,
111
+ });
112
+ };