@ultimat3/entity 22.15.0 → 24.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 +72 -51
- package/README.md +145 -21
- package/package.json +6 -5
- package/src/aggregate.ts +31 -22
- package/src/bulk-write.ts +4 -13
- package/src/column.ts +36 -1
- package/src/columns-data.ts +24 -3
- package/src/columns-scalar.ts +13 -6
- package/src/columns.ts +8 -1
- package/src/containment.ts +15 -2
- package/src/database.ts +9 -2
- package/src/entity-error.ts +45 -20
- package/src/entity.ts +81 -18
- package/src/errors.ts +16 -42
- package/src/expr.ts +66 -20
- package/src/index.ts +16 -1
- package/src/instant.ts +84 -5
- package/src/invariants.ts +3 -3
- package/src/judged-write.ts +28 -0
- package/src/like.ts +85 -0
- package/src/memory-match.ts +27 -82
- package/src/memory-repo.ts +16 -2
- package/src/memory-unique.ts +7 -6
- package/src/numeric-compare.ts +57 -0
- package/src/pg-driver.ts +38 -29
- package/src/pg-row.ts +4 -1
- package/src/pg-sql.ts +5 -1
- package/src/plan.ts +9 -3
- package/src/preload-ceiling.ts +75 -0
- package/src/preload.ts +23 -8
- package/src/query.ts +67 -34
- 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 +123 -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 +196 -0
- package/src/seed.ts +97 -73
- package/src/transition.ts +3 -0
- package/src/type-pins.ts +109 -3
- package/src/types.ts +106 -0
- package/src/unique-constraints.ts +44 -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,196 @@
|
|
|
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 parser the column had before `.sealed()`, which judges a PLAINTEXT — `text({ max })`
|
|
34
|
+
* included. Run before a value is sealed, because nothing after that point can: the driver only
|
|
35
|
+
* ever sees the sealed 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
|
+
// The UNWRAPPED parser: `column.$parse` passes anything shaped like a sealed value, so a
|
|
54
|
+
// lookalike plaintext skipped `text({ max })` on its way to being sealed.
|
|
55
|
+
parse: sealed.plaintext,
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const fields = new WeakMap<ColumnMap, readonly SealedField[]>();
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Every sealed column of the entity, in declaration order — empty for almost every entity, and
|
|
64
|
+
* that is the fast path: a repository over one with none is returned untouched. Memoised on the
|
|
65
|
+
* column map, which is the declaration itself and outlives nothing it should not.
|
|
66
|
+
*/
|
|
67
|
+
export const sealedFields = (entity: SealedSource): readonly SealedField[] => {
|
|
68
|
+
const known = fields.get(entity.$columns);
|
|
69
|
+
if (known !== undefined) return known;
|
|
70
|
+
const found = Object.entries(entity.$columns).flatMap(([property, column]) =>
|
|
71
|
+
describeField(entity.$table, property, column),
|
|
72
|
+
);
|
|
73
|
+
fields.set(entity.$columns, found);
|
|
74
|
+
return found;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The key ring for ONE repository call, resolved the first time a value needs it and shared by
|
|
79
|
+
* every value after. A 500-row page with two sealed columns is a thousand values and one ring —
|
|
80
|
+
* not a thousand reads of the environment or the key file. Lazy, so a call that seals and opens
|
|
81
|
+
* nothing needs no key at all; per call and never longer, so a rotation is seen by the next one.
|
|
82
|
+
*/
|
|
83
|
+
export type SealKeys = () => Promise<SealKeyRing>;
|
|
84
|
+
|
|
85
|
+
export const sealKeysOnce = (): SealKeys => {
|
|
86
|
+
let ring: Promise<SealKeyRing> | undefined;
|
|
87
|
+
return () => {
|
|
88
|
+
ring ??= resolveSealKeys();
|
|
89
|
+
return ring;
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The stored form of one value: validated as the plaintext it is, then sealed. A lookup column
|
|
95
|
+
* seals deterministically so equality survives. `async`, so a refused value rejects like every
|
|
96
|
+
* other repository failure rather than throwing at the call.
|
|
97
|
+
*/
|
|
98
|
+
export const sealValue = async (
|
|
99
|
+
field: SealedField,
|
|
100
|
+
plaintext: string,
|
|
101
|
+
keys: SealKeys,
|
|
102
|
+
): Promise<string> => {
|
|
103
|
+
const checked = field.parse(plaintext);
|
|
104
|
+
return seal(typeof checked === 'string' ? checked : plaintext, {
|
|
105
|
+
purpose: field.purpose,
|
|
106
|
+
deterministic: field.lookup,
|
|
107
|
+
keys: await keys(),
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The plaintext of one stored value. A stored string that is not a sealed value is
|
|
113
|
+
* `X_SEAL_INVALID`, always: there is no reading of an unsealed row, so a column sealed after rows
|
|
114
|
+
* were written is migrated by expand, `backfill()`, contract — never by tolerating the old rows.
|
|
115
|
+
*/
|
|
116
|
+
export const openValue = async (
|
|
117
|
+
field: SealedField,
|
|
118
|
+
stored: string,
|
|
119
|
+
keys: SealKeys,
|
|
120
|
+
): Promise<string> => openText(stored, { purpose: field.purpose, keys: await keys() });
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Every stored string a lookup may match `plaintext` against: one per declared key. Outside a
|
|
124
|
+
* rotation that is one string; while a retired key is still declared it is two, so the rows
|
|
125
|
+
* written before the rotation are still found.
|
|
126
|
+
*/
|
|
127
|
+
export const lookupCandidates = async (
|
|
128
|
+
field: SealedField,
|
|
129
|
+
plaintext: string,
|
|
130
|
+
keys: SealKeys,
|
|
131
|
+
): Promise<readonly string[]> => sealAll(plaintext, { purpose: field.purpose, keys: await keys() });
|
|
132
|
+
|
|
133
|
+
type Loose = Readonly<Record<string, unknown>>;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A copy of `row` whose sealed properties are SERVER-ONLY: own, readable and writable —
|
|
137
|
+
* `row.password` is the plaintext and the row type does not change — but NOT ENUMERABLE. So
|
|
138
|
+
* `JSON.stringify`, an object spread, `Object.keys`/`entries`, `structuredClone` and every
|
|
139
|
+
* serialiser built on them leave the property behind, which is what keeps a row handed whole to a
|
|
140
|
+
* `query`, an island prop, a log line, a job payload or a cache tier from carrying the secret out.
|
|
141
|
+
* Fail closed: a path nobody listed omits it too, and an explicit `row.password` is the only read.
|
|
142
|
+
*
|
|
143
|
+
* `opened` wins over the stored value; a property the row does not have is not invented, and a
|
|
144
|
+
* NULL is hidden like a value — whether a secret is set is not for the wire either.
|
|
145
|
+
*/
|
|
146
|
+
export const serverOnly = <T extends Loose>(
|
|
147
|
+
fields: readonly SealedField[],
|
|
148
|
+
row: T,
|
|
149
|
+
opened: Loose = {},
|
|
150
|
+
): T => {
|
|
151
|
+
const out: Record<string, unknown> = { ...row };
|
|
152
|
+
for (const { property } of fields) {
|
|
153
|
+
if (!Object.hasOwn(row, property)) continue;
|
|
154
|
+
Object.defineProperty(out, property, {
|
|
155
|
+
value: Object.hasOwn(opened, property) ? opened[property] : row[property],
|
|
156
|
+
enumerable: false,
|
|
157
|
+
writable: true,
|
|
158
|
+
configurable: true,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return out as T;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A copy of a row that KEEPS what `serverOnly` set — `{ ...row }` would drop every sealed property,
|
|
166
|
+
* which is the point of it everywhere a row leaves and wrong where server code only wants its own
|
|
167
|
+
* object. Enumerable properties stay enumerable, so this is a spread for every other entity.
|
|
168
|
+
*/
|
|
169
|
+
export const copyRow = <T extends object>(row: T): T =>
|
|
170
|
+
Object.defineProperties({}, Object.getOwnPropertyDescriptors(row)) as T;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* `keys` of a row, each as the row holds it: a projection that NAMES a sealed column still answers
|
|
174
|
+
* it server-only. Assigning the value instead would re-create it enumerable, and a row that names
|
|
175
|
+
* the secret would be the one repository row that serialises it. A key the row lacks is present
|
|
176
|
+
* and `undefined`, as an assignment would leave it.
|
|
177
|
+
*/
|
|
178
|
+
export const pickRow = <T extends object, K extends keyof T & string>(
|
|
179
|
+
row: T,
|
|
180
|
+
keys: readonly K[],
|
|
181
|
+
): Pick<T, K> => {
|
|
182
|
+
const picked = {};
|
|
183
|
+
for (const key of keys) {
|
|
184
|
+
Object.defineProperty(
|
|
185
|
+
picked,
|
|
186
|
+
key,
|
|
187
|
+
Object.getOwnPropertyDescriptor(row, key) ?? {
|
|
188
|
+
value: undefined,
|
|
189
|
+
enumerable: true,
|
|
190
|
+
writable: true,
|
|
191
|
+
configurable: true,
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return picked as Pick<T, K>;
|
|
196
|
+
};
|
package/src/seed.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { Driver } from './database';
|
|
|
10
10
|
import { memoryDriver } from './database';
|
|
11
11
|
import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
12
12
|
import { EntityError } from './errors';
|
|
13
|
+
import type { Tx } from './repo';
|
|
13
14
|
import type { Predicate } from './tenancy';
|
|
14
15
|
import type { ColumnMap, Insertable } from './types';
|
|
15
16
|
|
|
@@ -143,7 +144,7 @@ export interface SeedContext {
|
|
|
143
144
|
readonly now: Date;
|
|
144
145
|
readonly environment: Environment;
|
|
145
146
|
readonly tier: SeedTier;
|
|
146
|
-
/**
|
|
147
|
+
/** Every verb runs for real, inside a transaction `run()` rolls back: counted, never kept. */
|
|
147
148
|
readonly dryRun: boolean;
|
|
148
149
|
readonly metrics: SeedMetrics;
|
|
149
150
|
}
|
|
@@ -209,6 +210,14 @@ const equalityPredicates = <Row>(where: Partial<Row>): readonly Predicate[] =>
|
|
|
209
210
|
const primaryKeyTarget = <Row>(entity: EntityCore<Row>): readonly (keyof Row & string)[] =>
|
|
210
211
|
entity.$primaryKey as readonly (keyof Row & string)[];
|
|
211
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Thrown out of the dry run's own transaction to roll it back; never leaves `run()`. A dry run IS
|
|
215
|
+
* the real run, in a transaction (`Driver.transactor()`) that is then rolled back — one rule, so
|
|
216
|
+
* what it reports is what a real run would do: a key another tenant holds is skipped, a second
|
|
217
|
+
* verb sees the first one's rows, and a seed that would fail fails.
|
|
218
|
+
*/
|
|
219
|
+
const UNDO = Object.freeze({ seedDryRun: 'undo' });
|
|
220
|
+
|
|
212
221
|
/**
|
|
213
222
|
* A primary key the row leaves to a GENERATED default is a different id on every run, so the
|
|
214
223
|
* conflict target finds nothing and each replay inserts one more copy. `$parse` refuses a key with
|
|
@@ -256,89 +265,104 @@ export const defineSeed = (
|
|
|
256
265
|
const driver = options.driver ?? memoryDriver();
|
|
257
266
|
const dryRun = options.dryRun ?? false;
|
|
258
267
|
const metrics: SeedMetrics = { inserted: 0, updated: 0, skipped: 0 };
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
268
|
+
/** The context a seed body writes through; `tx` is the dry run's, and absent otherwise. */
|
|
269
|
+
const contextFor = (tx: Tx | undefined): SeedContext => {
|
|
270
|
+
// The in-memory driver undoes through the `tx` a write is handed; Postgres finds the open
|
|
271
|
+
// transaction itself and ignores it.
|
|
272
|
+
const undo = tx === undefined ? {} : { tx };
|
|
273
|
+
const context: SeedContext = {
|
|
274
|
+
insert: async (entity, rows) => {
|
|
275
|
+
// Judged on the row as WRITTEN, before `$parse` fills the column that would hide it.
|
|
276
|
+
for (const [position, row] of rows.entries()) {
|
|
277
|
+
const missing = entity.$primaryKey.find(
|
|
278
|
+
(property) =>
|
|
279
|
+
entity.$columns[property]?.$meta.default?.kind === 'generated' &&
|
|
280
|
+
!Object.hasOwn(row, property),
|
|
281
|
+
);
|
|
282
|
+
if (missing !== undefined) throw generatedKey(entity, missing, position);
|
|
283
|
+
}
|
|
284
|
+
const parsed = rows.map((row) => entity.$parse(row));
|
|
285
|
+
const written = await driver.repo(entity).upsertAll(parsed, {
|
|
286
|
+
...undo,
|
|
287
|
+
onConflict: primaryKeyTarget(entity),
|
|
288
|
+
// Never `'update'`: a do-nothing conflict needs no tenant column in the target, so this
|
|
289
|
+
// is the one form that replays on a tenant-scoped entity whose unique keys are global.
|
|
290
|
+
onMatch: 'nothing',
|
|
291
|
+
});
|
|
292
|
+
metrics.inserted += written.length;
|
|
293
|
+
metrics.skipped += parsed.length - written.length;
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
upsert: async (entity, key, values) => {
|
|
297
|
+
const row = entity.$parse(values);
|
|
298
|
+
const repo = driver.repo(entity);
|
|
299
|
+
const where = Object.fromEntries(
|
|
300
|
+
key.by.map((property) => [property, cellOf(row, property)]),
|
|
301
|
+
);
|
|
302
|
+
const found = await repo.findMany({ where: equalityPredicates(where), limit: 1 });
|
|
303
|
+
const stored = found.rows[0];
|
|
304
|
+
const preserve: readonly string[] = key.preserve ?? [CREATED_AT_COLUMN];
|
|
305
|
+
// Only what the CALLER named, and never a key the table generates: `$parse` fills a
|
|
306
|
+
// fresh uuid and a `defaultNow()` into `row`, which no stored row can equal — so a
|
|
307
|
+
// re-run reported every row `'updated'` and never once `'skipped'`.
|
|
308
|
+
const compared = Object.keys(row as Record<string, unknown>).filter(
|
|
264
309
|
(property) =>
|
|
265
|
-
|
|
266
|
-
|
|
310
|
+
!preserve.includes(property) &&
|
|
311
|
+
Object.hasOwn(values, property) &&
|
|
312
|
+
(!entity.$primaryKey.includes(property) || key.by.includes(property as never)),
|
|
267
313
|
);
|
|
268
|
-
if (
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
// is the one form that replays on a tenant-scoped entity whose unique keys are global.
|
|
279
|
-
onMatch: 'nothing',
|
|
280
|
-
});
|
|
281
|
-
metrics.inserted += written.length;
|
|
282
|
-
metrics.skipped += parsed.length - written.length;
|
|
283
|
-
},
|
|
284
|
-
|
|
285
|
-
upsert: async (entity, key, values) => {
|
|
286
|
-
const row = entity.$parse(values);
|
|
287
|
-
const repo = driver.repo(entity);
|
|
288
|
-
const where = Object.fromEntries(
|
|
289
|
-
key.by.map((property) => [property, cellOf(row, property)]),
|
|
290
|
-
);
|
|
291
|
-
const found = await repo.findMany({ where: equalityPredicates(where), limit: 1 });
|
|
292
|
-
const stored = found.rows[0];
|
|
293
|
-
const preserve: readonly string[] = key.preserve ?? [CREATED_AT_COLUMN];
|
|
294
|
-
// Only what the CALLER named, and never a key the table generates: `$parse` fills a
|
|
295
|
-
// fresh uuid and a `defaultNow()` into `row`, which no stored row can equal — so a
|
|
296
|
-
// re-run reported every row `'updated'` and never once `'skipped'`.
|
|
297
|
-
const compared = Object.keys(row as Record<string, unknown>).filter(
|
|
298
|
-
(property) =>
|
|
299
|
-
!preserve.includes(property) &&
|
|
300
|
-
Object.hasOwn(values, property) &&
|
|
301
|
-
(!entity.$primaryKey.includes(property) || key.by.includes(property as never)),
|
|
302
|
-
);
|
|
303
|
-
if (
|
|
304
|
-
stored !== undefined &&
|
|
305
|
-
compared.every((property) => sameCell(cellOf(stored, property), cellOf(row, property)))
|
|
306
|
-
) {
|
|
307
|
-
metrics.skipped += 1;
|
|
308
|
-
return 'skipped';
|
|
309
|
-
}
|
|
310
|
-
const write: SeedWrite = stored === undefined ? 'inserted' : 'updated';
|
|
311
|
-
if (!dryRun) {
|
|
314
|
+
if (
|
|
315
|
+
stored !== undefined &&
|
|
316
|
+
compared.every((property) =>
|
|
317
|
+
sameCell(cellOf(stored, property), cellOf(row, property)),
|
|
318
|
+
)
|
|
319
|
+
) {
|
|
320
|
+
metrics.skipped += 1;
|
|
321
|
+
return 'skipped';
|
|
322
|
+
}
|
|
323
|
+
const write: SeedWrite = stored === undefined ? 'inserted' : 'updated';
|
|
312
324
|
await repo.upsertAll([stored === undefined ? row : withoutPreserved(row, preserve)], {
|
|
325
|
+
...undo,
|
|
313
326
|
onConflict: key.by,
|
|
314
327
|
onMatch: 'update',
|
|
315
328
|
});
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
},
|
|
329
|
+
metrics[write === 'inserted' ? 'inserted' : 'updated'] += 1;
|
|
330
|
+
return write;
|
|
331
|
+
},
|
|
320
332
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
333
|
+
count: async (entity, where) =>
|
|
334
|
+
driver
|
|
335
|
+
.repo(entity)
|
|
336
|
+
.count(where === undefined ? {} : { where: equalityPredicates(where) }),
|
|
325
337
|
|
|
326
|
-
|
|
338
|
+
exists: async (entity, where) => (await context.count(entity, where)) > 0,
|
|
327
339
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
},
|
|
340
|
+
deleteWhere: async (entity, where) => {
|
|
341
|
+
if (entity.$softDelete) throw softDeleteWipe(entity);
|
|
342
|
+
return driver.repo(entity).deleteWhere(where, undo);
|
|
343
|
+
},
|
|
333
344
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
345
|
+
id: seedId,
|
|
346
|
+
now: entityNow(),
|
|
347
|
+
environment: resolveEnvironment({ env: options.env }),
|
|
348
|
+
tier,
|
|
349
|
+
dryRun,
|
|
350
|
+
metrics,
|
|
351
|
+
};
|
|
352
|
+
return context;
|
|
340
353
|
};
|
|
341
|
-
|
|
354
|
+
if (!dryRun) {
|
|
355
|
+
await build(contextFor(undefined));
|
|
356
|
+
return { name, tier, metrics };
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
await driver.transactor().run(async (tx) => {
|
|
360
|
+
await build(contextFor(tx));
|
|
361
|
+
throw UNDO;
|
|
362
|
+
});
|
|
363
|
+
} catch (thrown) {
|
|
364
|
+
if (thrown !== UNDO) throw thrown;
|
|
365
|
+
}
|
|
342
366
|
return { name, tier, metrics };
|
|
343
367
|
},
|
|
344
368
|
};
|
package/src/transition.ts
CHANGED
|
@@ -113,6 +113,9 @@ export const transitionRow = async <Row, C extends ColumnMap>(
|
|
|
113
113
|
// Keyed on the entity's OWN primary key, never the literal `id`: a `code`-keyed entity has no
|
|
114
114
|
// `id` column, so the filter matched nothing in memory (a false `X_STATE_CONFLICT`) and named a
|
|
115
115
|
// column Postgres does not have (`X_INVARIANT_VIOLATED`).
|
|
116
|
+
// An absent id names no row, and is refused HERE: `namedColumns` drops an `undefined` filter
|
|
117
|
+
// value, so the predicate below would be the from-state alone and move every row in it.
|
|
118
|
+
if (id === undefined || id === null) throw notFound(entity.$name, String(id));
|
|
116
119
|
const key = singleKeyOf(entity, 'transition');
|
|
117
120
|
const filter = { [key]: id, [property]: move.from } as unknown as RowPatch<Row>;
|
|
118
121
|
const values = { [property]: move.to } as unknown as RowPatch<Row>;
|