@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.
@@ -0,0 +1,235 @@
1
+ // Single responsibility: the ONE seam a sealed column crosses. A repository wrapped here seals the
2
+ // sealed properties of every row and patch on the way in, opens them on the way out, and rewrites
3
+ // or refuses every predicate that names one — so both drivers store ciphertext and neither knows
4
+ // it. Both driver factories return through this, which is why the typed handle and a hand-held
5
+ // `postgresRepo()` cannot disagree. An entity with no sealed column gets its repository back as is.
6
+
7
+ import type { AggregateFn } from './aggregate';
8
+ import { namedColumns } from './plan';
9
+ import type { FindManyArgs, Repo, UpsertArgs } from './repo';
10
+ import type { SealedField, SealedSource, SealKeys } from './sealed';
11
+ import {
12
+ lookupCandidates,
13
+ openValue,
14
+ sealedFields,
15
+ sealKeysOnce,
16
+ sealValue,
17
+ serverOnly,
18
+ } from './sealed';
19
+ import type { SealedUse } from './sealed-errors';
20
+ import { sealedLookupValue, sealedMissing, sealedPredicate } from './sealed-errors';
21
+ import type { Predicate } from './tenancy';
22
+
23
+ type Loose = Readonly<Record<string, unknown>>;
24
+
25
+ const isRecord = (value: unknown): value is Loose => typeof value === 'object' && value !== null;
26
+
27
+ /**
28
+ * A row or a patch with its sealed properties sealed. Only a STRING is sealed: `null` clears the
29
+ * column, `undefined` leaves it alone, and anything else is not a value this column holds — it is
30
+ * passed on untouched so the driver's own `$parse` refuses it in the words it always uses.
31
+ *
32
+ * Each sealed property is read BY NAME and carried explicitly, because the copy below is a spread
33
+ * and a repository row does not enumerate one: `insert(row)` and `update(id, row)` with a row
34
+ * passed back whole write exactly what the row holds, a `null` included.
35
+ *
36
+ * `inserting` says the values are a whole ROW: a required sealed column it lacks is refused here,
37
+ * in words that name the spread that usually lost it. The typed handle refuses the same row with
38
+ * the same error one step earlier (`entity.$parse`); this is the hand-held repository's.
39
+ */
40
+ const sealRow = async <T>(
41
+ entity: SealedSource,
42
+ fields: readonly SealedField[],
43
+ values: T,
44
+ keys: SealKeys,
45
+ inserting = false,
46
+ ): Promise<T> => {
47
+ if (!isRecord(values)) return values;
48
+ const sealed: Record<string, unknown> = {};
49
+ for (const field of fields) {
50
+ const value = Object.hasOwn(values, field.property) ? values[field.property] : undefined;
51
+ if (typeof value === 'string') sealed[field.property] = await sealValue(field, value, keys);
52
+ else if (value !== undefined) sealed[field.property] = value;
53
+ else if (inserting && field.required) throw sealedMissing(entity.$name, field.property);
54
+ }
55
+ // A copy, never the caller's object: the row they hold keeps the plaintext they wrote.
56
+ return { ...values, ...sealed } as T;
57
+ };
58
+
59
+ /**
60
+ * A stored row with its sealed properties opened. A projection that left one out leaves it out.
61
+ * Opened means SERVER-ONLY (`serverOnly`): the plaintext is read by name and enumerated by nothing,
62
+ * so the row a repository answers can be serialised whole and the secret stays here.
63
+ */
64
+ const openRow = async <T>(fields: readonly SealedField[], row: T, keys: SealKeys): Promise<T> => {
65
+ if (!isRecord(row)) return row;
66
+ const opened: Record<string, unknown> = {};
67
+ for (const field of fields) {
68
+ const value = Object.hasOwn(row, field.property) ? row[field.property] : undefined;
69
+ if (typeof value === 'string') opened[field.property] = await openValue(field, value, keys);
70
+ }
71
+ return serverOnly(fields, row, opened) as T;
72
+ };
73
+
74
+ const openRows = <T>(
75
+ fields: readonly SealedField[],
76
+ rows: readonly T[],
77
+ keys: SealKeys,
78
+ ): Promise<T[]> => Promise.all(rows.map((row) => openRow(fields, row, keys)));
79
+
80
+ /**
81
+ * One predicate, as the database can answer it.
82
+ *
83
+ * A NULL test passes through for EVERY sealed column: `null` is never sealed — it is stored as
84
+ * NULL in a nullable `text` column — so whether a value is present is a fact the schema already
85
+ * publishes, and it is what lets a backfill visit only the rows still to be sealed. Past that an
86
+ * opaque column answers nothing, and a lookup column answers equality: `eq` and `in` are rewritten
87
+ * to the stored strings the value seals to — one per declared key, so a rotation in progress still
88
+ * finds the rows written before it.
89
+ */
90
+ const rewrite = async (
91
+ entity: SealedSource,
92
+ fields: readonly SealedField[],
93
+ predicate: Predicate,
94
+ keys: SealKeys,
95
+ ): Promise<Predicate> => {
96
+ const field = fields.find((one) => one.property === predicate.column);
97
+ if (field === undefined) return predicate;
98
+ const { op, value } = predicate;
99
+ if (op === 'is-null' || op === 'is-not-null') return predicate;
100
+ if (!field.lookup) throw sealedPredicate(entity.$name, field.property, 'a filter', false);
101
+ // A stored value is ciphertext or NULL, so anything but a string or a NULL would reach the driver
102
+ // and match nothing — indistinguishable from "no such row". Refused instead.
103
+ const sealable = (one: unknown): boolean =>
104
+ typeof one === 'string' || one === null || one === undefined;
105
+ if ((op === 'eq' || op === 'in') && !(Array.isArray(value) ? value : [value]).every(sealable)) {
106
+ throw sealedLookupValue(entity.$name, field.property, op === 'in' ? 'list' : 'value');
107
+ }
108
+ if (op === 'eq') {
109
+ if (typeof value !== 'string') return predicate;
110
+ const candidates = await lookupCandidates(field, value, keys);
111
+ const [only] = candidates;
112
+ return candidates.length === 1 && only !== undefined
113
+ ? { ...predicate, value: only }
114
+ : { ...predicate, op: 'in', value: candidates };
115
+ }
116
+ if (op === 'in' && Array.isArray(value)) {
117
+ const lists = await Promise.all(
118
+ value.map((one: unknown) =>
119
+ typeof one === 'string' ? lookupCandidates(field, one, keys) : [one],
120
+ ),
121
+ );
122
+ return { ...predicate, value: lists.flat() };
123
+ }
124
+ throw sealedPredicate(entity.$name, field.property, 'a filter', true);
125
+ };
126
+
127
+ /** A column named where no rewrite exists: an order, a group, an aggregate, a write's filter. */
128
+ const refuseNamed = (
129
+ entity: SealedSource,
130
+ fields: readonly SealedField[],
131
+ properties: readonly string[],
132
+ use: SealedUse,
133
+ lookupAllowed = false,
134
+ ): void => {
135
+ for (const property of properties) {
136
+ const field = fields.find((one) => one.property === property);
137
+ if (field === undefined || (lookupAllowed && field.lookup)) continue;
138
+ throw sealedPredicate(entity.$name, field.property, use, field.lookup);
139
+ }
140
+ };
141
+
142
+ const readArgs = async <A extends FindManyArgs>(
143
+ entity: SealedSource,
144
+ fields: readonly SealedField[],
145
+ args: A | undefined,
146
+ keys: SealKeys,
147
+ ): Promise<A | undefined> => {
148
+ if (args === undefined) return args;
149
+ refuseNamed(
150
+ entity,
151
+ fields,
152
+ (args.orderBy ?? []).map((key) => key.column),
153
+ 'an order',
154
+ );
155
+ if (args.where === undefined) return args;
156
+ const where = await Promise.all(args.where.map((one) => rewrite(entity, fields, one, keys)));
157
+ return { ...args, where };
158
+ };
159
+
160
+ /**
161
+ * `repo`, sealing. Every method stays `async` and rejects rather than throws, as the contract
162
+ * requires. Written as a spread over the inner repository so a member this file does not know —
163
+ * `MemoryRepo.reset()` — survives it.
164
+ */
165
+ export const sealedRepo = <Row, R extends Repo<Row>>(entity: SealedSource, repo: R): R => {
166
+ const fields = sealedFields(entity);
167
+ if (fields.length === 0) return repo;
168
+ const filterOf = (filter: unknown): readonly string[] =>
169
+ namedColumns(filter).map(([property]) => property);
170
+
171
+ // Every method takes its own ring: one resolution per repository call, however many values.
172
+ const sealing: Repo<Row> = {
173
+ async findById(id, options) {
174
+ const row = await repo.findById(id, options);
175
+ return row === null ? null : openRow(fields, row, sealKeysOnce());
176
+ },
177
+ async findMany(args) {
178
+ const keys = sealKeysOnce();
179
+ const page = await repo.findMany(await readArgs(entity, fields, args, keys));
180
+ return { rows: await openRows(fields, page.rows, keys), nextCursor: page.nextCursor };
181
+ },
182
+ async insert(values, options) {
183
+ const keys = sealKeysOnce();
184
+ const stored = await repo.insert(await sealRow(entity, fields, values, keys, true), options);
185
+ return openRow(fields, stored, keys);
186
+ },
187
+ async insertAll(rows, options) {
188
+ const keys = sealKeysOnce();
189
+ const sealed = await Promise.all(rows.map((row) => sealRow(entity, fields, row, keys, true)));
190
+ return openRows(fields, await repo.insertAll(sealed, options), keys);
191
+ },
192
+ async upsertAll(rows, args: UpsertArgs<Row>) {
193
+ // A conflict target is a uniqueness comparison, so it is a lookup column's or nobody's.
194
+ refuseNamed(entity, fields, args.onConflict, 'an upsert conflict target', true);
195
+ const keys = sealKeysOnce();
196
+ const sealed = await Promise.all(rows.map((row) => sealRow(entity, fields, row, keys, true)));
197
+ return openRows(fields, await repo.upsertAll(sealed, args), keys);
198
+ },
199
+ async update(id, patch, options) {
200
+ const keys = sealKeysOnce();
201
+ const stored = await repo.update(id, await sealRow(entity, fields, patch, keys), options);
202
+ return openRow(fields, stored, keys);
203
+ },
204
+ delete: (id, options) => repo.delete(id, options),
205
+ // A filtered write's filter is refused for every sealed column, lookup included: its shape is
206
+ // one value per column, and during a rotation a lookup value has one stored form PER KEY.
207
+ async deleteWhere(filter, options) {
208
+ refuseNamed(entity, fields, filterOf(filter), "a filtered write's filter");
209
+ return repo.deleteWhere(filter, options);
210
+ },
211
+ async updateWhere(filter, patch, options) {
212
+ refuseNamed(entity, fields, filterOf(filter), "a filtered write's filter");
213
+ return repo.updateWhere(
214
+ filter,
215
+ await sealRow(entity, fields, patch, sealKeysOnce()),
216
+ options,
217
+ );
218
+ },
219
+ async count(args) {
220
+ return repo.count(await readArgs(entity, fields, args, sealKeysOnce()));
221
+ },
222
+ async countBy(column, args) {
223
+ refuseNamed(entity, fields, [column], 'a grouped count');
224
+ return repo.countBy(column, await readArgs(entity, fields, args, sealKeysOnce()));
225
+ },
226
+ async aggregate(fn: AggregateFn, column, args) {
227
+ refuseNamed(entity, fields, [column], 'an aggregate');
228
+ return repo.aggregate(fn, column, await readArgs(entity, fields, args, sealKeysOnce()));
229
+ },
230
+ async approximateCount(args) {
231
+ return repo.approximateCount(await readArgs(entity, fields, args, sealKeysOnce()));
232
+ },
233
+ };
234
+ return { ...repo, ...sealing };
235
+ };
package/src/sealed.ts ADDED
@@ -0,0 +1,194 @@
1
+ // Single responsibility: which columns of an entity are sealed, and the two conversions a value
2
+ // crosses on — plaintext to the stored string on a write, the stored string back on a read. The
3
+ // cipher is `@ultimat3/core`'s `seal()` / `open()`; this file only decides WHAT is sealed, under
4
+ // which purpose. `sealed-repo.ts` is the one caller on the row path.
5
+
6
+ import type { SealKeyRing } from '@ultimat3/core';
7
+ import { openText, resolveSealKeys, seal, sealAll } from '@ultimat3/core';
8
+ import { columnName } from './column';
9
+ import type { AnyColumn, ColumnMap } from './types';
10
+
11
+ /** The slice of an entity this reads, so the declaration path can ask before `entity()` returns. */
12
+ export interface SealedSource {
13
+ readonly $name: string;
14
+ readonly $table: string;
15
+ readonly $columns: ColumnMap;
16
+ }
17
+
18
+ export interface SealedField {
19
+ /** Property key on the row. */
20
+ readonly property: string;
21
+ /** Physical column — half of the purpose, and what `x doctor` names. */
22
+ readonly column: string;
23
+ /**
24
+ * `entity:<table>.<column>`, derived and never stated: two columns cannot share one by accident,
25
+ * so a value copied from one sealed column into another does not open there. PHYSICAL names —
26
+ * the ones a migration renames — so renaming the table or the column is a re-seal.
27
+ */
28
+ readonly purpose: string;
29
+ readonly lookup: boolean;
30
+ /** NOT NULL — and a sealed column takes no default, so a row to insert must carry it. */
31
+ readonly required: boolean;
32
+ /**
33
+ * The column's own parser, which judges a PLAINTEXT — `text({ max })` included. Run before a
34
+ * value is sealed, because nothing after that point can: the driver only ever sees the sealed
35
+ * string, which is longer than any bound the author wrote.
36
+ */
37
+ readonly parse: (value: unknown) => unknown;
38
+ }
39
+
40
+ export const sealedPurpose = (table: string, column: string): string => `entity:${table}.${column}`;
41
+
42
+ const describeField = (table: string, property: string, column: AnyColumn): SealedField[] => {
43
+ const sealed = column.$meta.sealed;
44
+ if (sealed === undefined) return [];
45
+ const physical = columnName(property, column.$meta);
46
+ return [
47
+ {
48
+ property,
49
+ column: physical,
50
+ purpose: sealedPurpose(table, physical),
51
+ lookup: sealed.lookup,
52
+ required: column.$meta.notNull,
53
+ parse: column.$parse,
54
+ },
55
+ ];
56
+ };
57
+
58
+ const fields = new WeakMap<ColumnMap, readonly SealedField[]>();
59
+
60
+ /**
61
+ * Every sealed column of the entity, in declaration order — empty for almost every entity, and
62
+ * that is the fast path: a repository over one with none is returned untouched. Memoised on the
63
+ * column map, which is the declaration itself and outlives nothing it should not.
64
+ */
65
+ export const sealedFields = (entity: SealedSource): readonly SealedField[] => {
66
+ const known = fields.get(entity.$columns);
67
+ if (known !== undefined) return known;
68
+ const found = Object.entries(entity.$columns).flatMap(([property, column]) =>
69
+ describeField(entity.$table, property, column),
70
+ );
71
+ fields.set(entity.$columns, found);
72
+ return found;
73
+ };
74
+
75
+ /**
76
+ * The key ring for ONE repository call, resolved the first time a value needs it and shared by
77
+ * every value after. A 500-row page with two sealed columns is a thousand values and one ring —
78
+ * not a thousand reads of the environment or the key file. Lazy, so a call that seals and opens
79
+ * nothing needs no key at all; per call and never longer, so a rotation is seen by the next one.
80
+ */
81
+ export type SealKeys = () => Promise<SealKeyRing>;
82
+
83
+ export const sealKeysOnce = (): SealKeys => {
84
+ let ring: Promise<SealKeyRing> | undefined;
85
+ return () => {
86
+ ring ??= resolveSealKeys();
87
+ return ring;
88
+ };
89
+ };
90
+
91
+ /**
92
+ * The stored form of one value: validated as the plaintext it is, then sealed. A lookup column
93
+ * seals deterministically so equality survives. `async`, so a refused value rejects like every
94
+ * other repository failure rather than throwing at the call.
95
+ */
96
+ export const sealValue = async (
97
+ field: SealedField,
98
+ plaintext: string,
99
+ keys: SealKeys,
100
+ ): Promise<string> => {
101
+ const checked = field.parse(plaintext);
102
+ return seal(typeof checked === 'string' ? checked : plaintext, {
103
+ purpose: field.purpose,
104
+ deterministic: field.lookup,
105
+ keys: await keys(),
106
+ });
107
+ };
108
+
109
+ /**
110
+ * The plaintext of one stored value. A stored string that is not a sealed value is
111
+ * `X_SEAL_INVALID`, always: there is no reading of an unsealed row, so a column sealed after rows
112
+ * were written is migrated by expand, `backfill()`, contract — never by tolerating the old rows.
113
+ */
114
+ export const openValue = async (
115
+ field: SealedField,
116
+ stored: string,
117
+ keys: SealKeys,
118
+ ): Promise<string> => openText(stored, { purpose: field.purpose, keys: await keys() });
119
+
120
+ /**
121
+ * Every stored string a lookup may match `plaintext` against: one per declared key. Outside a
122
+ * rotation that is one string; while a retired key is still declared it is two, so the rows
123
+ * written before the rotation are still found.
124
+ */
125
+ export const lookupCandidates = async (
126
+ field: SealedField,
127
+ plaintext: string,
128
+ keys: SealKeys,
129
+ ): Promise<readonly string[]> => sealAll(plaintext, { purpose: field.purpose, keys: await keys() });
130
+
131
+ type Loose = Readonly<Record<string, unknown>>;
132
+
133
+ /**
134
+ * A copy of `row` whose sealed properties are SERVER-ONLY: own, readable and writable —
135
+ * `row.password` is the plaintext and the row type does not change — but NOT ENUMERABLE. So
136
+ * `JSON.stringify`, an object spread, `Object.keys`/`entries`, `structuredClone` and every
137
+ * serialiser built on them leave the property behind, which is what keeps a row handed whole to a
138
+ * `query`, an island prop, a log line, a job payload or a cache tier from carrying the secret out.
139
+ * Fail closed: a path nobody listed omits it too, and an explicit `row.password` is the only read.
140
+ *
141
+ * `opened` wins over the stored value; a property the row does not have is not invented, and a
142
+ * NULL is hidden like a value — whether a secret is set is not for the wire either.
143
+ */
144
+ export const serverOnly = <T extends Loose>(
145
+ fields: readonly SealedField[],
146
+ row: T,
147
+ opened: Loose = {},
148
+ ): T => {
149
+ const out: Record<string, unknown> = { ...row };
150
+ for (const { property } of fields) {
151
+ if (!Object.hasOwn(row, property)) continue;
152
+ Object.defineProperty(out, property, {
153
+ value: Object.hasOwn(opened, property) ? opened[property] : row[property],
154
+ enumerable: false,
155
+ writable: true,
156
+ configurable: true,
157
+ });
158
+ }
159
+ return out as T;
160
+ };
161
+
162
+ /**
163
+ * A copy of a row that KEEPS what `serverOnly` set — `{ ...row }` would drop every sealed property,
164
+ * which is the point of it everywhere a row leaves and wrong where server code only wants its own
165
+ * object. Enumerable properties stay enumerable, so this is a spread for every other entity.
166
+ */
167
+ export const copyRow = <T extends object>(row: T): T =>
168
+ Object.defineProperties({}, Object.getOwnPropertyDescriptors(row)) as T;
169
+
170
+ /**
171
+ * `keys` of a row, each as the row holds it: a projection that NAMES a sealed column still answers
172
+ * it server-only. Assigning the value instead would re-create it enumerable, and a row that names
173
+ * the secret would be the one repository row that serialises it. A key the row lacks is present
174
+ * and `undefined`, as an assignment would leave it.
175
+ */
176
+ export const pickRow = <T extends object, K extends keyof T & string>(
177
+ row: T,
178
+ keys: readonly K[],
179
+ ): Pick<T, K> => {
180
+ const picked = {};
181
+ for (const key of keys) {
182
+ Object.defineProperty(
183
+ picked,
184
+ key,
185
+ Object.getOwnPropertyDescriptor(row, key) ?? {
186
+ value: undefined,
187
+ enumerable: true,
188
+ writable: true,
189
+ configurable: true,
190
+ },
191
+ );
192
+ }
193
+ return picked as Pick<T, K>;
194
+ };
package/src/type-pins.ts CHANGED
@@ -5,15 +5,25 @@
5
5
  // enforcement this repo counts (axiom 3).
6
6
 
7
7
  import type { Id } from '@ultimat3/core';
8
- import type { MoneyValue as SchemaMoneyValue } from '@ultimat3/schema';
8
+ import type { Schema, MoneyValue as SchemaMoneyValue } from '@ultimat3/schema';
9
9
  import type { uuid } from './columns';
10
10
  import type { EntitySet } from './database';
11
11
  import type { Entity, EntityCore, EntityInit } from './entity';
12
12
  import type { ColumnExpr, InvariantColumns } from './expr';
13
13
  import type { Invariant, InvariantDef } from './invariants';
14
- import type { Table } from './query';
14
+ import type { ReadBuilder, Table } from './query';
15
15
  import type { Repo } from './repo';
16
- import type { AnyColumn, IdOf, Insertable, MoneyInput, MoneyValue, RowOf } from './types';
16
+ import type {
17
+ AnyColumn,
18
+ IdOf,
19
+ Insertable,
20
+ MoneyInput,
21
+ MoneyValue,
22
+ RowOf,
23
+ SealedColumn,
24
+ TextColumn,
25
+ WireRow,
26
+ } from './types';
17
27
 
18
28
  /** Fails to compile when `T` is anything but `true`. The whole mechanism. */
19
29
  type Assert<T extends true> = T;
@@ -345,3 +355,99 @@ type _RepoBatchWritesTakeABigIntMinor = Assert<
345
355
  type _RepoAnswersTheValueType = Assert<
346
356
  [Awaited<ReturnType<Repo<PinMoneyRow>['insert']>>] extends [PinMoneyRow] ? true : false
347
357
  >;
358
+
359
+ // --- Sealed columns (plan 101, slice 03) ---------------------------------------------------------
360
+ // The row keeps `string`; the wire schema loses the column; a predicate naming it does not compile.
361
+
362
+ declare const sealedPin: Entity<RowOf<SealedPinColumns>, SealedPinColumns>;
363
+ type SealedPinColumns = {
364
+ readonly id: ReturnType<typeof uuid>;
365
+ readonly label: TextColumn;
366
+ readonly password: SealedColumn<string, false, false>;
367
+ readonly email: SealedColumn<string, false, true>;
368
+ };
369
+ type SealedPinRow = RowOf<SealedPinColumns>;
370
+ declare const sealedTable: Table<SealedPinRow, SealedPinColumns>;
371
+
372
+ /** The server row is the whole row, plaintext included. */
373
+ export type _SealedRowKeepsTheColumn = Assert<
374
+ SealedPinRow['password'] extends string ? true : false
375
+ >;
376
+
377
+ /** `$schema` parses to the row WITHOUT its sealed columns — what an `output` sends. */
378
+ type SealedWire = WireRow<SealedPinRow, SealedPinColumns>;
379
+ export type _SealedWireDropsBoth = Assert<
380
+ 'password' extends keyof SealedWire ? false : 'email' extends keyof SealedWire ? false : true
381
+ >;
382
+ export type _SealedWireKeepsTheRest = Assert<'label' extends keyof SealedWire ? true : false>;
383
+ export type _SealedSchemaIsTheWireRow = Assert<
384
+ (typeof sealedPin)['$schema'] extends Schema<unknown, SealedWire> ? true : false
385
+ >;
386
+ /** …and an entity with no sealed column keeps `Row` itself, not a mapped copy. */
387
+ export type _UnsealedWireIsTheRow = Assert<
388
+ WireRow<PinRow, PinColumns> extends PinRow
389
+ ? PinRow extends WireRow<PinRow, PinColumns>
390
+ ? true
391
+ : false
392
+ : false
393
+ >;
394
+
395
+ /** A sealed entity is still an `EntityCore`, so `database()` and every driver take it. */
396
+ export type _SealedEntityIsACore = Assert<
397
+ typeof sealedPin extends EntityCore<SealedPinRow, SealedPinColumns> ? true : false
398
+ >;
399
+ export type _SealedEntityIsInASet = Assert<
400
+ { readonly x: typeof sealedPin } extends EntitySet ? true : false
401
+ >;
402
+
403
+ type WhereFilter = Parameters<(typeof sealedTable)['where']>[0];
404
+ /** An opaque column is absent from the filter; a lookup column and a plain one are present. */
405
+ export type _OpaqueIsNotFilterable = Assert<'password' extends keyof WhereFilter ? false : true>;
406
+ export type _LookupIsFilterable = Assert<'email' extends keyof WhereFilter ? true : false>;
407
+ export type _PlainIsFilterable = Assert<'label' extends keyof WhereFilter ? true : false>;
408
+
409
+ type AndWhereColumn = Parameters<(typeof sealedTable)['andWhere']>[0];
410
+ export type _OpaqueIsNotAPredicate = Assert<'password' extends AndWhereColumn ? false : true>;
411
+ export type _LookupIsAPredicate = Assert<'email' extends AndWhereColumn ? true : false>;
412
+ /** …but a NULL test names any column: presence is not sealed. The overload is the first one. */
413
+ declare const nullTested: ReturnType<typeof nullTest>;
414
+ declare function nullTest(): ReturnType<(typeof sealedTable)['andWhere']>;
415
+ export type _NullTestTakesAnOpaqueColumn = Assert<
416
+ (typeof sealedTable)['andWhere'] extends (column: 'password', op: 'is-null') => typeof nullTested
417
+ ? true
418
+ : false
419
+ >;
420
+
421
+ type OrderColumn = Parameters<(typeof sealedTable)['orderBy']>[0];
422
+ /** Neither kind orders: ciphertext has no order worth asking for. */
423
+ export type _SealedDoesNotOrder = Assert<
424
+ 'password' extends OrderColumn ? false : 'email' extends OrderColumn ? false : true
425
+ >;
426
+ export type _PlainOrders = Assert<'label' extends OrderColumn ? true : false>;
427
+
428
+ /** `.sealed()` is `text()`'s: no other builder has it, so a sealed number does not compile. */
429
+ export type _OnlyTextSeals = Assert<
430
+ 'sealed' extends keyof TextColumn
431
+ ? 'sealed' extends keyof ReturnType<typeof uuid>
432
+ ? false
433
+ : true
434
+ : false
435
+ >;
436
+ /** It survives the links that may precede it, and keeps its marker across the ones that follow. */
437
+ export type _SealedAfterNullable = Assert<
438
+ ReturnType<TextColumn['nullable']> extends { sealed: unknown } ? true : false
439
+ >;
440
+ export type _MarkerSurvivesNullable = Assert<
441
+ ReturnType<SealedColumn<string, false, true>['nullable']>['$sealed'] extends 'lookup'
442
+ ? true
443
+ : false
444
+ >;
445
+
446
+ /**
447
+ * A sealed table is still a `ReadBuilder<Row>`: `backfill({ source })`, `preload` and every other
448
+ * consumer that names the chain without the columns takes it. The refusal is in the PARAMETERS,
449
+ * which a method compares bivariantly, so the narrower chain is assignable to the wider one.
450
+ */
451
+ export type _SealedTableIsAReadBuilder = Assert<
452
+ typeof sealedTable extends ReadBuilder<SealedPinRow> ? true : false
453
+ >;
package/src/types.ts CHANGED
@@ -155,6 +155,27 @@ export interface ColumnMeta {
155
155
  * indexes by a data key.
156
156
  */
157
157
  readonly machine?: StateMachine;
158
+ /**
159
+ * Presence is what makes this column SEALED: stored as `@ultimat3/core`'s `seal()` output, opened
160
+ * on read, absent from every schema that leaves the server. `sealed-repo.ts` is the one reader
161
+ * that seals and opens; everything else reads it to refuse (`sealed-declaration.ts`).
162
+ */
163
+ readonly sealed?: SealedMeta;
164
+ }
165
+
166
+ /** What `.sealed(options)` declared, resolved. */
167
+ export interface SealedMeta {
168
+ /** Deterministic seal: equality in `where` and `.unique()` are allowed, and nothing else. */
169
+ readonly lookup: boolean;
170
+ }
171
+
172
+ export interface SealedOptions<Lookup extends boolean = boolean> {
173
+ /**
174
+ * Seal deterministically so the column can be matched by equality (`where`, `in`, `.unique()`).
175
+ * It REVEALS EQUALITY: two rows holding the same value store the same string. Never for a
176
+ * low-entropy value — a boolean, a status, a PIN.
177
+ */
178
+ readonly lookup?: Lookup;
158
179
  }
159
180
 
160
181
  /**
@@ -230,6 +251,85 @@ export interface TimestampColumn<Optional extends boolean = false> extends Colum
230
251
  column(name: string): TimestampColumn<Optional>;
231
252
  }
232
253
 
254
+ /**
255
+ * `text()`'s own column: the one that may be sealed, because a sealed value is a string and no
256
+ * other kind has a use for it. `nullable()`, `unique()`, `default()` and `column()` keep the
257
+ * builder, so `.sealed()` reads the same wherever it sits among them.
258
+ */
259
+ export interface TextColumn<T extends string | null = string, Optional extends boolean = false>
260
+ extends Column<T, Optional> {
261
+ nullable(): TextColumn<T | null, Optional>;
262
+ unique(): TextColumn<T, Optional>;
263
+ default(value: T): TextColumn<T, true>;
264
+ column(name: string): TextColumn<T, Optional>;
265
+ /**
266
+ * Seal this column under the app's master key: the row still reads `string`, the database holds
267
+ * `x1.<keyId>.<iv>.<ciphertext>`, and no view, `$schema`, record or predicate carries it.
268
+ * `.sealed({ lookup: true })` allows equality at the cost of revealing it.
269
+ */
270
+ sealed<const Lookup extends boolean = false>(
271
+ options?: SealedOptions<Lookup>,
272
+ ): SealedColumn<T, Optional, Lookup>;
273
+ }
274
+
275
+ /**
276
+ * A sealed column. `$sealed` is the phantom the typed handle reads: `'opaque'` is refused in every
277
+ * predicate, `'lookup'` in everything but equality. It is a real string at runtime too.
278
+ */
279
+ export interface SealedColumn<
280
+ T extends string | null = string,
281
+ Optional extends boolean = false,
282
+ Lookup extends boolean = false,
283
+ > extends Column<T, Optional> {
284
+ readonly $sealed: Lookup extends true ? 'lookup' : 'opaque';
285
+ nullable(): SealedColumn<T | null, Optional, Lookup>;
286
+ /** `lookup` only: uniqueness over ciphertext needs equal values to seal equal. */
287
+ unique(): SealedColumn<T, Optional, Lookup>;
288
+ column(name: string): SealedColumn<T, Optional, Lookup>;
289
+ }
290
+
291
+ /** The columns no predicate may name, and the ones only equality may. Read by `query.ts`. */
292
+ export type OpaqueKeys<C extends ColumnMap> = {
293
+ [K in keyof C]-?: C[K] extends { readonly $sealed: 'opaque' } ? K : never;
294
+ }[keyof C];
295
+ export type SealedKeys<C extends ColumnMap> = {
296
+ [K in keyof C]-?: C[K] extends { readonly $sealed: string } ? K : never;
297
+ }[keyof C];
298
+
299
+ /**
300
+ * Which keys the typed chain refuses: `opaque` in every predicate, `sealed` (opaque and lookup
301
+ * both) in an order or a group. A pair rather than the column map itself, so `ReadBuilder` stays
302
+ * row-shaped and a `select()` or a `preload()` carries it along unchanged.
303
+ */
304
+ export interface SealedKeySet {
305
+ readonly opaque: PropertyKey;
306
+ readonly sealed: PropertyKey;
307
+ }
308
+
309
+ /** The default: an entity with no sealed column, and every row-agnostic consumer. */
310
+ export interface NoSealedKeys {
311
+ readonly opaque: never;
312
+ readonly sealed: never;
313
+ }
314
+
315
+ export interface SealedKeysOf<C extends ColumnMap> {
316
+ readonly opaque: OpaqueKeys<C>;
317
+ readonly sealed: SealedKeys<C>;
318
+ }
319
+
320
+ /** A filter's shape: the row without the columns no predicate may name. */
321
+ export type Filterable<Row, S extends SealedKeySet> = [S['opaque']] extends [never]
322
+ ? Row
323
+ : Omit<Row, S['opaque']>;
324
+
325
+ /**
326
+ * The row as it leaves the server: without its sealed columns. `[…] extends [never]` so an entity
327
+ * with none — and every row-agnostic `EntityCore` — keeps `Row` itself, not a mapped copy of it.
328
+ */
329
+ export type WireRow<Row, C extends ColumnMap> = [SealedKeys<C>] extends [never]
330
+ ? Row
331
+ : Omit<Row, SealedKeys<C>>;
332
+
233
333
  export type AnyColumn = Column<unknown, boolean>;
234
334
 
235
335
  export type ColumnMap = Readonly<Record<string, AnyColumn>>;