@ultimat3/entity 22.15.0 → 23.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +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
|
@@ -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 {
|
|
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>>;
|