@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/CLAUDE.md +37 -21
- package/README.md +32 -19
- package/package.json +5 -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.ts +8 -1
- package/src/containment.ts +15 -2
- package/src/database.ts +9 -2
- package/src/entity-error.ts +26 -21
- package/src/entity.ts +3 -6
- package/src/errors.ts +16 -42
- package/src/expr.ts +66 -20
- package/src/index.ts +6 -1
- 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-unique.ts +7 -6
- package/src/numeric-compare.ts +57 -0
- package/src/pg-driver.ts +34 -28
- 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 +14 -14
- package/src/sealed-column.ts +7 -3
- package/src/sealed.ts +6 -4
- package/src/seed.ts +97 -73
- package/src/transition.ts +3 -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
|
|
|
@@ -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>;
|
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
|
+
];
|