@ultimat3/entity 23.0.0 → 25.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
 
@@ -112,7 +113,8 @@ export interface SeedContext {
112
113
  * One row whose id the TABLE owns, matched on the natural key `by` names. Reads first so the
113
114
  * answer can be `'skipped'`, then writes with a single `on conflict … do update`, which is what
114
115
  * settles the race between two containers booting at once — the read is for the report, never
115
- * for the decision.
116
+ * for the decision. On an `appendOnly` entity the write is `on conflict do nothing`: an absent
117
+ * row is appended, and a stored row the seed would CHANGE is refused (`X_ENTITY_APPEND_ONLY`).
116
118
  */
117
119
  upsert<Row, C extends ColumnMap>(
118
120
  entity: EntityCore<Row, C>,
@@ -143,7 +145,7 @@ export interface SeedContext {
143
145
  readonly now: Date;
144
146
  readonly environment: Environment;
145
147
  readonly tier: SeedTier;
146
- /** Reads still run; every write short-circuits and is counted as what it WOULD have written. */
148
+ /** Every verb runs for real, inside a transaction `run()` rolls back: counted, never kept. */
147
149
  readonly dryRun: boolean;
148
150
  readonly metrics: SeedMetrics;
149
151
  }
@@ -209,6 +211,14 @@ const equalityPredicates = <Row>(where: Partial<Row>): readonly Predicate[] =>
209
211
  const primaryKeyTarget = <Row>(entity: EntityCore<Row>): readonly (keyof Row & string)[] =>
210
212
  entity.$primaryKey as readonly (keyof Row & string)[];
211
213
 
214
+ /**
215
+ * Thrown out of the dry run's own transaction to roll it back; never leaves `run()`. A dry run IS
216
+ * the real run, in a transaction (`Driver.transactor()`) that is then rolled back — one rule, so
217
+ * what it reports is what a real run would do: a key another tenant holds is skipped, a second
218
+ * verb sees the first one's rows, and a seed that would fail fails.
219
+ */
220
+ const UNDO = Object.freeze({ seedDryRun: 'undo' });
221
+
212
222
  /**
213
223
  * A primary key the row leaves to a GENERATED default is a different id on every run, so the
214
224
  * conflict target finds nothing and each replay inserts one more copy. `$parse` refuses a key with
@@ -256,89 +266,118 @@ export const defineSeed = (
256
266
  const driver = options.driver ?? memoryDriver();
257
267
  const dryRun = options.dryRun ?? false;
258
268
  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(
269
+ /** The context a seed body writes through; `tx` is the dry run's, and absent otherwise. */
270
+ const contextFor = (tx: Tx | undefined): SeedContext => {
271
+ // The in-memory driver undoes through the `tx` a write is handed; Postgres finds the open
272
+ // transaction itself and ignores it.
273
+ const undo = tx === undefined ? {} : { tx };
274
+ const context: SeedContext = {
275
+ insert: async (entity, rows) => {
276
+ // Judged on the row as WRITTEN, before `$parse` fills the column that would hide it.
277
+ for (const [position, row] of rows.entries()) {
278
+ const missing = entity.$primaryKey.find(
279
+ (property) =>
280
+ entity.$columns[property]?.$meta.default?.kind === 'generated' &&
281
+ !Object.hasOwn(row, property),
282
+ );
283
+ if (missing !== undefined) throw generatedKey(entity, missing, position);
284
+ }
285
+ const parsed = rows.map((row) => entity.$parse(row));
286
+ const written = await driver.repo(entity).upsertAll(parsed, {
287
+ ...undo,
288
+ onConflict: primaryKeyTarget(entity),
289
+ // Never `'update'`: a do-nothing conflict needs no tenant column in the target, so this
290
+ // is the one form that replays on a tenant-scoped entity whose unique keys are global.
291
+ onMatch: 'nothing',
292
+ });
293
+ metrics.inserted += written.length;
294
+ metrics.skipped += parsed.length - written.length;
295
+ },
296
+
297
+ upsert: async (entity, key, values) => {
298
+ const row = entity.$parse(values);
299
+ const repo = driver.repo(entity);
300
+ const where = Object.fromEntries(
301
+ key.by.map((property) => [property, cellOf(row, property)]),
302
+ );
303
+ const found = await repo.findMany({ where: equalityPredicates(where), limit: 1 });
304
+ const stored = found.rows[0];
305
+ const preserve: readonly string[] = key.preserve ?? [CREATED_AT_COLUMN];
306
+ // Only what the CALLER named, and never a key the table generates: `$parse` fills a
307
+ // fresh uuid and a `defaultNow()` into `row`, which no stored row can equal — so a
308
+ // re-run reported every row `'updated'` and never once `'skipped'`.
309
+ const compared = Object.keys(row as Record<string, unknown>).filter(
264
310
  (property) =>
265
- entity.$columns[property]?.$meta.default?.kind === 'generated' &&
266
- !Object.hasOwn(row, property),
311
+ !preserve.includes(property) &&
312
+ Object.hasOwn(values, property) &&
313
+ (!entity.$primaryKey.includes(property) || key.by.includes(property as never)),
267
314
  );
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) {
315
+ if (
316
+ stored !== undefined &&
317
+ compared.every((property) =>
318
+ sameCell(cellOf(stored, property), cellOf(row, property)),
319
+ )
320
+ ) {
321
+ metrics.skipped += 1;
322
+ return 'skipped';
323
+ }
324
+ if (stored === undefined && entity.$appendOnly) {
325
+ // An appendOnly table refuses `do update` outright, even with nothing to update; a
326
+ // do-nothing conflict still settles two boots racing on the key, and the loser's row
327
+ // was the winner's — the same seed — so it is reported skipped, never a refusal.
328
+ // A CHANGED stored row still falls through below, to the append-only refusal.
329
+ const written = await repo.upsertAll([row], {
330
+ ...undo,
331
+ onConflict: key.by,
332
+ onMatch: 'nothing',
333
+ });
334
+ const outcome: SeedWrite = written.length > 0 ? 'inserted' : 'skipped';
335
+ metrics[outcome] += 1;
336
+ return outcome;
337
+ }
338
+ const write: SeedWrite = stored === undefined ? 'inserted' : 'updated';
312
339
  await repo.upsertAll([stored === undefined ? row : withoutPreserved(row, preserve)], {
340
+ ...undo,
313
341
  onConflict: key.by,
314
342
  onMatch: 'update',
315
343
  });
316
- }
317
- metrics[write === 'inserted' ? 'inserted' : 'updated'] += 1;
318
- return write;
319
- },
344
+ metrics[write === 'inserted' ? 'inserted' : 'updated'] += 1;
345
+ return write;
346
+ },
320
347
 
321
- count: async (entity, where) =>
322
- driver
323
- .repo(entity)
324
- .count(where === undefined ? {} : { where: equalityPredicates(where) }),
348
+ count: async (entity, where) =>
349
+ driver
350
+ .repo(entity)
351
+ .count(where === undefined ? {} : { where: equalityPredicates(where) }),
325
352
 
326
- exists: async (entity, where) => (await context.count(entity, where)) > 0,
353
+ exists: async (entity, where) => (await context.count(entity, where)) > 0,
327
354
 
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
- },
355
+ deleteWhere: async (entity, where) => {
356
+ if (entity.$softDelete) throw softDeleteWipe(entity);
357
+ return driver.repo(entity).deleteWhere(where, undo);
358
+ },
333
359
 
334
- id: seedId,
335
- now: entityNow(),
336
- environment: resolveEnvironment({ env: options.env }),
337
- tier,
338
- dryRun,
339
- metrics,
360
+ id: seedId,
361
+ now: entityNow(),
362
+ environment: resolveEnvironment({ env: options.env }),
363
+ tier,
364
+ dryRun,
365
+ metrics,
366
+ };
367
+ return context;
340
368
  };
341
- await build(context);
369
+ if (!dryRun) {
370
+ await build(contextFor(undefined));
371
+ return { name, tier, metrics };
372
+ }
373
+ try {
374
+ await driver.transactor().run(async (tx) => {
375
+ await build(contextFor(tx));
376
+ throw UNDO;
377
+ });
378
+ } catch (thrown) {
379
+ if (thrown !== UNDO) throw thrown;
380
+ }
342
381
  return { name, tier, metrics };
343
382
  },
344
383
  };
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/type-pins.ts CHANGED
@@ -451,3 +451,28 @@ export type _MarkerSurvivesNullable = Assert<
451
451
  export type _SealedTableIsAReadBuilder = Assert<
452
452
  typeof sealedTable extends ReadBuilder<SealedPinRow> ? true : false
453
453
  >;
454
+
455
+ /**
456
+ * Entity exports no `Page` (owner decision O-12, 25.0.0): `@ultimat3/core`'s is the ONE page
457
+ * shape, and `findMany` answers it — import it from core (or `@ultimat3/query`, which re-exports
458
+ * it). A second spelling here is a second door to one name; re-exporting it from this barrel
459
+ * makes this directive unused, which is the build error.
460
+ */
461
+ // @ts-expect-error — `Page` is not an export of `@ultimat3/entity`.
462
+ export type _EntityExportsNoPage = import('./index').Page<unknown>;
463
+
464
+ /**
465
+ * `findMany` answers core's `Page`, both halves of the union: a repo page whose `nextCursor` could
466
+ * be set on the last page (entity's 24.x `Page` had no `hasMore` at all) fails here.
467
+ */
468
+ export type _FindManyAnswersCorePage = Assert<
469
+ [Awaited<ReturnType<Repo<{ id: string }>['findMany']>>] extends [
470
+ import('@ultimat3/core').Page<{ id: string }>,
471
+ ]
472
+ ? [import('@ultimat3/core').Page<{ id: string }>] extends [
473
+ Awaited<ReturnType<Repo<{ id: string }>['findMany']>>,
474
+ ]
475
+ ? true
476
+ : false
477
+ : false
478
+ >;
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
+ ];