@ultimat3/entity 2.0.0 → 4.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.
@@ -0,0 +1,205 @@
1
+ // The column builders an EXISTING schema needs. `columns.ts` holds the opinionated set — one way
2
+ // to store an id, an instant, money — and every one of them is a decision this framework made for
3
+ // a table it was going to create. These are the shapes a table already has: a `jsonb` payload, a
4
+ // `numeric(18,8)` rate, a calendar `date`, an `int8` id past 2^53, a `bytea` blob, a `text[]`.
5
+ //
6
+ // Two rules run through all of them. A value crossing the driver is parsed by the column that
7
+ // declared it, because the two drivers disagree about what they hand back (`int8` is a string from
8
+ // Bun's `sql` and a `bigint` from PGlite — measured); and nothing here is an `any` hole, so `json()`
9
+ // takes a schema and validates through it.
10
+
11
+ import { describeValue, formatIssues, type StandardSchemaV1, validate } from '@ultimat3/schema';
12
+ import { isPlainDate, type PlainDate, plainDateUtc } from '@ultimat3/time';
13
+ import { column } from './column';
14
+ import { invariantViolated } from './errors';
15
+ import type { AnyColumn, Column, ColumnMeta } from './types';
16
+
17
+ const reject = (rule: string, detail: string): never => {
18
+ throw invariantViolated('column', rule, detail);
19
+ };
20
+
21
+ /** The rejected value as its SHAPE, never its content — `columns.ts` explains why at length. */
22
+ const got = (value: unknown): string => `got ${describeValue(value)}`;
23
+
24
+ /**
25
+ * A `jsonb` column whose CONTENTS are validated. The schema is required and that is the point: a
26
+ * `json()` returning `unknown` is the `any` hole this framework forbids, and a column is the worst
27
+ * place for one — the value arrives from the DATABASE as often as from a caller, so the row type
28
+ * would be a claim nothing ever checked.
29
+ *
30
+ * The object is bound as an object, never as a string: a JSON string parameter is stored as a JSON
31
+ * *string* by Postgres (measured — `'{"a":1}'` comes back as the text, not the object), so
32
+ * stringifying here would change the value's type in the table.
33
+ */
34
+ export const json = <T>(schema: StandardSchemaV1<unknown, T>): Column<T> =>
35
+ column<T>('jsonb', (value) => {
36
+ const result = validate(schema, value);
37
+ if (result.issues === undefined) return result.value;
38
+ // The ISSUES, never the value: `formatIssues` renders path + message, and a column rejection
39
+ // reaches the caller and the log line where a value has no key left to redact.
40
+ return reject(
41
+ 'json',
42
+ `does not match the column's schema — ${formatIssues(result.issues).join('; ')}`,
43
+ );
44
+ });
45
+
46
+ const DIGITS = /^-?\d+$/;
47
+
48
+ /**
49
+ * `bigint`, whose row type is a decimal STRING. Neither alternative survives contact:
50
+ * a JS `bigint` is what `JSON.stringify` throws on — the reason `money.minor` is a `number` — and
51
+ * a `number` silently loses digits past 2^53, which is precisely the range a legacy `int8` key or
52
+ * a snowflake id lives in. A string holds every value the column can and crosses every wire this
53
+ * framework generates.
54
+ *
55
+ * Both driver spellings arrive here and leave as one: Bun's `sql` returns `int8` as a string and
56
+ * PGlite returns a `bigint`, and a row that meant two things by driver is the drift this package
57
+ * exists to refuse.
58
+ */
59
+ export const bigint = (): Column<string> =>
60
+ column<string>('bigint', (value) => {
61
+ if (typeof value === 'bigint') return value.toString();
62
+ if (typeof value === 'number') {
63
+ return Number.isSafeInteger(value)
64
+ ? String(value)
65
+ : reject(
66
+ 'bigint',
67
+ `${String(value)} is past ±2^53, where a JS number is no longer exact — pass the digits as a string`,
68
+ );
69
+ }
70
+ return typeof value === 'string' && DIGITS.test(value)
71
+ ? value
72
+ : reject('bigint', `expected whole digits, ${got(value)}`);
73
+ });
74
+
75
+ export interface DecimalOptions {
76
+ /** Emits `numeric(precision, scale)`. Both, or neither — a bare `numeric` is unbounded. */
77
+ readonly precision?: number;
78
+ readonly scale?: number;
79
+ }
80
+
81
+ /**
82
+ * `numeric(p, s)`, whose row type is the exact decimal STRING Postgres returns. Money is the one
83
+ * decimal this framework has an opinion about (integer minor units plus a currency, always); this
84
+ * is every other one — a tax rate, an FX rate, a measurement — where the precision is the column's
85
+ * and no JS number holds it.
86
+ *
87
+ * It is deliberately NOT arithmetic-friendly. A framework that handed back a float here would be
88
+ * the float-money bug with a different name, and one that shipped a decimal type would be shipping
89
+ * a numeric tower: the honest thing a driver already does is give you the digits.
90
+ */
91
+ export const decimal = (options: DecimalOptions = {}): Column<string> => {
92
+ const { precision, scale } = options;
93
+ if ((precision === undefined) !== (scale === undefined)) {
94
+ reject('numeric', 'precision and scale are declared together — numeric(18, 8), or neither');
95
+ }
96
+ if (precision !== undefined && scale !== undefined) {
97
+ if (!Number.isInteger(precision) || precision < 1 || precision > 1000) {
98
+ reject('numeric', `precision must be 1..1000, ${got(precision)}`);
99
+ }
100
+ if (!Number.isInteger(scale) || scale < 0 || scale > precision) {
101
+ reject('numeric', `scale must be 0..precision, ${got(scale)}`);
102
+ }
103
+ }
104
+ const shape = /^-?\d+(\.\d+)?$/;
105
+ return column<string>(
106
+ 'numeric',
107
+ (value) => {
108
+ const text = typeof value === 'number' ? decimalOfNumber(value) : value;
109
+ if (typeof text !== 'string' || !shape.test(text)) {
110
+ return reject('numeric', `expected a decimal number, ${got(value)}`);
111
+ }
112
+ const digits = text.replace('-', '').split('.');
113
+ const fraction = digits[1]?.length ?? 0;
114
+ if (scale !== undefined && fraction > scale) {
115
+ return reject(
116
+ 'numeric',
117
+ `${text} has ${fraction} decimal places and the column stores ${scale} — Postgres would round it, silently`,
118
+ );
119
+ }
120
+ if (
121
+ precision !== undefined &&
122
+ (digits[0] ?? '').replace(/^0+(?=\d)/, '').length > precision - (scale ?? 0)
123
+ ) {
124
+ return reject('numeric', `${text} does not fit numeric(${precision}, ${scale ?? 0})`);
125
+ }
126
+ return text;
127
+ },
128
+ precision === undefined || scale === undefined ? {} : { precision, numericScale: scale },
129
+ );
130
+ };
131
+
132
+ /**
133
+ * A float is accepted only where it is exactly representable as written — anything else is the
134
+ * rounding this column exists to refuse, and refusing it at the write is the only place the caller
135
+ * still knows what they meant.
136
+ */
137
+ const decimalOfNumber = (value: number): string =>
138
+ Number.isFinite(value) ? String(value) : 'not-a-number';
139
+
140
+ /**
141
+ * A `date`: a calendar date, with no time and therefore no zone. The row type is
142
+ * `@ultimat3/time`'s `PlainDate`, which is why this is not `timestamp()` with the clock zeroed —
143
+ * `effective_on` is the date a rate applies, and stored as an instant it is a different date on
144
+ * either side of midnight for half the planet.
145
+ *
146
+ * A driver hands a `date` column back as a `Date` at UTC midnight (measured: Bun's `sql` and
147
+ * PGlite both), so that is the one conversion here, by its own name. The value written is the
148
+ * string: binding a `Date` to a `date` parameter fails outright on a server whose client zone has
149
+ * no name Postgres knows (`time zone "gmt-0500" not recognized`, measured on 17.10).
150
+ */
151
+ export const date = (): Column<PlainDate> =>
152
+ column<PlainDate>('date', (value) => {
153
+ if (value instanceof Date) {
154
+ return Number.isNaN(value.getTime())
155
+ ? reject('date', `expected a calendar date, ${got(value)}`)
156
+ : plainDateUtc(value);
157
+ }
158
+ return isPlainDate(value)
159
+ ? value
160
+ : reject('date', `expected a YYYY-MM-DD calendar date, ${got(value)}`);
161
+ });
162
+
163
+ /**
164
+ * `bytea`. The row type is a plain `Uint8Array` and both drivers are normalised into one: Bun's
165
+ * `sql` returns a `Buffer`, PGlite a `Uint8Array`, and the two serialise differently
166
+ * (`{"type":"Buffer","data":[…]}` against `{"0":…}`) — so a row read through one driver and the
167
+ * same row read through the other would not be the same object on any wire.
168
+ */
169
+ export const bytes = (): Column<Uint8Array> =>
170
+ column<Uint8Array>('bytea', (value) => {
171
+ if (!(value instanceof Uint8Array)) {
172
+ return reject('bytea', `expected bytes, ${got(value)}`);
173
+ }
174
+ // Already the plain form: the overwhelmingly common case, and it costs one prototype read.
175
+ return Object.getPrototypeOf(value) === Uint8Array.prototype ? value : new Uint8Array(value);
176
+ });
177
+
178
+ /**
179
+ * `<element>[]` — a Postgres array of a SCALAR column. The element is a column, so its own
180
+ * `$parse` decides every member: `arrayOf(text({ max: 40 }))` refuses a 41-character tag exactly
181
+ * where a `text()` column would.
182
+ *
183
+ * Money and arrays of arrays are refused rather than approximated: money is three physical columns
184
+ * and cannot be one array element, and a nested array has no unambiguous literal form.
185
+ */
186
+ export const arrayOf = <T>(element: Column<T>): Column<readonly T[]> => {
187
+ const kind = element.$meta.kind;
188
+ if (kind === 'money' || kind === 'array') {
189
+ reject(
190
+ 'array',
191
+ `arrayOf(${kind}) has no single column behind it — an array element is one scalar column`,
192
+ );
193
+ }
194
+ return column<readonly T[]>(
195
+ 'array',
196
+ (value) => {
197
+ if (!Array.isArray(value)) return reject('array', `expected an array, ${got(value)}`);
198
+ return value.map((member) => element.$parse(member));
199
+ },
200
+ { element: element as AnyColumn },
201
+ );
202
+ };
203
+
204
+ /** The element's own kind, for the projections that need the physical type. */
205
+ export const elementMeta = (meta: ColumnMeta): ColumnMeta | undefined => meta.element?.$meta;
package/src/columns.ts CHANGED
@@ -10,11 +10,20 @@ import {
10
10
  isMoneyScale,
11
11
  MAX_MONEY_SCALE,
12
12
  } from '@ultimat3/schema';
13
- import { BARE, column, GENERATED_UUID, makeColumn, makeTimestamp } from './column';
13
+ import {
14
+ assertColumnName,
15
+ BARE,
16
+ column,
17
+ GENERATED_UUID,
18
+ makeColumn,
19
+ makeTimestamp,
20
+ } from './column';
14
21
  import { invariantViolated } from './errors';
15
22
  import type {
16
23
  Column,
17
24
  ColumnMap,
25
+ ColumnMeta,
26
+ MoneyColumnNames,
18
27
  MoneyInput,
19
28
  MoneyValue,
20
29
  TimestampColumn,
@@ -64,16 +73,22 @@ const parseBrandedUuid = <T extends string>(value: unknown): T => parseUuid(valu
64
73
  * derivation — row, insert, `findById`, `update`, `delete` — so mixing two entities' ids is a
65
74
  * compile error instead of a query that silently matches nothing.
66
75
  */
67
- export const uuid = <T extends string = string>(): UuidColumn<T> => ({
68
- ...makeColumn<T, false>({ ...BARE, kind: 'uuid' }, parseBrandedUuid, false),
76
+ export const uuid = <T extends string = string>(): UuidColumn<T> =>
77
+ uuidWith({ ...BARE, kind: 'uuid' });
78
+
79
+ const uuidWith = <T extends string>(meta: ColumnMeta): UuidColumn<T> => ({
80
+ ...makeColumn<T, false>(meta, parseBrandedUuid, false),
69
81
  // Narrower than the generic chain: a uuid key is generated when omitted, so it is the one
70
82
  // primary key an insert may leave out.
71
83
  primaryKey: () =>
72
84
  makeColumn<T, true>(
73
- { ...BARE, kind: 'uuid', primaryKey: true, default: GENERATED_UUID },
85
+ { ...meta, primaryKey: true, default: GENERATED_UUID },
74
86
  parseBrandedUuid,
75
87
  true,
76
88
  ),
89
+ // Overridden for the same reason `timestamp()`'s is: `uuid().column('user_id').primaryKey()`
90
+ // must still be the KEY form, which is optional on insert, rather than the general one.
91
+ column: (name) => uuidWith<T>({ ...meta, name: assertColumnName(name) }),
77
92
  });
78
93
 
79
94
  export interface TextOptions {
@@ -294,7 +309,34 @@ const parseMoney = (value: unknown): MoneyValue => {
294
309
  * silent 10,000x reinterpretation of a value the type system, `t.money` and `@ultimat3/money` all
295
310
  * carry. `null` in the column is "no explicit scale" and decodes to an ABSENT key, never to `0`.
296
311
  */
297
- export const money = (): Column<MoneyValue> => column<MoneyValue>('money', parseMoney);
312
+ export interface MoneyOptions {
313
+ /**
314
+ * Where the three columns already live, for a table this framework did not create. Named per
315
+ * part and merged over `<name>_minor` / `<name>_currency` / `<name>_scale`, so a legacy
316
+ * `amount_cents` beside a `currency` is two words rather than three.
317
+ *
318
+ * `scale: null` says the table has no scale column at all. What that costs is stated where the
319
+ * option is declared (`MoneyColumnNames`): every amount is then at the currency's own minor
320
+ * unit. What it does NOT cost is correctness — an absent scale already means exactly that.
321
+ */
322
+ readonly columns?: MoneyColumnNames;
323
+ }
324
+
325
+ export const money = (options: MoneyOptions = {}): Column<MoneyValue> =>
326
+ column<MoneyValue>(
327
+ 'money',
328
+ parseMoney,
329
+ options.columns === undefined ? {} : { parts: checkedParts(options.columns) },
330
+ );
331
+
332
+ /** Every part named goes through the same identifier rule a `.column()` does. */
333
+ const checkedParts = (columns: MoneyColumnNames): MoneyColumnNames => ({
334
+ ...(columns.minor === undefined ? {} : { minor: assertColumnName(columns.minor) }),
335
+ ...(columns.currency === undefined ? {} : { currency: assertColumnName(columns.currency) }),
336
+ ...(columns.scale === undefined || columns.scale === null
337
+ ? { ...(columns.scale === null ? { scale: null } : {}) }
338
+ : { scale: assertColumnName(columns.scale) }),
339
+ });
298
340
 
299
341
  /**
300
342
  * Money is the one column whose write type is wider than its row type — `MoneyInput` takes a
package/src/count-by.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // a `countBy` against Postgres means; a rule added to one driver alone is the drift this file
5
5
  // exists to prevent.
6
6
 
7
+ import { columnFor } from './column';
7
8
  import type { EntityCore } from './entity';
8
9
  import { EntityError } from './errors';
9
10
  import type { AnyColumn, ColumnKind } from './types';
@@ -86,7 +87,7 @@ export const groupColumnOf = <Row>(
86
87
  property: string,
87
88
  operation: string,
88
89
  ): AnyColumn => {
89
- const column = entity.$columns[property];
90
+ const column = columnFor(entity.$columns, property);
90
91
  if (column === undefined) {
91
92
  throw notGroupable(
92
93
  entity,
package/src/cursor.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  // to still exist, and a row deleted between two pages would silently restart pagination.
8
8
 
9
9
  import { CursorInvalidError, decodeCursor, encodeCursor } from '@ultimat3/core';
10
+ import { columnFor } from './column';
10
11
  import type { EntityCore } from './entity';
11
12
  import { invariantViolated } from './errors';
12
13
  import type { QueryPlan } from './tenancy';
@@ -28,7 +29,7 @@ const partsOf = (path: string): { readonly property: string; readonly part?: str
28
29
  };
29
30
 
30
31
  const columnAt = <Row>(entity: EntityCore<Row>, path: string): AnyColumn => {
31
- const column = entity.$columns[partsOf(path).property];
32
+ const column = columnFor(entity.$columns, partsOf(path).property);
32
33
  if (column === undefined) {
33
34
  throw invariantViolated(entity.$name, 'orderBy', `no column "${path}"`);
34
35
  }
@@ -51,13 +52,33 @@ const kindAt = <Row>(entity: EntityCore<Row>, path: string): ColumnKind => {
51
52
  `${path} is money: order by ${path}.minor or ${path}.currency`,
52
53
  );
53
54
  }
54
- const money = kind === 'money' ? MONEY_PARTS[part] : undefined;
55
+ // `MONEY_PARTS[part]` alone answers a FUNCTION for `orderBy('price.toString')` not
56
+ // `undefined` — so the refusal below never fired and `assertSeekable` minted a cursor for it.
57
+ const money =
58
+ kind === 'money' && Object.hasOwn(MONEY_PARTS, part) ? MONEY_PARTS[part] : undefined;
55
59
  if (money === undefined) {
56
60
  throw invariantViolated(entity.$name, 'orderBy', `${path} names no column part`);
57
61
  }
58
62
  return money;
59
63
  };
60
64
 
65
+ /**
66
+ * The kind a PATH holds, or `undefined` when it names none — the non-throwing half of `kindAt`.
67
+ *
68
+ * The in-memory driver asks this about a predicate column and a sort key, both of which are caller
69
+ * data: an unknown name compares as text there exactly as it always did, rather than turning a
70
+ * filter into a refusal the Postgres driver does not make. It is what lets a comparison be decided
71
+ * by the column's DECLARED kind — which is what Postgres decides by — instead of by the JS type of
72
+ * whichever value is in hand.
73
+ */
74
+ export const kindOf = <Row>(entity: EntityCore<Row>, path: string): ColumnKind | undefined => {
75
+ const { property, part } = partsOf(path);
76
+ const kind = columnFor(entity.$columns, property)?.$meta.kind;
77
+ if (kind === undefined) return undefined;
78
+ if (part === undefined) return kind === 'money' ? undefined : kind;
79
+ return kind === 'money' && Object.hasOwn(MONEY_PARTS, part) ? MONEY_PARTS[part] : undefined;
80
+ };
81
+
61
82
  export const valueAt = (row: unknown, path: string): unknown => {
62
83
  const { property, part } = partsOf(path);
63
84
  const record = typeof row === 'object' && row !== null ? (row as Record<string, unknown>) : {};
package/src/describe.ts CHANGED
@@ -5,7 +5,7 @@
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 { referenceBinding, snake } from './column';
8
+ import { columnName, moneyColumns, referenceBinding } from './column';
9
9
  import { currencyCheck, scaleCheck } from './columns';
10
10
  import type { Invariant } from './invariants';
11
11
  import type { ColumnDescription, EntityDescription, ReferenceDescription } from './registry';
@@ -13,6 +13,8 @@ import type { AnyColumn, ColumnMeta, IndexDef } from './types';
13
13
 
14
14
  export interface DescribeInput<Row> {
15
15
  readonly name: string;
16
+ /** The physical table. The entity's own name unless `entity(name, { table })` said otherwise. */
17
+ readonly table: string;
16
18
  readonly columns: readonly (readonly [string, AnyColumn])[];
17
19
  readonly primaryKey: readonly string[];
18
20
  readonly invariants: readonly Invariant<Row>[];
@@ -41,35 +43,60 @@ export const describeReferences = (
41
43
  return [
42
44
  {
43
45
  property,
44
- column: snake(property),
46
+ column: columnName(property, meta),
45
47
  nullable: !meta.notNull,
46
48
  targetEntity: target.table,
47
49
  targetProperty: target.property,
48
50
  targetColumn: target.name,
51
+ onDelete: meta.onDelete ?? null,
49
52
  },
50
53
  ];
51
54
  });
52
55
 
56
+ /**
57
+ * The Postgres type a column becomes. `kind` is what the migration generator reads and its table
58
+ * falls through to the kind itself for anything it does not name (`SQL_TYPES[kind] ?? kind`), so a
59
+ * precise type belongs HERE, where the precision, the element and the length are still in scope —
60
+ * the alternative is a second copy of the column vocabulary inside `@ultimat3/db`.
61
+ */
62
+ export const sqlTypeOf = (meta: ColumnMeta): string => {
63
+ if (meta.kind === 'numeric') {
64
+ return meta.precision === undefined || meta.numericScale === undefined
65
+ ? 'numeric'
66
+ : `numeric(${meta.precision}, ${meta.numericScale})`;
67
+ }
68
+ if (meta.kind === 'array') {
69
+ const element = meta.element?.$meta;
70
+ // `arrayOf` refuses an element that is not one scalar column, so this is total in practice;
71
+ // `text[]` is the answer that keeps a description renderable rather than throwing inside a
72
+ // projection, which is the one place an error has no caller to instruct.
73
+ return `${element === undefined ? 'text' : sqlTypeOf(element)}[]`;
74
+ }
75
+ return meta.kind;
76
+ };
77
+
53
78
  const describeColumn = <Row>(
54
79
  input: DescribeInput<Row>,
55
80
  property: string,
56
81
  meta: ColumnMeta,
57
82
  reference: ReferenceDescription | undefined,
58
83
  ): readonly ColumnDescription[] => {
59
- const physical = snake(property);
84
+ const physical = columnName(property, meta);
60
85
  if (meta.kind === 'money') {
61
- const currency = `${physical}_currency`;
86
+ const parts = moneyColumns(property, meta);
87
+ const currency = parts.currency;
62
88
  const shared = {
63
89
  notNull: meta.notNull,
64
90
  primaryKey: false,
65
91
  unique: false,
66
92
  hasDefault: false,
67
93
  references: null,
94
+ onDelete: null,
68
95
  };
69
96
  return [
70
97
  {
71
98
  property: `${property}Minor`,
72
- column: `${physical}_minor`,
99
+ column: parts.minor,
73
100
  kind: 'bigint',
74
101
  check: null,
75
102
  ...shared,
@@ -85,21 +112,29 @@ const describeColumn = <Row>(
85
112
  // currency's own minor unit", which is every amount written before the column existed and
86
113
  // every ordinary price after it. A NOT NULL here would demand a scale on values that have
87
114
  // 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
- },
115
+ //
116
+ // Absent entirely when the table has none: an adopted amount column predating scale is two
117
+ // physical columns, and describing a third would put a column in the DDL and in every
118
+ // statement that the table does not have.
119
+ ...(parts.scale === null
120
+ ? []
121
+ : [
122
+ {
123
+ property: `${property}Scale`,
124
+ column: parts.scale,
125
+ kind: 'integer',
126
+ check: scaleCheck(parts.scale),
127
+ ...shared,
128
+ notNull: false,
129
+ },
130
+ ]),
96
131
  ];
97
132
  }
98
133
  return [
99
134
  {
100
135
  property,
101
136
  column: physical,
102
- kind: meta.kind,
137
+ kind: sqlTypeOf(meta),
103
138
  notNull: meta.notNull,
104
139
  primaryKey: meta.primaryKey || input.primaryKey.includes(property),
105
140
  unique: meta.unique,
@@ -109,11 +144,18 @@ const describeColumn = <Row>(
109
144
  // traversal reads can never disagree about what a `references()` points at.
110
145
  references:
111
146
  reference === undefined ? null : `${reference.targetEntity}.${reference.targetColumn}`,
147
+ // Off the resolved reference, never off `meta` again: a rule with no key is not a thing, and
148
+ // reading the option twice is two places for the pair to disagree.
149
+ onDelete: reference?.onDelete ?? null,
112
150
  },
113
151
  ];
114
152
  };
115
153
 
116
154
  export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescription => {
155
+ const physicalOf = (property: string): string => {
156
+ const column = input.columns.find(([key]) => key === property)?.[1];
157
+ return column === undefined ? property : columnName(property, column.$meta);
158
+ };
117
159
  const references = new Map(
118
160
  describeReferences(input.name, input.columns).map((reference) => [
119
161
  reference.property,
@@ -122,8 +164,8 @@ export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescriptio
122
164
  );
123
165
  return {
124
166
  name: input.name,
125
- table: input.name,
126
- primaryKey: input.primaryKey.map((property) => snake(property)),
167
+ table: input.table,
168
+ primaryKey: input.primaryKey.map(physicalOf),
127
169
  columns: input.columns.flatMap(([property, column]) =>
128
170
  describeColumn(input, property, column.$meta, references.get(property)),
129
171
  ),
package/src/entity.ts CHANGED
@@ -3,9 +3,9 @@
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 { systemClock } from '@ultimat3/core';
7
6
  import { describeValue, type StandardSchemaV1 } from '@ultimat3/schema';
8
- import { bindColumn, snake } from './column';
7
+ import { entityNow } from './clock';
8
+ import { assertColumnName, bindColumn, columnName, moneyColumns } from './column';
9
9
  import { newId } from './columns';
10
10
  import { describeEntity, describeReferences } from './describe';
11
11
  import { invariantViolated } from './errors';
@@ -33,6 +33,15 @@ export interface IndexInit<C extends ColumnMap> {
33
33
 
34
34
  export interface EntityInit<C extends ColumnMap> {
35
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;
36
45
  /**
37
46
  * The tenant column, said out loud. Omitted, it is inferred from `.tenant()` or a column named
38
47
  * `orgId`, so silence never means unscoped.
@@ -106,7 +115,7 @@ const defaultValue = (meta: ColumnMeta): unknown => {
106
115
  const declared = meta.default;
107
116
  if (declared === undefined) return undefined;
108
117
  if (declared.kind === 'value') return declared.value;
109
- return declared.by === 'uuid-v7' ? newId() : systemClock.now();
118
+ return declared.by === 'uuid-v7' ? newId() : entityNow();
110
119
  };
111
120
 
112
121
  export const entity = <const C extends ColumnMap>(
@@ -117,6 +126,7 @@ export const entity = <const C extends ColumnMap>(
117
126
  const entries: readonly (readonly [string, AnyColumn])[] = Object.entries(init.columns);
118
127
  for (const [property, column] of entries) bindColumn(column, name, property);
119
128
 
129
+ const table = init.table === undefined ? name : assertColumnName(init.table);
120
130
  const cacheTag = `entity:${name}`;
121
131
  const softDelete = Object.hasOwn(init.columns, SOFT_DELETE_COLUMN);
122
132
  const tenantColumn = resolveTenantColumn(name, init.columns, init.tenant);
@@ -147,19 +157,22 @@ export const entity = <const C extends ColumnMap>(
147
157
  `${property} is money: name ${property}.minor or ${property}.currency`,
148
158
  );
149
159
  }
150
- return snake(property);
160
+ return columnName(property, column.$meta);
151
161
  }
152
162
  if (!isMoney || !MONEY_PARTS.has(part)) {
153
163
  throw invariantViolated(name, property, `${property} has no part "${part}"`);
154
164
  }
155
- return `${snake(property)}_${part}`;
165
+ const parts = moneyColumns(property, column.$meta);
166
+ return part === 'minor' ? parts.minor : parts.currency;
156
167
  };
157
168
 
158
169
  const columnsExpr = invariantColumns<C>(
159
170
  name,
160
171
  entries.map(([property]) => property),
161
172
  );
162
- const partialWhere = softDelete ? `${snake(SOFT_DELETE_COLUMN)} is null` : undefined;
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;
163
176
  // Called once, here, so an unknown column throws while the entity is being declared.
164
177
  const invariants: readonly Invariant<Row>[] = (init.invariants?.(columnsExpr) ?? []).map((def) =>
165
178
  bindInvariant<Row>(def, resolve, partialWhere),
@@ -169,9 +182,11 @@ export const entity = <const C extends ColumnMap>(
169
182
  ...entries.flatMap(([property, column]) => {
170
183
  const meta = column.$meta;
171
184
  if (!meta.unique && !meta.index) return [];
172
- 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
+ ];
173
188
  return [
174
- { name: indexName(name, physical, meta.unique), columns: physical, unique: meta.unique },
189
+ { name: indexName(table, physical, meta.unique), columns: physical, unique: meta.unique },
175
190
  ];
176
191
  }),
177
192
  ...(init.indexes ?? []).map((index) => {
@@ -191,7 +206,7 @@ export const entity = <const C extends ColumnMap>(
191
206
  );
192
207
  }
193
208
  return {
194
- name: indexName(name, columns, unique),
209
+ name: indexName(table, columns, unique),
195
210
  columns,
196
211
  unique,
197
212
  ...(index.order === undefined ? {} : { order: index.order }),
@@ -208,6 +223,7 @@ export const entity = <const C extends ColumnMap>(
208
223
  const describe = (): EntityDescription =>
209
224
  describeEntity({
210
225
  name,
226
+ table,
211
227
  columns: entries,
212
228
  primaryKey,
213
229
  invariants,
@@ -251,7 +267,7 @@ export const entity = <const C extends ColumnMap>(
251
267
 
252
268
  const core: EntityCore<Row, C> = {
253
269
  $name: name,
254
- $table: name,
270
+ $table: table,
255
271
  $columns: init.columns,
256
272
  $primaryKey: primaryKey,
257
273
  $indexes: indexes,
@@ -289,7 +305,7 @@ export const entity = <const C extends ColumnMap>(
289
305
  $references: references,
290
306
  };
291
307
 
292
- registerEntity({ name, tableName: name, describe, references });
308
+ registerEntity({ name, tableName: table, describe, references });
293
309
  // The columns land on the entity itself so `orgs.id` is a column reference; every framework
294
310
  // member is `$`-prefixed, which is why a column may be called `name`.
295
311
  return Object.assign(core, init.columns);