@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/CLAUDE.md +49 -31
- package/README.md +76 -20
- package/package.json +6 -6
- package/src/aggregate.ts +31 -22
- package/src/append-only-errors.ts +53 -0
- package/src/append-only.ts +82 -0
- package/src/batch.ts +1 -1
- package/src/bulk-write.ts +4 -13
- package/src/column.ts +36 -1
- package/src/columns-data.ts +24 -3
- package/src/columns.ts +9 -2
- package/src/containment.ts +15 -2
- package/src/database.ts +9 -2
- package/src/describe.ts +5 -0
- package/src/entity-error.ts +31 -23
- package/src/entity.ts +18 -30
- package/src/errors.ts +18 -44
- package/src/expr.ts +66 -20
- package/src/feature-errors.ts +3 -1
- package/src/index-init.ts +29 -0
- package/src/index.ts +12 -3
- package/src/invariants.ts +3 -3
- package/src/judged-write.ts +28 -0
- package/src/like.ts +85 -0
- package/src/memory-match.ts +20 -82
- package/src/memory-repo.ts +14 -9
- package/src/memory-unique.ts +7 -6
- package/src/numeric-compare.ts +57 -0
- package/src/pg-driver.ts +53 -45
- package/src/pg-sql.ts +5 -1
- package/src/plan.ts +9 -3
- package/src/preload-ceiling.ts +75 -0
- package/src/preload.ts +20 -7
- package/src/query.ts +19 -17
- package/src/registry.ts +2 -0
- package/src/repo.ts +5 -6
- package/src/row-observer.ts +2 -2
- package/src/sealed-column.ts +7 -3
- package/src/sealed-repo.ts +2 -1
- package/src/sealed.ts +6 -4
- package/src/seed.ts +113 -74
- package/src/transition.ts +3 -0
- package/src/type-pins.ts +25 -0
- package/src/types.ts +6 -0
- package/src/unique-constraints.ts +44 -0
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
|
|
34
|
-
* value is sealed, because nothing after that point can: the driver only
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
266
|
-
|
|
311
|
+
!preserve.includes(property) &&
|
|
312
|
+
Object.hasOwn(values, property) &&
|
|
313
|
+
(!entity.$primaryKey.includes(property) || key.by.includes(property as never)),
|
|
267
314
|
);
|
|
268
|
-
if (
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
},
|
|
344
|
+
metrics[write === 'inserted' ? 'inserted' : 'updated'] += 1;
|
|
345
|
+
return write;
|
|
346
|
+
},
|
|
320
347
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
348
|
+
count: async (entity, where) =>
|
|
349
|
+
driver
|
|
350
|
+
.repo(entity)
|
|
351
|
+
.count(where === undefined ? {} : { where: equalityPredicates(where) }),
|
|
325
352
|
|
|
326
|
-
|
|
353
|
+
exists: async (entity, where) => (await context.count(entity, where)) > 0,
|
|
327
354
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
+
];
|