@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.
- package/CLAUDE.md +142 -7
- package/README.md +83 -5
- package/package.json +5 -4
- package/src/bulk-write.ts +9 -7
- package/src/clock.ts +18 -0
- package/src/coalesce.ts +30 -16
- package/src/column.ts +82 -2
- package/src/columns-data.ts +205 -0
- package/src/columns.ts +47 -5
- package/src/count-by.ts +2 -1
- package/src/cursor.ts +23 -2
- package/src/describe.ts +58 -16
- package/src/entity.ts +27 -11
- package/src/index.ts +26 -3
- package/src/jit-preload.ts +66 -10
- package/src/memory-match.ts +169 -0
- package/src/pg-driver.ts +3 -4
- package/src/pg-row.ts +66 -20
- package/src/pg-sql.ts +58 -11
- package/src/plan.ts +5 -4
- package/src/query.ts +7 -7
- package/src/registry.ts +10 -0
- package/src/relations.ts +4 -1
- package/src/repo.ts +70 -99
- package/src/seed.ts +289 -19
- package/src/types.ts +103 -11
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
{ ...
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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 =
|
|
84
|
+
const physical = columnName(property, meta);
|
|
60
85
|
if (meta.kind === 'money') {
|
|
61
|
-
const
|
|
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:
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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.
|
|
126
|
-
primaryKey: input.primaryKey.map(
|
|
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 {
|
|
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() :
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 = [
|
|
185
|
+
const physical = [
|
|
186
|
+
meta.kind === 'money' ? moneyColumns(property, meta).minor : columnName(property, meta),
|
|
187
|
+
];
|
|
173
188
|
return [
|
|
174
|
-
{ name: indexName(
|
|
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(
|
|
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:
|
|
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:
|
|
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);
|