@ultimat3/entity 22.14.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 +36 -31
- package/README.md +113 -2
- package/package.json +6 -5
- package/src/columns-scalar.ts +13 -6
- package/src/entity-error.ts +21 -1
- package/src/entity.ts +78 -12
- package/src/index.ts +10 -0
- package/src/instant.ts +84 -5
- package/src/memory-match.ts +7 -0
- package/src/memory-repo.ts +16 -2
- package/src/pg-driver.ts +4 -1
- package/src/pg-row.ts +4 -1
- package/src/preload.ts +3 -1
- package/src/query.ts +54 -21
- package/src/record-projection.ts +17 -2
- package/src/row-observer.ts +17 -2
- package/src/rows-of.ts +13 -1
- package/src/sealed-column.ts +119 -0
- package/src/sealed-declaration.ts +64 -0
- package/src/sealed-errors.ts +112 -0
- package/src/sealed-repo.ts +235 -0
- package/src/sealed.ts +194 -0
- package/src/type-pins.ts +109 -3
- package/src/types.ts +100 -0
package/src/memory-repo.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
33
|
-
|
|
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(
|
|
52
|
-
|
|
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
|
|
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>
|
|
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
|
|
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
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
package/src/record-projection.ts
CHANGED
|
@@ -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({
|
|
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
|
package/src/row-observer.ts
CHANGED
|
@@ -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:
|
|
213
|
-
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
|
+
};
|