@ultimat3/entity 1.2.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/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 { bindingOf, snake } from './column';
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
- 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
- };
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
- references: referenceOf(input.name, property, meta),
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
- 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
- });
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 type { StandardSchemaV1 } from '@ultimat3/schema';
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
- readonly invariants?: readonly InvariantDef[];
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() : new Date();
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
- const invariants: readonly Invariant<Row>[] = (init.invariants ?? []).map((def) =>
152
- bindInvariant<Row>(def, columnsExpr, resolve, partialWhere),
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
- throw invariantViolated(name, 'row', `expected an object, got ${String(value)}`);
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
- const given = input[property] ?? defaultValue(column.$meta);
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);