@ultimat3/entity 1.2.0 → 3.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 +694 -0
- package/README.md +467 -16
- package/package.json +6 -4
- package/src/batch-read.ts +134 -0
- package/src/batch.ts +125 -0
- package/src/bulk-write.ts +285 -0
- package/src/coalesce.ts +189 -0
- package/src/column.ts +91 -2
- package/src/columns-data.ts +205 -0
- package/src/columns.ts +228 -38
- package/src/count-by.ts +148 -0
- package/src/cross-tenant.ts +76 -0
- package/src/cursor.ts +17 -3
- package/src/database.ts +35 -2
- package/src/describe.ts +121 -39
- package/src/entity.ts +65 -20
- package/src/errors.ts +346 -5
- package/src/expr.ts +65 -15
- package/src/index.ts +72 -7
- package/src/invariants.ts +56 -14
- package/src/jit-preload.ts +216 -0
- package/src/n-plus-one.ts +122 -0
- package/src/pg-driver.ts +282 -35
- package/src/pg-row.ts +87 -16
- package/src/pg-sql.ts +156 -13
- package/src/plan.ts +130 -20
- package/src/preload.ts +184 -0
- package/src/query.ts +231 -27
- package/src/registry.ts +63 -5
- package/src/relations.ts +212 -0
- package/src/repo.ts +226 -12
- package/src/seed.ts +288 -19
- package/src/tenancy.ts +194 -12
- package/src/type-pins.ts +311 -0
- package/src/types.ts +114 -12
- package/src/view.ts +8 -2
package/src/database.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// writes a repository class per entity, and nobody can reach a table that is not in the set.
|
|
3
3
|
|
|
4
4
|
import type { EntityCore } from './entity';
|
|
5
|
+
import type { RelatedTables } from './preload';
|
|
5
6
|
import type { Table } from './query';
|
|
6
7
|
import { tableFor } from './query';
|
|
7
8
|
import type { Repo } from './repo';
|
|
@@ -16,6 +17,13 @@ export type Database<E extends EntitySet> = {
|
|
|
16
17
|
/** Where rows actually live. Postgres in production, memory in tests and before migrations. */
|
|
17
18
|
export interface Driver {
|
|
18
19
|
repo<Row>(entity: EntityCore<Row>): Repo<Row>;
|
|
20
|
+
/**
|
|
21
|
+
* TEST SEAM, optional on purpose. Empties everything this driver holds, so one test's rows are
|
|
22
|
+
* not the next test's fixtures. `memoryDriver()` implements it; `postgresDriver()` leaves it
|
|
23
|
+
* undefined, because the rows there are an app's and a framework that could truncate them from a
|
|
24
|
+
* `reset()` eventually would. A harness therefore asks and does not assume: `driver.reset?.()`.
|
|
25
|
+
*/
|
|
26
|
+
reset?(): void;
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
export interface DatabaseOptions {
|
|
@@ -28,6 +36,9 @@ export interface DatabaseOptions {
|
|
|
28
36
|
*/
|
|
29
37
|
export const memoryDriver = (): Driver => {
|
|
30
38
|
const repos = new Map<string, unknown>();
|
|
39
|
+
// Held separately from `repos` so the reset is a call on the repository the tables already
|
|
40
|
+
// resolved, never a replacement of it.
|
|
41
|
+
const resets: (() => void)[] = [];
|
|
31
42
|
return {
|
|
32
43
|
repo<Row>(entity: EntityCore<Row>): Repo<Row> {
|
|
33
44
|
const existing = repos.get(entity.$name);
|
|
@@ -36,14 +47,27 @@ export const memoryDriver = (): Driver => {
|
|
|
36
47
|
if (existing !== undefined) return existing as Repo<Row>;
|
|
37
48
|
const created = memoryRepo<Row>(entity);
|
|
38
49
|
repos.set(entity.$name, created);
|
|
50
|
+
resets.push(() => created.reset());
|
|
39
51
|
return created;
|
|
40
52
|
},
|
|
53
|
+
reset() {
|
|
54
|
+
for (const reset of resets) reset();
|
|
55
|
+
},
|
|
41
56
|
};
|
|
42
57
|
};
|
|
43
58
|
|
|
44
59
|
let shared: Driver | undefined;
|
|
45
60
|
|
|
46
|
-
|
|
61
|
+
/**
|
|
62
|
+
* The driver `database()` uses when a call names none — one per process, created on first use.
|
|
63
|
+
*
|
|
64
|
+
* Exported for ONE reason: a test harness needs the same object the app reads through, so it can
|
|
65
|
+
* seed it before a test and `reset?.()` it after. Without a handle on it, a preload could only
|
|
66
|
+
* build a driver of its own, and rows written into that one are invisible to every `database()`
|
|
67
|
+
* call the app already made. Application code names its driver in `database(entities, { driver })`
|
|
68
|
+
* or takes this one implicitly; it never asks for it by hand.
|
|
69
|
+
*/
|
|
70
|
+
export const defaultDriver = (): Driver => {
|
|
47
71
|
shared ??= memoryDriver();
|
|
48
72
|
return shared;
|
|
49
73
|
};
|
|
@@ -53,9 +77,18 @@ export const database = <E extends EntitySet>(
|
|
|
53
77
|
options: DatabaseOptions = {},
|
|
54
78
|
): Database<E> => {
|
|
55
79
|
const driver = options.driver ?? defaultDriver();
|
|
80
|
+
// Keyed by entity name, which is what a relation names — the object key is the caller's spelling
|
|
81
|
+
// of it. This handle is the whole of what a preload can reach: a table reads the entities its own
|
|
82
|
+
// `database()` call named, through the driver that call was given, so a preload against memory
|
|
83
|
+
// means what a preload against Postgres means.
|
|
84
|
+
const declared = new Map(Object.values(entities).map((entity) => [entity.$name, entity]));
|
|
85
|
+
const related: RelatedTables = (entityName) => {
|
|
86
|
+
const entity = declared.get(entityName);
|
|
87
|
+
return entity === undefined ? undefined : { entity, repo: driver.repo(entity) };
|
|
88
|
+
};
|
|
56
89
|
const tables: Record<string, unknown> = {};
|
|
57
90
|
for (const [key, entity] of Object.entries(entities)) {
|
|
58
|
-
tables[key] = tableFor(entity, driver.repo(entity));
|
|
91
|
+
tables[key] = tableFor(entity, driver.repo(entity), related);
|
|
59
92
|
}
|
|
60
93
|
// Built key by key from `entities`, so each table is the one `Database<E>` names.
|
|
61
94
|
return tables as Database<E>;
|
package/src/describe.ts
CHANGED
|
@@ -5,15 +5,16 @@
|
|
|
5
5
|
// Money is the one place the projection is not one-to-one: one property becomes the two
|
|
6
6
|
// physical columns that back it.
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
import { currencyCheck } from './columns';
|
|
10
|
-
import { invariantViolated } from './errors';
|
|
8
|
+
import { columnName, moneyColumns, referenceBinding } from './column';
|
|
9
|
+
import { currencyCheck, scaleCheck } from './columns';
|
|
11
10
|
import type { Invariant } from './invariants';
|
|
12
|
-
import type { ColumnDescription, EntityDescription } from './registry';
|
|
11
|
+
import type { ColumnDescription, EntityDescription, ReferenceDescription } from './registry';
|
|
13
12
|
import type { AnyColumn, ColumnMeta, IndexDef } from './types';
|
|
14
13
|
|
|
15
14
|
export interface DescribeInput<Row> {
|
|
16
15
|
readonly name: string;
|
|
16
|
+
/** The physical table. The entity's own name unless `entity(name, { table })` said otherwise. */
|
|
17
|
+
readonly table: string;
|
|
17
18
|
readonly columns: readonly (readonly [string, AnyColumn])[];
|
|
18
19
|
readonly primaryKey: readonly string[];
|
|
19
20
|
readonly invariants: readonly Invariant<Row>[];
|
|
@@ -24,27 +25,65 @@ export interface DescribeInput<Row> {
|
|
|
24
25
|
readonly tenantColumn: string | null;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
28
|
+
/**
|
|
29
|
+
* The foreign keys an entity declares, resolved through the one binding resolver. Money is
|
|
30
|
+
* skipped for the reason the DDL projection drops a reference there too: one property is two
|
|
31
|
+
* physical columns, so `snake(property)` names neither of them, and a relation and a foreign-key
|
|
32
|
+
* constraint must not disagree about which columns exist.
|
|
33
|
+
*/
|
|
34
|
+
export const describeReferences = (
|
|
35
|
+
entityName: string,
|
|
36
|
+
columns: readonly (readonly [string, AnyColumn])[],
|
|
37
|
+
): readonly ReferenceDescription[] =>
|
|
38
|
+
columns.flatMap(([property, column]) => {
|
|
39
|
+
const meta = column.$meta;
|
|
40
|
+
if (meta.kind === 'money') return [];
|
|
41
|
+
const target = referenceBinding(entityName, property, meta);
|
|
42
|
+
if (target === null) return [];
|
|
43
|
+
return [
|
|
44
|
+
{
|
|
45
|
+
property,
|
|
46
|
+
column: columnName(property, meta),
|
|
47
|
+
nullable: !meta.notNull,
|
|
48
|
+
targetEntity: target.table,
|
|
49
|
+
targetProperty: target.property,
|
|
50
|
+
targetColumn: target.name,
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The Postgres type a column becomes. `kind` is what the migration generator reads and its table
|
|
57
|
+
* falls through to the kind itself for anything it does not name (`SQL_TYPES[kind] ?? kind`), so a
|
|
58
|
+
* precise type belongs HERE, where the precision, the element and the length are still in scope —
|
|
59
|
+
* the alternative is a second copy of the column vocabulary inside `@ultimat3/db`.
|
|
60
|
+
*/
|
|
61
|
+
export const sqlTypeOf = (meta: ColumnMeta): string => {
|
|
62
|
+
if (meta.kind === 'numeric') {
|
|
63
|
+
return meta.precision === undefined || meta.numericScale === undefined
|
|
64
|
+
? 'numeric'
|
|
65
|
+
: `numeric(${meta.precision}, ${meta.numericScale})`;
|
|
36
66
|
}
|
|
37
|
-
|
|
67
|
+
if (meta.kind === 'array') {
|
|
68
|
+
const element = meta.element?.$meta;
|
|
69
|
+
// `arrayOf` refuses an element that is not one scalar column, so this is total in practice;
|
|
70
|
+
// `text[]` is the answer that keeps a description renderable rather than throwing inside a
|
|
71
|
+
// projection, which is the one place an error has no caller to instruct.
|
|
72
|
+
return `${element === undefined ? 'text' : sqlTypeOf(element)}[]`;
|
|
73
|
+
}
|
|
74
|
+
return meta.kind;
|
|
38
75
|
};
|
|
39
76
|
|
|
40
77
|
const describeColumn = <Row>(
|
|
41
78
|
input: DescribeInput<Row>,
|
|
42
79
|
property: string,
|
|
43
80
|
meta: ColumnMeta,
|
|
81
|
+
reference: ReferenceDescription | undefined,
|
|
44
82
|
): readonly ColumnDescription[] => {
|
|
45
|
-
const physical =
|
|
83
|
+
const physical = columnName(property, meta);
|
|
46
84
|
if (meta.kind === 'money') {
|
|
47
|
-
const
|
|
85
|
+
const parts = moneyColumns(property, meta);
|
|
86
|
+
const currency = parts.currency;
|
|
48
87
|
const shared = {
|
|
49
88
|
notNull: meta.notNull,
|
|
50
89
|
primaryKey: false,
|
|
@@ -55,7 +94,7 @@ const describeColumn = <Row>(
|
|
|
55
94
|
return [
|
|
56
95
|
{
|
|
57
96
|
property: `${property}Minor`,
|
|
58
|
-
column:
|
|
97
|
+
column: parts.minor,
|
|
59
98
|
kind: 'bigint',
|
|
60
99
|
check: null,
|
|
61
100
|
...shared,
|
|
@@ -67,40 +106,83 @@ const describeColumn = <Row>(
|
|
|
67
106
|
check: currencyCheck(currency),
|
|
68
107
|
...shared,
|
|
69
108
|
},
|
|
109
|
+
// Always nullable, whatever the property's own nullability: NULL is how a row says "the
|
|
110
|
+
// currency's own minor unit", which is every amount written before the column existed and
|
|
111
|
+
// every ordinary price after it. A NOT NULL here would demand a scale on values that have
|
|
112
|
+
// none, and `0` is not that value — it means whole units.
|
|
113
|
+
//
|
|
114
|
+
// Absent entirely when the table has none: an adopted amount column predating scale is two
|
|
115
|
+
// physical columns, and describing a third would put a column in the DDL and in every
|
|
116
|
+
// statement that the table does not have.
|
|
117
|
+
...(parts.scale === null
|
|
118
|
+
? []
|
|
119
|
+
: [
|
|
120
|
+
{
|
|
121
|
+
property: `${property}Scale`,
|
|
122
|
+
column: parts.scale,
|
|
123
|
+
kind: 'integer',
|
|
124
|
+
check: scaleCheck(parts.scale),
|
|
125
|
+
...shared,
|
|
126
|
+
notNull: false,
|
|
127
|
+
},
|
|
128
|
+
]),
|
|
70
129
|
];
|
|
71
130
|
}
|
|
72
131
|
return [
|
|
73
132
|
{
|
|
74
133
|
property,
|
|
75
134
|
column: physical,
|
|
76
|
-
kind: meta
|
|
135
|
+
kind: sqlTypeOf(meta),
|
|
77
136
|
notNull: meta.notNull,
|
|
78
137
|
primaryKey: meta.primaryKey || input.primaryKey.includes(property),
|
|
79
138
|
unique: meta.unique,
|
|
80
139
|
hasDefault: meta.default !== undefined,
|
|
81
140
|
check: meta.check?.(physical) ?? null,
|
|
82
|
-
|
|
141
|
+
// Rendered from the resolved record, so the string a migration reads and the record a
|
|
142
|
+
// traversal reads can never disagree about what a `references()` points at.
|
|
143
|
+
references:
|
|
144
|
+
reference === undefined ? null : `${reference.targetEntity}.${reference.targetColumn}`,
|
|
83
145
|
},
|
|
84
146
|
];
|
|
85
147
|
};
|
|
86
148
|
|
|
87
|
-
export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescription =>
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
149
|
+
export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescription => {
|
|
150
|
+
const physicalOf = (property: string): string => {
|
|
151
|
+
const column = input.columns.find(([key]) => key === property)?.[1];
|
|
152
|
+
return column === undefined ? property : columnName(property, column.$meta);
|
|
153
|
+
};
|
|
154
|
+
const references = new Map(
|
|
155
|
+
describeReferences(input.name, input.columns).map((reference) => [
|
|
156
|
+
reference.property,
|
|
157
|
+
reference,
|
|
158
|
+
]),
|
|
159
|
+
);
|
|
160
|
+
return {
|
|
161
|
+
name: input.name,
|
|
162
|
+
table: input.table,
|
|
163
|
+
primaryKey: input.primaryKey.map(physicalOf),
|
|
164
|
+
columns: input.columns.flatMap(([property, column]) =>
|
|
165
|
+
describeColumn(input, property, column.$meta, references.get(property)),
|
|
166
|
+
),
|
|
167
|
+
invariants: input.invariants.map((inv) => ({
|
|
168
|
+
name: inv.name,
|
|
169
|
+
kind: inv.kind,
|
|
170
|
+
message: inv.message,
|
|
171
|
+
sql: inv.sql,
|
|
172
|
+
where: inv.where ?? null,
|
|
173
|
+
})),
|
|
174
|
+
// Projected whole, never reduced to the name: the generator spells the column list from this
|
|
175
|
+
// and a name cannot be parsed back into one. See `IndexDescription`.
|
|
176
|
+
indexes: input.indexes.map((index) => ({
|
|
177
|
+
name: index.name,
|
|
178
|
+
columns: index.columns,
|
|
179
|
+
unique: index.unique,
|
|
180
|
+
where: index.where ?? null,
|
|
181
|
+
order: index.order ?? null,
|
|
182
|
+
})),
|
|
183
|
+
tags: input.tags,
|
|
184
|
+
cacheTag: input.cacheTag,
|
|
185
|
+
softDelete: input.softDelete,
|
|
186
|
+
orgScoped: input.tenantColumn !== null,
|
|
187
|
+
};
|
|
188
|
+
};
|
package/src/entity.ts
CHANGED
|
@@ -3,16 +3,17 @@
|
|
|
3
3
|
// (the typed db handle, migrations, cache tags, the admin UI, the manifest) is projected from
|
|
4
4
|
// this one call.
|
|
5
5
|
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
6
|
+
import { systemClock } from '@ultimat3/core';
|
|
7
|
+
import { describeValue, type StandardSchemaV1 } from '@ultimat3/schema';
|
|
8
|
+
import { assertColumnName, bindColumn, columnName, moneyColumns } from './column';
|
|
8
9
|
import { newId } from './columns';
|
|
9
|
-
import { describeEntity } from './describe';
|
|
10
|
+
import { describeEntity, describeReferences } from './describe';
|
|
10
11
|
import { invariantViolated } from './errors';
|
|
11
12
|
import type { Expr, InvariantColumns, Resolve } from './expr';
|
|
12
13
|
import { invariantColumns } from './expr';
|
|
13
14
|
import type { Invariant, InvariantDef } from './invariants';
|
|
14
15
|
import { assertInvariants, bindInvariant, invariantsToSql } from './invariants';
|
|
15
|
-
import type { EntityDescription } from './registry';
|
|
16
|
+
import type { EntityDescription, ReferenceDescription } from './registry';
|
|
16
17
|
import { registerEntity } from './registry';
|
|
17
18
|
import { resolveTenantColumn } from './tenancy';
|
|
18
19
|
import type { AnyColumn, ColumnMap, ColumnMeta, IndexDef, RowOf } from './types';
|
|
@@ -27,11 +28,20 @@ export interface IndexInit<C extends ColumnMap> {
|
|
|
27
28
|
readonly order?: 'asc' | 'desc';
|
|
28
29
|
readonly unique?: boolean;
|
|
29
30
|
/** Partial index predicate, written in the same language as an invariant. */
|
|
30
|
-
readonly where?: (columns: InvariantColumns) => Expr;
|
|
31
|
+
readonly where?: (columns: InvariantColumns<C>) => Expr;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
export interface EntityInit<C extends ColumnMap> {
|
|
34
35
|
readonly columns: C;
|
|
36
|
+
/**
|
|
37
|
+
* The physical table, when it is not the entity's own name. The half of adoption a column name
|
|
38
|
+
* cannot cover: `entity('user', { table: 'users', … })` reads and writes the table that is
|
|
39
|
+
* already there, and every statement, index name and foreign key follows it.
|
|
40
|
+
*
|
|
41
|
+
* The entity NAME stays the framework's key — the registry, the cache tag, `x entities describe`
|
|
42
|
+
* and every relation are keyed by it — so renaming a table never moves a cache tag or a policy.
|
|
43
|
+
*/
|
|
44
|
+
readonly table?: string;
|
|
35
45
|
/**
|
|
36
46
|
* The tenant column, said out loud. Omitted, it is inferred from `.tenant()` or a column named
|
|
37
47
|
* `orgId`, so silence never means unscoped.
|
|
@@ -39,7 +49,12 @@ export interface EntityInit<C extends ColumnMap> {
|
|
|
39
49
|
readonly tenant?: keyof C & string;
|
|
40
50
|
/** Composite keys only — a single key is `uuid().primaryKey()` on the column itself. */
|
|
41
51
|
readonly primaryKey?: readonly (keyof C & string)[];
|
|
42
|
-
|
|
52
|
+
/**
|
|
53
|
+
* A callback, not an array of `(c) => …` builders: the column proxy is typed from `C`, and `C`
|
|
54
|
+
* is only fixed once for the whole `invariants` argument. Per-element builders were checked
|
|
55
|
+
* before `C` existed, which is why `c.title` used to be `ColumnExpr | undefined`.
|
|
56
|
+
*/
|
|
57
|
+
readonly invariants?: (columns: InvariantColumns<C>) => readonly InvariantDef[];
|
|
43
58
|
readonly indexes?: readonly IndexInit<C>[];
|
|
44
59
|
/** Extra cache tags this entity participates in, beyond its own. */
|
|
45
60
|
readonly tags?: readonly string[];
|
|
@@ -81,6 +96,12 @@ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
|
|
|
81
96
|
/** The CHECK/UNIQUE statements the migration emits for this entity. */
|
|
82
97
|
$migration(): string;
|
|
83
98
|
$describe(): EntityDescription;
|
|
99
|
+
/**
|
|
100
|
+
* The foreign keys this entity declares, resolved — one record per `references()`, both ends
|
|
101
|
+
* named. The relation map reads it off the registry entry; a consumer holding the entity
|
|
102
|
+
* itself reads it here. Same closure, so there is one reading of a foreign key, not two.
|
|
103
|
+
*/
|
|
104
|
+
$references(): readonly ReferenceDescription[];
|
|
84
105
|
}
|
|
85
106
|
|
|
86
107
|
export type Entity<Row, C extends ColumnMap = ColumnMap> = EntityCore<Row, C> & C;
|
|
@@ -94,7 +115,7 @@ const defaultValue = (meta: ColumnMeta): unknown => {
|
|
|
94
115
|
const declared = meta.default;
|
|
95
116
|
if (declared === undefined) return undefined;
|
|
96
117
|
if (declared.kind === 'value') return declared.value;
|
|
97
|
-
return declared.by === 'uuid-v7' ? newId() :
|
|
118
|
+
return declared.by === 'uuid-v7' ? newId() : systemClock.now();
|
|
98
119
|
};
|
|
99
120
|
|
|
100
121
|
export const entity = <const C extends ColumnMap>(
|
|
@@ -105,6 +126,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
105
126
|
const entries: readonly (readonly [string, AnyColumn])[] = Object.entries(init.columns);
|
|
106
127
|
for (const [property, column] of entries) bindColumn(column, name, property);
|
|
107
128
|
|
|
129
|
+
const table = init.table === undefined ? name : assertColumnName(init.table);
|
|
108
130
|
const cacheTag = `entity:${name}`;
|
|
109
131
|
const softDelete = Object.hasOwn(init.columns, SOFT_DELETE_COLUMN);
|
|
110
132
|
const tenantColumn = resolveTenantColumn(name, init.columns, init.tenant);
|
|
@@ -135,35 +157,46 @@ export const entity = <const C extends ColumnMap>(
|
|
|
135
157
|
`${property} is money: name ${property}.minor or ${property}.currency`,
|
|
136
158
|
);
|
|
137
159
|
}
|
|
138
|
-
return
|
|
160
|
+
return columnName(property, column.$meta);
|
|
139
161
|
}
|
|
140
162
|
if (!isMoney || !MONEY_PARTS.has(part)) {
|
|
141
163
|
throw invariantViolated(name, property, `${property} has no part "${part}"`);
|
|
142
164
|
}
|
|
143
|
-
|
|
165
|
+
const parts = moneyColumns(property, column.$meta);
|
|
166
|
+
return part === 'minor' ? parts.minor : parts.currency;
|
|
144
167
|
};
|
|
145
168
|
|
|
146
|
-
const columnsExpr = invariantColumns(
|
|
169
|
+
const columnsExpr = invariantColumns<C>(
|
|
147
170
|
name,
|
|
148
171
|
entries.map(([property]) => property),
|
|
149
172
|
);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
173
|
+
// Through the one resolver, so a soft-delete column the table spells differently is still the
|
|
174
|
+
// column every partial index excludes rows by.
|
|
175
|
+
const partialWhere = softDelete ? `${resolve([SOFT_DELETE_COLUMN])} is null` : undefined;
|
|
176
|
+
// Called once, here, so an unknown column throws while the entity is being declared.
|
|
177
|
+
const invariants: readonly Invariant<Row>[] = (init.invariants?.(columnsExpr) ?? []).map((def) =>
|
|
178
|
+
bindInvariant<Row>(def, resolve, partialWhere),
|
|
153
179
|
);
|
|
154
180
|
|
|
155
181
|
const declared: readonly IndexDef[] = [
|
|
156
182
|
...entries.flatMap(([property, column]) => {
|
|
157
183
|
const meta = column.$meta;
|
|
158
184
|
if (!meta.unique && !meta.index) return [];
|
|
159
|
-
const physical = [
|
|
185
|
+
const physical = [
|
|
186
|
+
meta.kind === 'money' ? moneyColumns(property, meta).minor : columnName(property, meta),
|
|
187
|
+
];
|
|
160
188
|
return [
|
|
161
|
-
{ name: indexName(
|
|
189
|
+
{ name: indexName(table, physical, meta.unique), columns: physical, unique: meta.unique },
|
|
162
190
|
];
|
|
163
191
|
}),
|
|
164
192
|
...(init.indexes ?? []).map((index) => {
|
|
165
193
|
const columns = index.on.map((property) => resolve([property]));
|
|
166
194
|
const unique = index.unique === true;
|
|
195
|
+
// `on: []` is type-legal and names nothing: the generated DDL would be `on "posts" ()`, a
|
|
196
|
+
// syntax error one migration later. Refused where it was written instead.
|
|
197
|
+
if (columns.length === 0) {
|
|
198
|
+
throw invariantViolated(name, 'index', 'an index must name at least one column');
|
|
199
|
+
}
|
|
167
200
|
const where = index.where?.(columnsExpr).toSql(resolve) ?? null;
|
|
168
201
|
if (index.where !== undefined && where === null) {
|
|
169
202
|
throw invariantViolated(
|
|
@@ -173,7 +206,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
173
206
|
);
|
|
174
207
|
}
|
|
175
208
|
return {
|
|
176
|
-
name: indexName(
|
|
209
|
+
name: indexName(table, columns, unique),
|
|
177
210
|
columns,
|
|
178
211
|
unique,
|
|
179
212
|
...(index.order === undefined ? {} : { order: index.order }),
|
|
@@ -190,6 +223,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
190
223
|
const describe = (): EntityDescription =>
|
|
191
224
|
describeEntity({
|
|
192
225
|
name,
|
|
226
|
+
table,
|
|
193
227
|
columns: entries,
|
|
194
228
|
primaryKey,
|
|
195
229
|
invariants,
|
|
@@ -199,15 +233,25 @@ export const entity = <const C extends ColumnMap>(
|
|
|
199
233
|
softDelete,
|
|
200
234
|
tenantColumn,
|
|
201
235
|
});
|
|
236
|
+
const references = (): readonly ReferenceDescription[] => describeReferences(name, entries);
|
|
202
237
|
|
|
203
238
|
const parse = (value: unknown): Row => {
|
|
204
239
|
if (typeof value !== 'object' || value === null) {
|
|
205
|
-
|
|
240
|
+
// `describeValue`, never `String(value)`: this message is a `$parse` failure, which reaches
|
|
241
|
+
// the caller and the log line — and the whole row is the last value in the framework that
|
|
242
|
+
// may be echoed there. Same renderer as the column builders (`columns.ts`) use.
|
|
243
|
+
throw invariantViolated(name, 'row', `expected an object, got ${describeValue(value)}`);
|
|
206
244
|
}
|
|
207
245
|
const input = value as Readonly<Record<string, unknown>>;
|
|
208
246
|
const row: Record<string, unknown> = {};
|
|
209
247
|
for (const [property, column] of entries) {
|
|
210
|
-
|
|
248
|
+
// `=== undefined`, never `??`: an explicit `null` is the caller CLEARING a nullable column,
|
|
249
|
+
// and `??` read it as absence and wrote the column's declared default straight back — so
|
|
250
|
+
// `update(id, { status: null })` reported success and stored `'draft'`, with nothing on any
|
|
251
|
+
// surface to say otherwise. A present `undefined` still means absence, which is what a
|
|
252
|
+
// spread of an omitted optional key produces and is the shape every existing caller has.
|
|
253
|
+
const raw = input[property];
|
|
254
|
+
const given = raw === undefined ? defaultValue(column.$meta) : raw;
|
|
211
255
|
if (given === undefined || given === null) {
|
|
212
256
|
if (column.$meta.notNull) {
|
|
213
257
|
throw invariantViolated(name, property, 'is required and has no default');
|
|
@@ -223,7 +267,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
223
267
|
|
|
224
268
|
const core: EntityCore<Row, C> = {
|
|
225
269
|
$name: name,
|
|
226
|
-
$table:
|
|
270
|
+
$table: table,
|
|
227
271
|
$columns: init.columns,
|
|
228
272
|
$primaryKey: primaryKey,
|
|
229
273
|
$indexes: indexes,
|
|
@@ -258,9 +302,10 @@ export const entity = <const C extends ColumnMap>(
|
|
|
258
302
|
$assert: (row) => assertInvariants(name, invariants, row),
|
|
259
303
|
$migration: () => invariantsToSql(name, invariants),
|
|
260
304
|
$describe: describe,
|
|
305
|
+
$references: references,
|
|
261
306
|
};
|
|
262
307
|
|
|
263
|
-
registerEntity({ name, tableName:
|
|
308
|
+
registerEntity({ name, tableName: table, describe, references });
|
|
264
309
|
// The columns land on the entity itself so `orgs.id` is a column reference; every framework
|
|
265
310
|
// member is `$`-prefixed, which is why a column may be called `name`.
|
|
266
311
|
return Object.assign(core, init.columns);
|