@ultimat3/entity 1.1.0 → 2.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 +614 -0
- package/README.md +391 -11
- package/package.json +5 -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 +175 -0
- package/src/column.ts +24 -0
- package/src/columns.ts +183 -35
- 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 +82 -37
- package/src/entity.ts +41 -12
- package/src/errors.ts +346 -5
- package/src/expr.ts +65 -15
- package/src/index.ts +49 -4
- 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 +281 -33
- package/src/pg-row.ts +32 -7
- package/src/pg-sql.ts +117 -8
- 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/tenancy.ts +194 -12
- package/src/type-pins.ts +311 -0
- package/src/types.ts +64 -12
- package/src/view.ts +8 -2
package/src/describe.ts
CHANGED
|
@@ -5,11 +5,10 @@
|
|
|
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 { referenceBinding, snake } 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> {
|
|
@@ -24,23 +23,38 @@ export interface DescribeInput<Row> {
|
|
|
24
23
|
readonly tenantColumn: string | null;
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
26
|
+
/**
|
|
27
|
+
* The foreign keys an entity declares, resolved through the one binding resolver. Money is
|
|
28
|
+
* skipped for the reason the DDL projection drops a reference there too: one property is two
|
|
29
|
+
* physical columns, so `snake(property)` names neither of them, and a relation and a foreign-key
|
|
30
|
+
* constraint must not disagree about which columns exist.
|
|
31
|
+
*/
|
|
32
|
+
export const describeReferences = (
|
|
33
|
+
entityName: string,
|
|
34
|
+
columns: readonly (readonly [string, AnyColumn])[],
|
|
35
|
+
): readonly ReferenceDescription[] =>
|
|
36
|
+
columns.flatMap(([property, column]) => {
|
|
37
|
+
const meta = column.$meta;
|
|
38
|
+
if (meta.kind === 'money') return [];
|
|
39
|
+
const target = referenceBinding(entityName, property, meta);
|
|
40
|
+
if (target === null) return [];
|
|
41
|
+
return [
|
|
42
|
+
{
|
|
43
|
+
property,
|
|
44
|
+
column: snake(property),
|
|
45
|
+
nullable: !meta.notNull,
|
|
46
|
+
targetEntity: target.table,
|
|
47
|
+
targetProperty: target.property,
|
|
48
|
+
targetColumn: target.name,
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
});
|
|
39
52
|
|
|
40
53
|
const describeColumn = <Row>(
|
|
41
54
|
input: DescribeInput<Row>,
|
|
42
55
|
property: string,
|
|
43
56
|
meta: ColumnMeta,
|
|
57
|
+
reference: ReferenceDescription | undefined,
|
|
44
58
|
): readonly ColumnDescription[] => {
|
|
45
59
|
const physical = snake(property);
|
|
46
60
|
if (meta.kind === 'money') {
|
|
@@ -67,6 +81,18 @@ const describeColumn = <Row>(
|
|
|
67
81
|
check: currencyCheck(currency),
|
|
68
82
|
...shared,
|
|
69
83
|
},
|
|
84
|
+
// Always nullable, whatever the property's own nullability: NULL is how a row says "the
|
|
85
|
+
// currency's own minor unit", which is every amount written before the column existed and
|
|
86
|
+
// every ordinary price after it. A NOT NULL here would demand a scale on values that have
|
|
87
|
+
// none, and `0` is not that value — it means whole units.
|
|
88
|
+
{
|
|
89
|
+
property: `${property}Scale`,
|
|
90
|
+
column: `${physical}_scale`,
|
|
91
|
+
kind: 'integer',
|
|
92
|
+
check: scaleCheck(`${physical}_scale`),
|
|
93
|
+
...shared,
|
|
94
|
+
notNull: false,
|
|
95
|
+
},
|
|
70
96
|
];
|
|
71
97
|
}
|
|
72
98
|
return [
|
|
@@ -79,28 +105,47 @@ const describeColumn = <Row>(
|
|
|
79
105
|
unique: meta.unique,
|
|
80
106
|
hasDefault: meta.default !== undefined,
|
|
81
107
|
check: meta.check?.(physical) ?? null,
|
|
82
|
-
|
|
108
|
+
// Rendered from the resolved record, so the string a migration reads and the record a
|
|
109
|
+
// traversal reads can never disagree about what a `references()` points at.
|
|
110
|
+
references:
|
|
111
|
+
reference === undefined ? null : `${reference.targetEntity}.${reference.targetColumn}`,
|
|
83
112
|
},
|
|
84
113
|
];
|
|
85
114
|
};
|
|
86
115
|
|
|
87
|
-
export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescription =>
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
name:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
116
|
+
export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescription => {
|
|
117
|
+
const references = new Map(
|
|
118
|
+
describeReferences(input.name, input.columns).map((reference) => [
|
|
119
|
+
reference.property,
|
|
120
|
+
reference,
|
|
121
|
+
]),
|
|
122
|
+
);
|
|
123
|
+
return {
|
|
124
|
+
name: input.name,
|
|
125
|
+
table: input.name,
|
|
126
|
+
primaryKey: input.primaryKey.map((property) => snake(property)),
|
|
127
|
+
columns: input.columns.flatMap(([property, column]) =>
|
|
128
|
+
describeColumn(input, property, column.$meta, references.get(property)),
|
|
129
|
+
),
|
|
130
|
+
invariants: input.invariants.map((inv) => ({
|
|
131
|
+
name: inv.name,
|
|
132
|
+
kind: inv.kind,
|
|
133
|
+
message: inv.message,
|
|
134
|
+
sql: inv.sql,
|
|
135
|
+
where: inv.where ?? null,
|
|
136
|
+
})),
|
|
137
|
+
// Projected whole, never reduced to the name: the generator spells the column list from this
|
|
138
|
+
// and a name cannot be parsed back into one. See `IndexDescription`.
|
|
139
|
+
indexes: input.indexes.map((index) => ({
|
|
140
|
+
name: index.name,
|
|
141
|
+
columns: index.columns,
|
|
142
|
+
unique: index.unique,
|
|
143
|
+
where: index.where ?? null,
|
|
144
|
+
order: index.order ?? null,
|
|
145
|
+
})),
|
|
146
|
+
tags: input.tags,
|
|
147
|
+
cacheTag: input.cacheTag,
|
|
148
|
+
softDelete: input.softDelete,
|
|
149
|
+
orgScoped: input.tenantColumn !== null,
|
|
150
|
+
};
|
|
151
|
+
};
|
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
|
|
6
|
+
import { systemClock } from '@ultimat3/core';
|
|
7
|
+
import { describeValue, type StandardSchemaV1 } from '@ultimat3/schema';
|
|
7
8
|
import { bindColumn, snake } 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,7 +28,7 @@ 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> {
|
|
@@ -39,7 +40,12 @@ export interface EntityInit<C extends ColumnMap> {
|
|
|
39
40
|
readonly tenant?: keyof C & string;
|
|
40
41
|
/** Composite keys only — a single key is `uuid().primaryKey()` on the column itself. */
|
|
41
42
|
readonly primaryKey?: readonly (keyof C & string)[];
|
|
42
|
-
|
|
43
|
+
/**
|
|
44
|
+
* A callback, not an array of `(c) => …` builders: the column proxy is typed from `C`, and `C`
|
|
45
|
+
* is only fixed once for the whole `invariants` argument. Per-element builders were checked
|
|
46
|
+
* before `C` existed, which is why `c.title` used to be `ColumnExpr | undefined`.
|
|
47
|
+
*/
|
|
48
|
+
readonly invariants?: (columns: InvariantColumns<C>) => readonly InvariantDef[];
|
|
43
49
|
readonly indexes?: readonly IndexInit<C>[];
|
|
44
50
|
/** Extra cache tags this entity participates in, beyond its own. */
|
|
45
51
|
readonly tags?: readonly string[];
|
|
@@ -81,6 +87,12 @@ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
|
|
|
81
87
|
/** The CHECK/UNIQUE statements the migration emits for this entity. */
|
|
82
88
|
$migration(): string;
|
|
83
89
|
$describe(): EntityDescription;
|
|
90
|
+
/**
|
|
91
|
+
* The foreign keys this entity declares, resolved — one record per `references()`, both ends
|
|
92
|
+
* named. The relation map reads it off the registry entry; a consumer holding the entity
|
|
93
|
+
* itself reads it here. Same closure, so there is one reading of a foreign key, not two.
|
|
94
|
+
*/
|
|
95
|
+
$references(): readonly ReferenceDescription[];
|
|
84
96
|
}
|
|
85
97
|
|
|
86
98
|
export type Entity<Row, C extends ColumnMap = ColumnMap> = EntityCore<Row, C> & C;
|
|
@@ -94,7 +106,7 @@ const defaultValue = (meta: ColumnMeta): unknown => {
|
|
|
94
106
|
const declared = meta.default;
|
|
95
107
|
if (declared === undefined) return undefined;
|
|
96
108
|
if (declared.kind === 'value') return declared.value;
|
|
97
|
-
return declared.by === 'uuid-v7' ? newId() :
|
|
109
|
+
return declared.by === 'uuid-v7' ? newId() : systemClock.now();
|
|
98
110
|
};
|
|
99
111
|
|
|
100
112
|
export const entity = <const C extends ColumnMap>(
|
|
@@ -143,13 +155,14 @@ export const entity = <const C extends ColumnMap>(
|
|
|
143
155
|
return `${snake(property)}_${part}`;
|
|
144
156
|
};
|
|
145
157
|
|
|
146
|
-
const columnsExpr = invariantColumns(
|
|
158
|
+
const columnsExpr = invariantColumns<C>(
|
|
147
159
|
name,
|
|
148
160
|
entries.map(([property]) => property),
|
|
149
161
|
);
|
|
150
162
|
const partialWhere = softDelete ? `${snake(SOFT_DELETE_COLUMN)} is null` : undefined;
|
|
151
|
-
|
|
152
|
-
|
|
163
|
+
// Called once, here, so an unknown column throws while the entity is being declared.
|
|
164
|
+
const invariants: readonly Invariant<Row>[] = (init.invariants?.(columnsExpr) ?? []).map((def) =>
|
|
165
|
+
bindInvariant<Row>(def, resolve, partialWhere),
|
|
153
166
|
);
|
|
154
167
|
|
|
155
168
|
const declared: readonly IndexDef[] = [
|
|
@@ -164,6 +177,11 @@ export const entity = <const C extends ColumnMap>(
|
|
|
164
177
|
...(init.indexes ?? []).map((index) => {
|
|
165
178
|
const columns = index.on.map((property) => resolve([property]));
|
|
166
179
|
const unique = index.unique === true;
|
|
180
|
+
// `on: []` is type-legal and names nothing: the generated DDL would be `on "posts" ()`, a
|
|
181
|
+
// syntax error one migration later. Refused where it was written instead.
|
|
182
|
+
if (columns.length === 0) {
|
|
183
|
+
throw invariantViolated(name, 'index', 'an index must name at least one column');
|
|
184
|
+
}
|
|
167
185
|
const where = index.where?.(columnsExpr).toSql(resolve) ?? null;
|
|
168
186
|
if (index.where !== undefined && where === null) {
|
|
169
187
|
throw invariantViolated(
|
|
@@ -199,15 +217,25 @@ export const entity = <const C extends ColumnMap>(
|
|
|
199
217
|
softDelete,
|
|
200
218
|
tenantColumn,
|
|
201
219
|
});
|
|
220
|
+
const references = (): readonly ReferenceDescription[] => describeReferences(name, entries);
|
|
202
221
|
|
|
203
222
|
const parse = (value: unknown): Row => {
|
|
204
223
|
if (typeof value !== 'object' || value === null) {
|
|
205
|
-
|
|
224
|
+
// `describeValue`, never `String(value)`: this message is a `$parse` failure, which reaches
|
|
225
|
+
// the caller and the log line — and the whole row is the last value in the framework that
|
|
226
|
+
// may be echoed there. Same renderer as the column builders (`columns.ts`) use.
|
|
227
|
+
throw invariantViolated(name, 'row', `expected an object, got ${describeValue(value)}`);
|
|
206
228
|
}
|
|
207
229
|
const input = value as Readonly<Record<string, unknown>>;
|
|
208
230
|
const row: Record<string, unknown> = {};
|
|
209
231
|
for (const [property, column] of entries) {
|
|
210
|
-
|
|
232
|
+
// `=== undefined`, never `??`: an explicit `null` is the caller CLEARING a nullable column,
|
|
233
|
+
// and `??` read it as absence and wrote the column's declared default straight back — so
|
|
234
|
+
// `update(id, { status: null })` reported success and stored `'draft'`, with nothing on any
|
|
235
|
+
// surface to say otherwise. A present `undefined` still means absence, which is what a
|
|
236
|
+
// spread of an omitted optional key produces and is the shape every existing caller has.
|
|
237
|
+
const raw = input[property];
|
|
238
|
+
const given = raw === undefined ? defaultValue(column.$meta) : raw;
|
|
211
239
|
if (given === undefined || given === null) {
|
|
212
240
|
if (column.$meta.notNull) {
|
|
213
241
|
throw invariantViolated(name, property, 'is required and has no default');
|
|
@@ -258,9 +286,10 @@ export const entity = <const C extends ColumnMap>(
|
|
|
258
286
|
$assert: (row) => assertInvariants(name, invariants, row),
|
|
259
287
|
$migration: () => invariantsToSql(name, invariants),
|
|
260
288
|
$describe: describe,
|
|
289
|
+
$references: references,
|
|
261
290
|
};
|
|
262
291
|
|
|
263
|
-
registerEntity({ name, tableName: name, describe });
|
|
292
|
+
registerEntity({ name, tableName: name, describe, references });
|
|
264
293
|
// The columns land on the entity itself so `orgs.id` is a column reference; every framework
|
|
265
294
|
// member is `$`-prefixed, which is why a column may be called `name`.
|
|
266
295
|
return Object.assign(core, init.columns);
|