@ultimat3/entity 0.0.1 → 1.1.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/LICENSE +21 -0
- package/README.md +126 -40
- package/package.json +4 -3
- package/src/column.ts +134 -0
- package/src/columns.ts +164 -217
- package/src/cursor.ts +187 -0
- package/src/database.ts +62 -0
- package/src/describe.ts +106 -0
- package/src/entity.ts +246 -99
- package/src/errors.ts +27 -6
- package/src/expr.ts +231 -0
- package/src/index.ts +36 -20
- package/src/invariants.ts +53 -49
- package/src/pg-driver.ts +154 -0
- package/src/pg-row.ts +110 -0
- package/src/pg-sql.ts +162 -0
- package/src/plan.ts +82 -0
- package/src/query.ts +144 -0
- package/src/registry.ts +8 -5
- package/src/repo.ts +0 -0
- package/src/seed.ts +69 -0
- package/src/tenancy.ts +68 -19
- package/src/types.ts +94 -44
- package/src/view.ts +97 -0
package/src/database.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The one typed database handle. `db.posts` exists because `posts` was declared — nobody
|
|
2
|
+
// writes a repository class per entity, and nobody can reach a table that is not in the set.
|
|
3
|
+
|
|
4
|
+
import type { EntityCore } from './entity';
|
|
5
|
+
import type { Table } from './query';
|
|
6
|
+
import { tableFor } from './query';
|
|
7
|
+
import type { Repo } from './repo';
|
|
8
|
+
import { memoryRepo } from './repo';
|
|
9
|
+
|
|
10
|
+
export type EntitySet = Readonly<Record<string, EntityCore>>;
|
|
11
|
+
|
|
12
|
+
export type Database<E extends EntitySet> = {
|
|
13
|
+
readonly [K in keyof E]: Table<E[K]['$row'], E[K]['$columns']>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Where rows actually live. Postgres in production, memory in tests and before migrations. */
|
|
17
|
+
export interface Driver {
|
|
18
|
+
repo<Row>(entity: EntityCore<Row>): Repo<Row>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DatabaseOptions {
|
|
22
|
+
readonly driver?: Driver;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The default driver: correct semantics, no database, so `x dev` and every test run before
|
|
27
|
+
* the first migration exists.
|
|
28
|
+
*/
|
|
29
|
+
export const memoryDriver = (): Driver => {
|
|
30
|
+
const repos = new Map<string, unknown>();
|
|
31
|
+
return {
|
|
32
|
+
repo<Row>(entity: EntityCore<Row>): Repo<Row> {
|
|
33
|
+
const existing = repos.get(entity.$name);
|
|
34
|
+
// Keyed by entity name and only ever written from that same entity, so the row type a
|
|
35
|
+
// caller asks for is the one that was stored.
|
|
36
|
+
if (existing !== undefined) return existing as Repo<Row>;
|
|
37
|
+
const created = memoryRepo<Row>(entity);
|
|
38
|
+
repos.set(entity.$name, created);
|
|
39
|
+
return created;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
let shared: Driver | undefined;
|
|
45
|
+
|
|
46
|
+
const defaultDriver = (): Driver => {
|
|
47
|
+
shared ??= memoryDriver();
|
|
48
|
+
return shared;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const database = <E extends EntitySet>(
|
|
52
|
+
entities: E,
|
|
53
|
+
options: DatabaseOptions = {},
|
|
54
|
+
): Database<E> => {
|
|
55
|
+
const driver = options.driver ?? defaultDriver();
|
|
56
|
+
const tables: Record<string, unknown> = {};
|
|
57
|
+
for (const [key, entity] of Object.entries(entities)) {
|
|
58
|
+
tables[key] = tableFor(entity, driver.repo(entity));
|
|
59
|
+
}
|
|
60
|
+
// Built key by key from `entities`, so each table is the one `Database<E>` names.
|
|
61
|
+
return tables as Database<E>;
|
|
62
|
+
};
|
package/src/describe.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// The projection an entity hands the rest of the toolchain: `x.manifest.json`, the migration
|
|
2
|
+
// generator, the admin dashboard and `x entities`. It is a plain data snapshot on purpose —
|
|
3
|
+
// a consumer must be able to read the whole domain without importing a single schema module.
|
|
4
|
+
//
|
|
5
|
+
// Money is the one place the projection is not one-to-one: one property becomes the two
|
|
6
|
+
// physical columns that back it.
|
|
7
|
+
|
|
8
|
+
import { bindingOf, snake } from './column';
|
|
9
|
+
import { currencyCheck } from './columns';
|
|
10
|
+
import { invariantViolated } from './errors';
|
|
11
|
+
import type { Invariant } from './invariants';
|
|
12
|
+
import type { ColumnDescription, EntityDescription } from './registry';
|
|
13
|
+
import type { AnyColumn, ColumnMeta, IndexDef } from './types';
|
|
14
|
+
|
|
15
|
+
export interface DescribeInput<Row> {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly columns: readonly (readonly [string, AnyColumn])[];
|
|
18
|
+
readonly primaryKey: readonly string[];
|
|
19
|
+
readonly invariants: readonly Invariant<Row>[];
|
|
20
|
+
readonly indexes: readonly IndexDef[];
|
|
21
|
+
readonly tags: readonly string[];
|
|
22
|
+
readonly cacheTag: string;
|
|
23
|
+
readonly softDelete: boolean;
|
|
24
|
+
readonly tenantColumn: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const referenceOf = (entityName: string, property: string, meta: ColumnMeta): string | null => {
|
|
28
|
+
if (meta.references === undefined) return null;
|
|
29
|
+
const target = bindingOf(meta.references());
|
|
30
|
+
if (target === undefined) {
|
|
31
|
+
throw invariantViolated(
|
|
32
|
+
entityName,
|
|
33
|
+
property,
|
|
34
|
+
'references a column that belongs to no entity — pass a column of an entity() result',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return `${target.table}.${target.name}`;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const describeColumn = <Row>(
|
|
41
|
+
input: DescribeInput<Row>,
|
|
42
|
+
property: string,
|
|
43
|
+
meta: ColumnMeta,
|
|
44
|
+
): readonly ColumnDescription[] => {
|
|
45
|
+
const physical = snake(property);
|
|
46
|
+
if (meta.kind === 'money') {
|
|
47
|
+
const currency = `${physical}_currency`;
|
|
48
|
+
const shared = {
|
|
49
|
+
notNull: meta.notNull,
|
|
50
|
+
primaryKey: false,
|
|
51
|
+
unique: false,
|
|
52
|
+
hasDefault: false,
|
|
53
|
+
references: null,
|
|
54
|
+
};
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
property: `${property}Minor`,
|
|
58
|
+
column: `${physical}_minor`,
|
|
59
|
+
kind: 'bigint',
|
|
60
|
+
check: null,
|
|
61
|
+
...shared,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
property: `${property}Currency`,
|
|
65
|
+
column: currency,
|
|
66
|
+
kind: 'char',
|
|
67
|
+
check: currencyCheck(currency),
|
|
68
|
+
...shared,
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
return [
|
|
73
|
+
{
|
|
74
|
+
property,
|
|
75
|
+
column: physical,
|
|
76
|
+
kind: meta.kind,
|
|
77
|
+
notNull: meta.notNull,
|
|
78
|
+
primaryKey: meta.primaryKey || input.primaryKey.includes(property),
|
|
79
|
+
unique: meta.unique,
|
|
80
|
+
hasDefault: meta.default !== undefined,
|
|
81
|
+
check: meta.check?.(physical) ?? null,
|
|
82
|
+
references: referenceOf(input.name, property, meta),
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescription => ({
|
|
88
|
+
name: input.name,
|
|
89
|
+
table: input.name,
|
|
90
|
+
primaryKey: input.primaryKey.map((property) => snake(property)),
|
|
91
|
+
columns: input.columns.flatMap(([property, column]) =>
|
|
92
|
+
describeColumn(input, property, column.$meta),
|
|
93
|
+
),
|
|
94
|
+
invariants: input.invariants.map((inv) => ({
|
|
95
|
+
name: inv.name,
|
|
96
|
+
kind: inv.kind,
|
|
97
|
+
message: inv.message,
|
|
98
|
+
sql: inv.sql,
|
|
99
|
+
where: inv.where ?? null,
|
|
100
|
+
})),
|
|
101
|
+
indexes: input.indexes.map((index) => index.name),
|
|
102
|
+
tags: input.tags,
|
|
103
|
+
cacheTag: input.cacheTag,
|
|
104
|
+
softDelete: input.softDelete,
|
|
105
|
+
orgScoped: input.tenantColumn !== null,
|
|
106
|
+
});
|
package/src/entity.ts
CHANGED
|
@@ -1,120 +1,267 @@
|
|
|
1
|
-
// `entity()` is the first primitive
|
|
2
|
-
//
|
|
3
|
-
// manifest) is
|
|
1
|
+
// `entity(name, { columns })` is the first primitive. The row type is DERIVED from the columns —
|
|
2
|
+
// there is no second declaration of the same shape to keep in sync — and everything downstream
|
|
3
|
+
// (the typed db handle, migrations, cache tags, the admin UI, the manifest) is projected from
|
|
4
|
+
// this one call.
|
|
5
|
+
|
|
4
6
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
7
|
+
import { bindColumn, snake } from './column';
|
|
8
|
+
import { newId } from './columns';
|
|
9
|
+
import { describeEntity } from './describe';
|
|
5
10
|
import { invariantViolated } from './errors';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
import type { Expr, InvariantColumns, Resolve } from './expr';
|
|
12
|
+
import { invariantColumns } from './expr';
|
|
13
|
+
import type { Invariant, InvariantDef } from './invariants';
|
|
14
|
+
import { assertInvariants, bindInvariant, invariantsToSql } from './invariants';
|
|
15
|
+
import type { EntityDescription } from './registry';
|
|
16
|
+
import { registerEntity } from './registry';
|
|
17
|
+
import { resolveTenantColumn } from './tenancy';
|
|
18
|
+
import type { AnyColumn, ColumnMap, ColumnMeta, IndexDef, RowOf } from './types';
|
|
19
|
+
import type { EntityView } from './view';
|
|
20
|
+
import { viewFor } from './view';
|
|
21
|
+
|
|
22
|
+
/** Presence of this column is what makes an entity soft-deletable — not a flag. */
|
|
23
|
+
export const SOFT_DELETE_COLUMN = 'deletedAt';
|
|
24
|
+
|
|
25
|
+
export interface IndexInit<C extends ColumnMap> {
|
|
26
|
+
readonly on: readonly (keyof C & string)[];
|
|
27
|
+
readonly order?: 'asc' | 'desc';
|
|
28
|
+
readonly unique?: boolean;
|
|
29
|
+
/** Partial index predicate, written in the same language as an invariant. */
|
|
30
|
+
readonly where?: (columns: InvariantColumns) => Expr;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface EntityInit<C extends ColumnMap> {
|
|
34
|
+
readonly columns: C;
|
|
35
|
+
/**
|
|
36
|
+
* The tenant column, said out loud. Omitted, it is inferred from `.tenant()` or a column named
|
|
37
|
+
* `orgId`, so silence never means unscoped.
|
|
38
|
+
*/
|
|
39
|
+
readonly tenant?: keyof C & string;
|
|
40
|
+
/** Composite keys only — a single key is `uuid().primaryKey()` on the column itself. */
|
|
41
|
+
readonly primaryKey?: readonly (keyof C & string)[];
|
|
42
|
+
readonly invariants?: readonly InvariantDef[];
|
|
43
|
+
readonly indexes?: readonly IndexInit<C>[];
|
|
19
44
|
/** Extra cache tags this entity participates in, beyond its own. */
|
|
20
45
|
readonly tags?: readonly string[];
|
|
21
|
-
/** Defaults to the presence of a `deletedAt` column. */
|
|
22
|
-
readonly softDelete?: boolean;
|
|
23
46
|
}
|
|
24
47
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
48
|
+
/**
|
|
49
|
+
* The row-shaped half of an entity. Consumers that must name "some entity" without naming its
|
|
50
|
+
* row use `EntityCore`; `Entity` adds the columns themselves, so `orgs.id` is a column
|
|
51
|
+
* reference and `typeof orgs.$row` is the derived row type.
|
|
52
|
+
*/
|
|
53
|
+
export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
|
|
54
|
+
readonly $name: string;
|
|
55
|
+
readonly $table: string;
|
|
56
|
+
readonly $columns: C;
|
|
57
|
+
readonly $primaryKey: readonly string[];
|
|
58
|
+
readonly $indexes: readonly IndexDef[];
|
|
59
|
+
readonly $invariants: readonly Invariant<Row>[];
|
|
60
|
+
readonly $tags: readonly string[];
|
|
31
61
|
/** `entity:<name>`. `@ultimat3/cache` invalidates by this string. */
|
|
32
|
-
readonly cacheTag: string;
|
|
33
|
-
readonly softDelete: boolean;
|
|
34
|
-
|
|
62
|
+
readonly $cacheTag: string;
|
|
63
|
+
readonly $softDelete: boolean;
|
|
64
|
+
/** Property key of the tenant column, or `null`. Presence is what turns tenancy on. */
|
|
65
|
+
readonly $tenantColumn: string | null;
|
|
66
|
+
/** Phantom: `type Post = typeof posts.$row`. Reading it at runtime throws. */
|
|
67
|
+
readonly $row: Row;
|
|
68
|
+
/** The Standard Schema the columns already describe — forms and actions hand input to it. */
|
|
69
|
+
readonly $schema: StandardSchemaV1<unknown, Row>;
|
|
35
70
|
/** `entity:<name>:<id>` — row-level invalidation for live queries. */
|
|
36
|
-
tagFor(id: string): string;
|
|
37
|
-
/**
|
|
38
|
-
parse(value: unknown):
|
|
71
|
+
$tagFor(id: string): string;
|
|
72
|
+
/** Fills declared defaults, then validates every column. Throws on a bad value. */
|
|
73
|
+
$parse(value: unknown): Row;
|
|
74
|
+
/**
|
|
75
|
+
* `const PostView = posts.$view(['id', 'title'])` — the projection an action names as its
|
|
76
|
+
* `output`. An unknown key is a compile error, and a declaration error for a JS caller.
|
|
77
|
+
*/
|
|
78
|
+
$view<K extends keyof Row & string>(keys: readonly K[]): EntityView<Row, K>;
|
|
39
79
|
/** Runs every invariant. Called by the repository on insert and update. */
|
|
40
|
-
assert(row:
|
|
80
|
+
$assert(row: Row): void;
|
|
41
81
|
/** The CHECK/UNIQUE statements the migration emits for this entity. */
|
|
42
|
-
migration(): string;
|
|
43
|
-
describe(): EntityDescription;
|
|
82
|
+
$migration(): string;
|
|
83
|
+
$describe(): EntityDescription;
|
|
44
84
|
}
|
|
45
85
|
|
|
46
|
-
|
|
47
|
-
readonly value?: T;
|
|
48
|
-
readonly issues?: readonly { readonly message: string }[] | undefined;
|
|
49
|
-
}
|
|
86
|
+
export type Entity<Row, C extends ColumnMap = ColumnMap> = EntityCore<Row, C> & C;
|
|
50
87
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return
|
|
88
|
+
const MONEY_PARTS = new Set(['minor', 'currency']);
|
|
89
|
+
|
|
90
|
+
const indexName = (table: string, columns: readonly string[], unique: boolean): string =>
|
|
91
|
+
`${table}_${columns.join('_')}_${unique ? 'key' : 'idx'}`;
|
|
92
|
+
|
|
93
|
+
const defaultValue = (meta: ColumnMeta): unknown => {
|
|
94
|
+
const declared = meta.default;
|
|
95
|
+
if (declared === undefined) return undefined;
|
|
96
|
+
if (declared.kind === 'value') return declared.value;
|
|
97
|
+
return declared.by === 'uuid-v7' ? newId() : new Date();
|
|
61
98
|
};
|
|
62
99
|
|
|
63
|
-
export const entity = <
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
100
|
+
export const entity = <const C extends ColumnMap>(
|
|
101
|
+
name: string,
|
|
102
|
+
init: EntityInit<C>,
|
|
103
|
+
): Entity<RowOf<C>, C> => {
|
|
104
|
+
type Row = RowOf<C>;
|
|
105
|
+
const entries: readonly (readonly [string, AnyColumn])[] = Object.entries(init.columns);
|
|
106
|
+
for (const [property, column] of entries) bindColumn(column, name, property);
|
|
107
|
+
|
|
68
108
|
const cacheTag = `entity:${name}`;
|
|
109
|
+
const softDelete = Object.hasOwn(init.columns, SOFT_DELETE_COLUMN);
|
|
110
|
+
const tenantColumn = resolveTenantColumn(name, init.columns, init.tenant);
|
|
69
111
|
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
112
|
+
const primaryKey =
|
|
113
|
+
init.primaryKey ?? entries.filter(([, column]) => column.$meta.primaryKey).map(([key]) => key);
|
|
114
|
+
if (primaryKey.length === 0) {
|
|
115
|
+
throw invariantViolated(
|
|
116
|
+
name,
|
|
117
|
+
'primary-key',
|
|
118
|
+
'no primary key: mark a column .primaryKey() or pass primaryKey: [...] for a composite one',
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Property path -> physical name. The one place `orgId` becomes `org_id`.
|
|
123
|
+
const resolve: Resolve = (path) => {
|
|
124
|
+
const [property, part] = path;
|
|
125
|
+
const column = property === undefined ? undefined : init.columns[property];
|
|
126
|
+
if (property === undefined || column === undefined) {
|
|
127
|
+
throw invariantViolated(name, 'invariant', `no column "${String(property)}"`);
|
|
128
|
+
}
|
|
129
|
+
const isMoney = column.$meta.kind === 'money';
|
|
130
|
+
if (part === undefined) {
|
|
131
|
+
if (isMoney) {
|
|
132
|
+
throw invariantViolated(
|
|
133
|
+
name,
|
|
134
|
+
property,
|
|
135
|
+
`${property} is money: name ${property}.minor or ${property}.currency`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return snake(property);
|
|
139
|
+
}
|
|
140
|
+
if (!isMoney || !MONEY_PARTS.has(part)) {
|
|
141
|
+
throw invariantViolated(name, property, `${property} has no part "${part}"`);
|
|
142
|
+
}
|
|
143
|
+
return `${snake(property)}_${part}`;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const columnsExpr = invariantColumns(
|
|
103
147
|
name,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
148
|
+
entries.map(([property]) => property),
|
|
149
|
+
);
|
|
150
|
+
const partialWhere = softDelete ? `${snake(SOFT_DELETE_COLUMN)} is null` : undefined;
|
|
151
|
+
const invariants: readonly Invariant<Row>[] = (init.invariants ?? []).map((def) =>
|
|
152
|
+
bindInvariant<Row>(def, columnsExpr, resolve, partialWhere),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const declared: readonly IndexDef[] = [
|
|
156
|
+
...entries.flatMap(([property, column]) => {
|
|
157
|
+
const meta = column.$meta;
|
|
158
|
+
if (!meta.unique && !meta.index) return [];
|
|
159
|
+
const physical = [meta.kind === 'money' ? `${snake(property)}_minor` : snake(property)];
|
|
160
|
+
return [
|
|
161
|
+
{ name: indexName(name, physical, meta.unique), columns: physical, unique: meta.unique },
|
|
162
|
+
];
|
|
163
|
+
}),
|
|
164
|
+
...(init.indexes ?? []).map((index) => {
|
|
165
|
+
const columns = index.on.map((property) => resolve([property]));
|
|
166
|
+
const unique = index.unique === true;
|
|
167
|
+
const where = index.where?.(columnsExpr).toSql(resolve) ?? null;
|
|
168
|
+
if (index.where !== undefined && where === null) {
|
|
169
|
+
throw invariantViolated(
|
|
170
|
+
name,
|
|
171
|
+
'index',
|
|
172
|
+
'a partial index predicate must be expressible in SQL; a JS predicate cannot be one',
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
name: indexName(name, columns, unique),
|
|
177
|
+
columns,
|
|
178
|
+
unique,
|
|
179
|
+
...(index.order === undefined ? {} : { order: index.order }),
|
|
180
|
+
...(where === null ? {} : { where }),
|
|
181
|
+
};
|
|
182
|
+
}),
|
|
183
|
+
];
|
|
184
|
+
// A foreign key already indexes its column; naming it again in `indexes` is not two indexes.
|
|
185
|
+
const indexes: readonly IndexDef[] = declared.filter(
|
|
186
|
+
(index, position) => declared.findIndex((other) => other.name === index.name) === position,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const tags = [cacheTag, ...(init.tags ?? [])];
|
|
190
|
+
const describe = (): EntityDescription =>
|
|
191
|
+
describeEntity({
|
|
192
|
+
name,
|
|
193
|
+
columns: entries,
|
|
194
|
+
primaryKey,
|
|
195
|
+
invariants,
|
|
196
|
+
indexes,
|
|
197
|
+
tags,
|
|
198
|
+
cacheTag,
|
|
199
|
+
softDelete,
|
|
200
|
+
tenantColumn,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const parse = (value: unknown): Row => {
|
|
204
|
+
if (typeof value !== 'object' || value === null) {
|
|
205
|
+
throw invariantViolated(name, 'row', `expected an object, got ${String(value)}`);
|
|
206
|
+
}
|
|
207
|
+
const input = value as Readonly<Record<string, unknown>>;
|
|
208
|
+
const row: Record<string, unknown> = {};
|
|
209
|
+
for (const [property, column] of entries) {
|
|
210
|
+
const given = input[property] ?? defaultValue(column.$meta);
|
|
211
|
+
if (given === undefined || given === null) {
|
|
212
|
+
if (column.$meta.notNull) {
|
|
213
|
+
throw invariantViolated(name, property, 'is required and has no default');
|
|
214
|
+
}
|
|
215
|
+
row[property] = null;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
row[property] = column.$parse(given);
|
|
219
|
+
}
|
|
220
|
+
// Every property was validated by its own column above, so the shape is the derived row.
|
|
221
|
+
return row as Row;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const core: EntityCore<Row, C> = {
|
|
225
|
+
$name: name,
|
|
226
|
+
$table: name,
|
|
227
|
+
$columns: init.columns,
|
|
228
|
+
$primaryKey: primaryKey,
|
|
229
|
+
$indexes: indexes,
|
|
230
|
+
$invariants: invariants,
|
|
231
|
+
$tags: tags,
|
|
232
|
+
$cacheTag: cacheTag,
|
|
233
|
+
$softDelete: softDelete,
|
|
234
|
+
$tenantColumn: tenantColumn,
|
|
235
|
+
$schema: {
|
|
236
|
+
'~standard': {
|
|
237
|
+
version: 1,
|
|
238
|
+
vendor: 'ultimate',
|
|
239
|
+
validate: (value) => {
|
|
240
|
+
try {
|
|
241
|
+
return { value: parse(value) };
|
|
242
|
+
} catch (error) {
|
|
243
|
+
return {
|
|
244
|
+
issues: [{ message: error instanceof Error ? error.message : String(error) }],
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
get $row(): Row {
|
|
251
|
+
// Type-only. Reading it means someone expected a value where a type was meant.
|
|
252
|
+
throw invariantViolated(name, '$row', '$row is a type, not a value — use typeof x.$row');
|
|
253
|
+
},
|
|
254
|
+
$tagFor: (id) => `${cacheTag}:${id}`,
|
|
255
|
+
$parse: parse,
|
|
256
|
+
$view: <K extends keyof Row & string>(keys: readonly K[]) =>
|
|
257
|
+
viewFor<Row, K>(name, init.columns, keys),
|
|
258
|
+
$assert: (row) => assertInvariants(name, invariants, row),
|
|
259
|
+
$migration: () => invariantsToSql(name, invariants),
|
|
260
|
+
$describe: describe,
|
|
116
261
|
};
|
|
117
262
|
|
|
118
|
-
registerEntity({ name, tableName:
|
|
119
|
-
|
|
263
|
+
registerEntity({ name, tableName: name, describe });
|
|
264
|
+
// The columns land on the entity itself so `orgs.id` is a column reference; every framework
|
|
265
|
+
// member is `$`-prefixed, which is why a column may be called `name`.
|
|
266
|
+
return Object.assign(core, init.columns);
|
|
120
267
|
};
|
package/src/errors.ts
CHANGED
|
@@ -1,27 +1,49 @@
|
|
|
1
1
|
// The entity layer's stable error codes. Each factory produces the exact command
|
|
2
2
|
// that fixes the situation — `X_DB_DRIFT` is the flagship: it names the table, the
|
|
3
3
|
// column and the generator invocation.
|
|
4
|
-
import { UltimateError } from '@ultimat3/core';
|
|
4
|
+
import { registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
/** Codes this package declares and owns. */
|
|
7
|
+
export const ENTITY_OWNED_ERROR_CODES = [
|
|
7
8
|
'X_ENTITY_DUPLICATE',
|
|
8
9
|
'X_INVARIANT_VIOLATED',
|
|
9
10
|
'X_TENANCY_UNSCOPED',
|
|
10
|
-
'X_DB_DRIFT',
|
|
11
11
|
'X_NOT_FOUND',
|
|
12
12
|
] as const;
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* `X_DB_DRIFT` is `@ultimat3/db`'s — drift is a fact about migrations, and this package imports db
|
|
16
|
+
* rather than the other way round. `dbDrift()` below throws it; nothing here titles it, because a
|
|
17
|
+
* second copy of the title is what lets the two packages disagree about what the code means.
|
|
18
|
+
*/
|
|
19
|
+
export const ENTITY_BORROWED_ERROR_CODES = ['X_DB_DRIFT'] as const;
|
|
20
|
+
|
|
21
|
+
/** Every code entity can throw: the ones it owns plus the one it borrows. */
|
|
22
|
+
export const ENTITY_ERROR_CODES = [
|
|
23
|
+
...ENTITY_OWNED_ERROR_CODES,
|
|
24
|
+
...ENTITY_BORROWED_ERROR_CODES,
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export type EntityOwnedErrorCode = (typeof ENTITY_OWNED_ERROR_CODES)[number];
|
|
14
28
|
export type EntityErrorCode = (typeof ENTITY_ERROR_CODES)[number];
|
|
15
29
|
|
|
16
|
-
export const ENTITY_ERROR_TITLES: Readonly<Record<
|
|
30
|
+
export const ENTITY_ERROR_TITLES: Readonly<Record<EntityOwnedErrorCode, string>> = {
|
|
17
31
|
X_ENTITY_DUPLICATE: 'two entities claim the same name',
|
|
18
32
|
X_INVARIANT_VIOLATED: 'a domain invariant rejected this row',
|
|
19
33
|
X_TENANCY_UNSCOPED: 'a tenant-scoped query has no org predicate',
|
|
20
|
-
X_DB_DRIFT: 'schema differs from migrations',
|
|
21
34
|
X_NOT_FOUND: 'no row for that id',
|
|
22
35
|
};
|
|
23
36
|
|
|
37
|
+
// Registered at module load, unconditionally, in one call. Without this the registry humanises the
|
|
38
|
+
// code and every surface renders a title this package never wrote; with a presence guard, a second
|
|
39
|
+
// package claiming one of these codes would silently win instead of throwing X_ERROR_CODE_DUPLICATE.
|
|
40
|
+
registerErrorCodes(
|
|
41
|
+
Object.fromEntries(Object.entries(ENTITY_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
42
|
+
);
|
|
43
|
+
|
|
24
44
|
export class EntityError extends UltimateError {
|
|
45
|
+
override readonly name = 'EntityError';
|
|
46
|
+
|
|
25
47
|
constructor(init: { code: EntityErrorCode; cause: string; fix: string }) {
|
|
26
48
|
super({
|
|
27
49
|
code: init.code,
|
|
@@ -29,7 +51,6 @@ export class EntityError extends UltimateError {
|
|
|
29
51
|
fix: init.fix,
|
|
30
52
|
docs: `https://ultimate.dev/errors/${init.code}`,
|
|
31
53
|
});
|
|
32
|
-
this.name = 'EntityError';
|
|
33
54
|
}
|
|
34
55
|
}
|
|
35
56
|
|