@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/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
- const defaultDriver = (): Driver => {
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 { bindingOf, snake } from './column';
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
- 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
- );
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
- return `${target.table}.${target.name}`;
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 = snake(property);
83
+ const physical = columnName(property, meta);
46
84
  if (meta.kind === 'money') {
47
- const currency = `${physical}_currency`;
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: `${physical}_minor`,
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.kind,
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
- references: referenceOf(input.name, property, meta),
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
- 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
- });
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 type { StandardSchemaV1 } from '@ultimat3/schema';
7
- import { bindColumn, snake } from './column';
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
- readonly invariants?: readonly InvariantDef[];
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() : new Date();
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 snake(property);
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
- return `${snake(property)}_${part}`;
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
- 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),
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 = [meta.kind === 'money' ? `${snake(property)}_minor` : snake(property)];
185
+ const physical = [
186
+ meta.kind === 'money' ? moneyColumns(property, meta).minor : columnName(property, meta),
187
+ ];
160
188
  return [
161
- { name: indexName(name, physical, meta.unique), columns: physical, unique: meta.unique },
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(name, columns, unique),
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
- throw invariantViolated(name, 'row', `expected an object, got ${String(value)}`);
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
- const given = input[property] ?? defaultValue(column.$meta);
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: name,
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: name, describe });
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);