@ultimat3/entity 23.0.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/src/sealed.ts CHANGED
@@ -30,9 +30,9 @@ export interface SealedField {
30
30
  /** NOT NULL — and a sealed column takes no default, so a row to insert must carry it. */
31
31
  readonly required: boolean;
32
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.
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
36
  */
37
37
  readonly parse: (value: unknown) => unknown;
38
38
  }
@@ -50,7 +50,9 @@ const describeField = (table: string, property: string, column: AnyColumn): Seal
50
50
  purpose: sealedPurpose(table, physical),
51
51
  lookup: sealed.lookup,
52
52
  required: column.$meta.notNull,
53
- parse: column.$parse,
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,
54
56
  },
55
57
  ];
56
58
  };
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>;
package/src/types.ts CHANGED
@@ -167,6 +167,12 @@ export interface ColumnMeta {
167
167
  export interface SealedMeta {
168
168
  /** Deterministic seal: equality in `where` and `.unique()` are allowed, and nothing else. */
169
169
  readonly lookup: boolean;
170
+ /**
171
+ * The parser the column had BEFORE it was sealed — the one that judges a plaintext, `max`
172
+ * included. The column's own `$parse` passes any string shaped like a sealed value (a driver
173
+ * hands it one), so a value about to be sealed is judged by this instead.
174
+ */
175
+ readonly plaintext: (value: unknown) => unknown;
170
176
  }
171
177
 
172
178
  export interface SealedOptions<Lookup extends boolean = boolean> {
@@ -0,0 +1,44 @@
1
+ // Single responsibility: the non-partial unique constraints an entity declares, beside its primary
2
+ // key — ONE list, read by the upsert's conflict-target check (`bulk-write.ts`) and by the in-memory
3
+ // driver's own enforcement (`memory-unique.ts`), so a unique the one accepts as a target is a
4
+ // unique the other refuses a duplicate under.
5
+
6
+ import { constraintNameFor } from '@ultimat3/db';
7
+ import type { EntityCore } from './entity';
8
+
9
+ export interface UniqueConstraint {
10
+ /** Physical column names, in declaration order. */
11
+ readonly columns: readonly string[];
12
+ /** The constraint's name in the database — asked for only once a collision is being reported. */
13
+ name(): string;
14
+ }
15
+
16
+ /**
17
+ * Two sources, because this framework has two ways to declare a unique index beside the key and a
18
+ * rule honoured for one of them only sends its author to declare the constraint a second time:
19
+ * `unique()` / `indexes: [{ unique: true }]` (both land in `$indexes`), and
20
+ * `invariant(name, c.unique([…]))`, which emits its `create unique index` out of `$invariants`.
21
+ *
22
+ * A PARTIAL unique is excluded from both: its predicate is SQL the memory driver cannot evaluate
23
+ * and an `on conflict` clause here does not spell. `bindInvariant` stamps that `where` on a
24
+ * soft-deleting entity, so the one rule covers both lists.
25
+ */
26
+ export const uniqueConstraints = <Row>(entity: EntityCore<Row>): readonly UniqueConstraint[] => [
27
+ ...entity.$indexes
28
+ .filter((index) => index.unique && index.where === undefined)
29
+ .map((index) => ({ columns: index.columns, name: () => index.name })),
30
+ ...entity.$invariants
31
+ .filter((rule) => rule.kind === 'unique' && rule.where === undefined)
32
+ .map((rule) => ({
33
+ columns: rule.columns,
34
+ // `@ultimat3/db` names it, as it does in the DDL: `<table>_<name>_key`.
35
+ name: () =>
36
+ constraintNameFor(entity.$table, {
37
+ name: rule.name,
38
+ kind: 'unique',
39
+ message: rule.message,
40
+ sql: rule.sql,
41
+ where: null,
42
+ }),
43
+ })),
44
+ ];