@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.
@@ -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
- /** Reads still run; every write short-circuits and is counted as what it WOULD have written. */
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
- const context: SeedContext = {
260
- insert: async (entity, rows) => {
261
- // Judged on the row as WRITTEN, before `$parse` fills the column that would hide it.
262
- for (const [position, row] of rows.entries()) {
263
- const missing = entity.$primaryKey.find(
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
- entity.$columns[property]?.$meta.default?.kind === 'generated' &&
266
- !Object.hasOwn(row, property),
310
+ !preserve.includes(property) &&
311
+ Object.hasOwn(values, property) &&
312
+ (!entity.$primaryKey.includes(property) || key.by.includes(property as never)),
267
313
  );
268
- if (missing !== undefined) throw generatedKey(entity, missing, position);
269
- }
270
- const parsed = rows.map((row) => entity.$parse(row));
271
- if (dryRun) {
272
- metrics.inserted += parsed.length;
273
- return;
274
- }
275
- const written = await driver.repo(entity).upsertAll(parsed, {
276
- onConflict: primaryKeyTarget(entity),
277
- // Never `'update'`: a do-nothing conflict needs no tenant column in the target, so this
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
- metrics[write === 'inserted' ? 'inserted' : 'updated'] += 1;
318
- return write;
319
- },
329
+ metrics[write === 'inserted' ? 'inserted' : 'updated'] += 1;
330
+ return write;
331
+ },
320
332
 
321
- count: async (entity, where) =>
322
- driver
323
- .repo(entity)
324
- .count(where === undefined ? {} : { where: equalityPredicates(where) }),
333
+ count: async (entity, where) =>
334
+ driver
335
+ .repo(entity)
336
+ .count(where === undefined ? {} : { where: equalityPredicates(where) }),
325
337
 
326
- exists: async (entity, where) => (await context.count(entity, where)) > 0,
338
+ exists: async (entity, where) => (await context.count(entity, where)) > 0,
327
339
 
328
- deleteWhere: async (entity, where) => {
329
- if (entity.$softDelete) throw softDeleteWipe(entity);
330
- if (dryRun) return context.count(entity, where);
331
- return driver.repo(entity).deleteWhere(where);
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
- id: seedId,
335
- now: entityNow(),
336
- environment: resolveEnvironment({ env: options.env }),
337
- tier,
338
- dryRun,
339
- metrics,
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
- await build(context);
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>;