@ultimat3/entity 11.3.0 → 13.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 +243 -20
- package/README.md +206 -1
- package/package.json +5 -5
- package/src/aggregate-decode.ts +35 -0
- package/src/aggregate-fold.ts +91 -0
- package/src/aggregate.ts +232 -0
- package/src/batch.ts +2 -1
- package/src/column-values.ts +29 -0
- package/src/column.ts +37 -1
- package/src/columns.ts +5 -46
- package/src/containment.ts +94 -0
- package/src/cursor.ts +100 -14
- package/src/database.ts +1 -1
- package/src/describe.ts +38 -3
- package/src/entity.ts +117 -8
- package/src/enum-column.ts +81 -0
- package/src/errors.ts +16 -0
- package/src/feature-errors.ts +121 -0
- package/src/index-name.ts +83 -0
- package/src/index.ts +42 -4
- package/src/instant.ts +93 -0
- package/src/memory-match.ts +72 -0
- package/src/memory-repo.ts +357 -0
- package/src/pg-driver.ts +94 -7
- package/src/pg-row.ts +38 -1
- package/src/pg-sql.ts +311 -133
- package/src/pg-write-sql.ts +129 -0
- package/src/plan.ts +71 -10
- package/src/query.ts +120 -3
- package/src/registry.ts +16 -0
- package/src/repo.ts +16 -328
- package/src/search.ts +153 -0
- package/src/state-machine.ts +132 -0
- package/src/tenancy.ts +23 -2
- package/src/transition.ts +119 -0
- package/src/types.ts +60 -0
package/src/cursor.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { CursorInvalidError, decodeCursor, encodeCursor } from '@ultimat3/core';
|
|
|
10
10
|
import { columnFor } from './column';
|
|
11
11
|
import type { EntityCore } from './entity';
|
|
12
12
|
import { invariantViolated } from './errors';
|
|
13
|
+
import { instantMicros } from './instant';
|
|
13
14
|
import type { QueryPlan } from './tenancy';
|
|
14
15
|
import type { AnyColumn, ColumnKind } from './types';
|
|
15
16
|
|
|
@@ -89,8 +90,36 @@ export const valueAt = (row: unknown, path: string): unknown => {
|
|
|
89
90
|
: undefined;
|
|
90
91
|
};
|
|
91
92
|
|
|
92
|
-
/**
|
|
93
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Stringified so the cursor is JSON; `revive` restores the type from the column's kind — and the
|
|
95
|
+
* KIND decides how, never the JS type in hand, because those are two different questions on
|
|
96
|
+
* exactly the column that made this file wrong.
|
|
97
|
+
*
|
|
98
|
+
* A `timestamptz` is carried as MICROSECONDS since the epoch, not as `toISOString()`. The column
|
|
99
|
+
* holds microseconds and a `Date` holds milliseconds, so an ISO rendition of a decoded row is the
|
|
100
|
+
* row's own position FLOORED — and a seek built from a floored position ranks rows differently
|
|
101
|
+
* from the `order by` that produced them, which silently drops every row inside the boundary
|
|
102
|
+
* millisecond. Proven against a real server: `pg-cursor-precision.live.test.ts`.
|
|
103
|
+
*/
|
|
104
|
+
const ABSENT_MARK = '~';
|
|
105
|
+
const PRESENT_MARK = '!';
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A sort value's place in the cursor is TAGGED, so absence can be told from the text that spells
|
|
109
|
+
* it: `~` alone is NULL, `!` prefixes a present value. Positional, therefore total — a `text`
|
|
110
|
+
* column holding the four characters `null` encodes as `!null` and can never be read as an absent
|
|
111
|
+
* one, which is the collision a bare sentinel value would reopen.
|
|
112
|
+
*
|
|
113
|
+
* The tag exists because a nullable sort key is legal `As of 2026-08-24` (`asc nulls last` /
|
|
114
|
+
* `desc nulls first`, `@ultimat3/query`'s spelling), and a keyset position over one has to be able
|
|
115
|
+
* to say "the boundary row had none".
|
|
116
|
+
*/
|
|
117
|
+
const tagged = (text: string): string => `${PRESENT_MARK}${text}`;
|
|
118
|
+
|
|
119
|
+
const serializeSortValue = (kind: ColumnKind, value: unknown): string | undefined => {
|
|
120
|
+
// `undefined`, never `'0'`: a position nothing could read would decode to the epoch, which is
|
|
121
|
+
// "start from the top" wearing a signature — the one thing a cursor must never mean.
|
|
122
|
+
if (kind === 'timestamptz') return instantMicros(value)?.toString();
|
|
94
123
|
if (value instanceof Date) return value.toISOString();
|
|
95
124
|
if (typeof value === 'bigint') return value.toString();
|
|
96
125
|
return String(value);
|
|
@@ -108,8 +137,16 @@ const serializeSortValue = (value: unknown): string => {
|
|
|
108
137
|
// constant three lines above it.
|
|
109
138
|
const reviveSortValue = (kind: ColumnKind, text: string): unknown => {
|
|
110
139
|
switch (kind) {
|
|
111
|
-
case 'timestamptz':
|
|
112
|
-
|
|
140
|
+
case 'timestamptz': {
|
|
141
|
+
// Microseconds since the epoch — the precision the COLUMN keeps, which a `Date` cannot.
|
|
142
|
+
// A cursor minted before that decision carries an ISO string, so this is where it is
|
|
143
|
+
// refused: `BigInt('2026-…')` is a bare `SyntaxError` with no code and no fix.
|
|
144
|
+
const micros = instantMicros(text);
|
|
145
|
+
if (micros === undefined) {
|
|
146
|
+
throw new CursorInvalidError('its position is not a microsecond instant');
|
|
147
|
+
}
|
|
148
|
+
return micros;
|
|
149
|
+
}
|
|
113
150
|
case 'bigint':
|
|
114
151
|
return BigInt(text);
|
|
115
152
|
case 'integer':
|
|
@@ -125,9 +162,12 @@ const reviveSortValue = (kind: ColumnKind, text: string): unknown => {
|
|
|
125
162
|
* A keyset seek only has a total order when every sort column is present on every row —
|
|
126
163
|
* `null > 'x'` is unknown in SQL and would drop rows from the middle of a listing.
|
|
127
164
|
*
|
|
128
|
-
* Checked
|
|
129
|
-
* a
|
|
130
|
-
*
|
|
165
|
+
* Checked where the PLAN is built (`planFor`), which is every read either driver sends, as well as
|
|
166
|
+
* when a cursor is minted and when one is decoded. The plan is the load-bearing one: `cursorFor`
|
|
167
|
+
* runs only when a page found a row past its limit, so the refusal used to depend on how many rows
|
|
168
|
+
* the table happened to hold — green on fifteen seeded rows, `X_INVARIANT_VIOLATED` on the first
|
|
169
|
+
* read past a page of twenty in production. An ordering that cannot carry a position is the
|
|
170
|
+
* author's mistake at any row count.
|
|
131
171
|
*/
|
|
132
172
|
export const assertSeekable = <Row>(
|
|
133
173
|
entity: EntityCore<Row>,
|
|
@@ -138,15 +178,28 @@ export const assertSeekable = <Row>(
|
|
|
138
178
|
// money property named without its part — both mint a cursor nothing can decode.
|
|
139
179
|
kindAt(entity, key.column);
|
|
140
180
|
if (columnAt(entity, key.column).$meta.notNull) continue;
|
|
181
|
+
// An ORDINARY nullable key is orderable, `As of 2026-08-24`: NULL has a declared place
|
|
182
|
+
// (`asc nulls last` / `desc nulls first`), the cursor carries that place, and the seek reaches
|
|
183
|
+
// it. What is left is the TIEBREAK — `totalOrder` appends the primary key precisely so two
|
|
184
|
+
// rows sharing a sort value cannot straddle a page boundary, and a nullable primary-key column
|
|
185
|
+
// cannot do that job: `null = null` is unknown, so two such rows are indistinguishable to the
|
|
186
|
+
// seek and one of them is served twice or never. Reachable only through `primaryKey: [...]`,
|
|
187
|
+
// which takes the columns as declared.
|
|
188
|
+
if (!entity.$primaryKey.includes(key.column)) continue;
|
|
141
189
|
throw invariantViolated(
|
|
142
190
|
entity.$name,
|
|
143
191
|
'cursor',
|
|
144
|
-
`${key.column} is
|
|
145
|
-
|
|
192
|
+
`${key.column} is part of the primary key and is nullable, so no ordering can be total — ` +
|
|
193
|
+
'an ordinary nullable column orders fine (nulls last ascending, nulls first descending), ' +
|
|
194
|
+
`but the tiebreak cannot: drop .nullable() from ${key.column}`,
|
|
146
195
|
);
|
|
147
196
|
}
|
|
148
197
|
};
|
|
149
198
|
|
|
199
|
+
/** Whether a sort key may hold NULL — what decides the seek's SHAPE, not only its values. */
|
|
200
|
+
export const isNullableKey = <Row>(entity: EntityCore<Row>, path: string): boolean =>
|
|
201
|
+
!columnAt(entity, path).$meta.notNull;
|
|
202
|
+
|
|
150
203
|
/** Deterministic, and total over the value shapes a predicate can hold. */
|
|
151
204
|
const renderValue = (value: unknown): string => {
|
|
152
205
|
if (value === null || value === undefined) return 'null';
|
|
@@ -182,17 +235,37 @@ export const planScope = (plan: QueryPlan): string => {
|
|
|
182
235
|
.slice(0, 16);
|
|
183
236
|
};
|
|
184
237
|
|
|
185
|
-
/**
|
|
238
|
+
/**
|
|
239
|
+
* The cursor that continues this plan after `row`. Signed by core, scoped by the plan.
|
|
240
|
+
*
|
|
241
|
+
* `exact` is how a driver hands over a value the DECODED row cannot hold: a `timestamptz` comes
|
|
242
|
+
* back as a `Date`, which is milliseconds, and the microseconds it dropped are the difference
|
|
243
|
+
* between a position the `order by` agrees with and one it does not. Optional because the
|
|
244
|
+
* in-memory driver stores millisecond `Date`s and therefore has nothing finer to give.
|
|
245
|
+
*/
|
|
186
246
|
export const cursorFor = <Row>(
|
|
187
247
|
entity: EntityCore<Row>,
|
|
188
248
|
plan: QueryPlan,
|
|
189
249
|
row: unknown,
|
|
190
250
|
id: string,
|
|
251
|
+
exact?: ReadonlyMap<string, unknown>,
|
|
191
252
|
): string => {
|
|
192
253
|
assertSeekable(entity, plan.orderBy);
|
|
193
254
|
return encodeCursor({
|
|
194
255
|
scope: planScope(plan),
|
|
195
|
-
key: plan.orderBy.map((entry) =>
|
|
256
|
+
key: plan.orderBy.map((entry) => {
|
|
257
|
+
const value = exact?.get(entry.column) ?? valueAt(row, entry.column);
|
|
258
|
+
// A column the row never named and a stored NULL are one absence everywhere else in this
|
|
259
|
+
// package (`isNull`), and they are one position here too.
|
|
260
|
+
if (value === null || value === undefined) return ABSENT_MARK;
|
|
261
|
+
const text = serializeSortValue(kindAt(entity, entry.column), value);
|
|
262
|
+
if (text !== undefined) return tagged(text);
|
|
263
|
+
throw invariantViolated(
|
|
264
|
+
entity.$name,
|
|
265
|
+
'cursor',
|
|
266
|
+
`${entry.column} on the last row of the page holds no instant a cursor can carry`,
|
|
267
|
+
);
|
|
268
|
+
}),
|
|
196
269
|
id,
|
|
197
270
|
});
|
|
198
271
|
};
|
|
@@ -216,7 +289,20 @@ export const seekFrom = <Row>(
|
|
|
216
289
|
`it carries ${key.length} sort values, this order needs ${plan.orderBy.length}`,
|
|
217
290
|
);
|
|
218
291
|
}
|
|
219
|
-
return plan.orderBy.map((entry, index) =>
|
|
220
|
-
|
|
221
|
-
|
|
292
|
+
return plan.orderBy.map((entry, index) => {
|
|
293
|
+
// `segment` and `ABSENT_MARK`, never `token` and `NULL_KEY`: both names said CREDENTIAL to
|
|
294
|
+
// `bun run secret-compare`, whose rule is that a `===` on one leaks it a byte at a time. What
|
|
295
|
+
// this compares is a page POSITION against a one-character tag, where the repair the guard
|
|
296
|
+
// names — `timingSafeEqual` — would be constant-time nonsense. The guard reads names because a
|
|
297
|
+
// unit test cannot assert timing, so the name is the thing that has to be right.
|
|
298
|
+
const segment = String(key[index]);
|
|
299
|
+
if (segment === ABSENT_MARK) return null;
|
|
300
|
+
if (!segment.startsWith(PRESENT_MARK)) {
|
|
301
|
+
// Every cursor this package mints carries a tag. An untagged one was forged past the
|
|
302
|
+
// signature or minted before nullable sort keys existed; either way the alternative is a
|
|
303
|
+
// silent restart at the top, which is the one thing a cursor may never mean.
|
|
304
|
+
throw new CursorInvalidError('a sort value carries no null-or-value tag');
|
|
305
|
+
}
|
|
306
|
+
return reviveSortValue(kindAt(entity, entry.column), segment.slice(PRESENT_MARK.length));
|
|
307
|
+
});
|
|
222
308
|
};
|
package/src/database.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
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 { memoryRepo } from './memory-repo';
|
|
5
6
|
import type { RelatedTables } from './preload';
|
|
6
7
|
import type { Table } from './query';
|
|
7
8
|
import { tableFor } from './query';
|
|
8
9
|
import type { Repo } from './repo';
|
|
9
|
-
import { memoryRepo } from './repo';
|
|
10
10
|
import { observedRepo } from './row-observer';
|
|
11
11
|
|
|
12
12
|
export type EntitySet = Readonly<Record<string, EntityCore>>;
|
package/src/describe.ts
CHANGED
|
@@ -9,6 +9,7 @@ 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';
|
|
12
|
+
import type { SearchVector } from './search';
|
|
12
13
|
import type { AnyColumn, ColumnMeta, IndexDef } from './types';
|
|
13
14
|
|
|
14
15
|
export interface DescribeInput<Row> {
|
|
@@ -23,8 +24,34 @@ export interface DescribeInput<Row> {
|
|
|
23
24
|
readonly cacheTag: string;
|
|
24
25
|
readonly softDelete: boolean;
|
|
25
26
|
readonly tenantColumn: string | null;
|
|
27
|
+
/** The generated `tsvector`, when any column is `.searchable()`. */
|
|
28
|
+
readonly search?: SearchVector | null;
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The search vector as a physical column: `tsvector`, computed by the database, never written.
|
|
33
|
+
*
|
|
34
|
+
* `notNull` is what makes a missing `generated` clause LOUD rather than silent. Every function in
|
|
35
|
+
* the expression is total over a coalesced text, so the value can never be NULL — and if a
|
|
36
|
+
* generator that does not yet render `generated` emits the column as a plain `tsvector`, the first
|
|
37
|
+
* insert is a `23502` naming this column, instead of a table of NULL vectors where every search
|
|
38
|
+
* quietly answers nothing.
|
|
39
|
+
*/
|
|
40
|
+
const describeSearchColumn = (search: SearchVector): ColumnDescription => ({
|
|
41
|
+
// `$`-prefixed: a property key no column can be spelled as, because nothing may address it.
|
|
42
|
+
property: '$search',
|
|
43
|
+
column: search.column,
|
|
44
|
+
kind: 'tsvector',
|
|
45
|
+
notNull: true,
|
|
46
|
+
primaryKey: false,
|
|
47
|
+
unique: false,
|
|
48
|
+
hasDefault: false,
|
|
49
|
+
check: null,
|
|
50
|
+
references: null,
|
|
51
|
+
onDelete: null,
|
|
52
|
+
generated: search.expression,
|
|
53
|
+
});
|
|
54
|
+
|
|
28
55
|
/**
|
|
29
56
|
* The foreign keys an entity declares, resolved through the one binding resolver. Money is
|
|
30
57
|
* skipped for the reason the DDL projection drops a reference there too: one property is two
|
|
@@ -170,9 +197,14 @@ export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescriptio
|
|
|
170
197
|
name: input.name,
|
|
171
198
|
table: input.table,
|
|
172
199
|
primaryKey: input.primaryKey.map(physicalOf),
|
|
173
|
-
columns:
|
|
174
|
-
|
|
175
|
-
|
|
200
|
+
columns: [
|
|
201
|
+
...input.columns.flatMap(([property, column]) =>
|
|
202
|
+
describeColumn(input, property, column.$meta, references.get(property)),
|
|
203
|
+
),
|
|
204
|
+
// LAST, so every column an author declared keeps the position it had and no snapshot of an
|
|
205
|
+
// entity without a search vector moves.
|
|
206
|
+
...(input.search == null ? [] : [describeSearchColumn(input.search)]),
|
|
207
|
+
],
|
|
176
208
|
invariants: input.invariants.map((inv) => ({
|
|
177
209
|
name: inv.name,
|
|
178
210
|
kind: inv.kind,
|
|
@@ -188,6 +220,9 @@ export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescriptio
|
|
|
188
220
|
unique: index.unique,
|
|
189
221
|
where: index.where ?? null,
|
|
190
222
|
order: index.order ?? null,
|
|
223
|
+
// Spread, never `?? null`: absent is what `@ultimat3/db` reads as the btree it always was,
|
|
224
|
+
// and a written-out `null` would be a field no existing snapshot carries.
|
|
225
|
+
...(index.using === undefined ? {} : { using: index.using }),
|
|
191
226
|
})),
|
|
192
227
|
tags: input.tags,
|
|
193
228
|
cacheTag: input.cacheTag,
|
package/src/entity.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// this one call.
|
|
5
5
|
|
|
6
6
|
import { renderThrowable } from '@ultimat3/core';
|
|
7
|
+
import type { IndexMethod } from '@ultimat3/db';
|
|
7
8
|
import { describeValue, type StandardSchemaV1 } from '@ultimat3/schema';
|
|
8
9
|
import { entityNow } from './clock';
|
|
9
10
|
import { assertColumnName, bindColumn, columnName, moneyColumns } from './column';
|
|
@@ -12,10 +13,13 @@ import { describeEntity, describeReferences } from './describe';
|
|
|
12
13
|
import { invariantViolated } from './errors';
|
|
13
14
|
import type { Expr, InvariantColumns, Resolve } from './expr';
|
|
14
15
|
import { invariantColumns } from './expr';
|
|
16
|
+
import { indexName } from './index-name';
|
|
15
17
|
import type { Invariant, InvariantDef } from './invariants';
|
|
16
18
|
import { assertInvariants, bindInvariant, invariantsToSql } from './invariants';
|
|
17
19
|
import type { EntityDescription, ReferenceDescription } from './registry';
|
|
18
20
|
import { registerEntity } from './registry';
|
|
21
|
+
import type { SearchInit, SearchSource, SearchVector } from './search';
|
|
22
|
+
import { searchVectorOf } from './search';
|
|
19
23
|
import { resolveTenantColumn } from './tenancy';
|
|
20
24
|
import type { AnyColumn, ColumnMap, ColumnMeta, IndexDef, RowOf } from './types';
|
|
21
25
|
import type { EntityView } from './view';
|
|
@@ -30,6 +34,21 @@ export interface IndexInit<C extends ColumnMap> {
|
|
|
30
34
|
readonly unique?: boolean;
|
|
31
35
|
/** Partial index predicate, written in the same language as an invariant. */
|
|
32
36
|
readonly where?: (columns: InvariantColumns<C>) => Expr;
|
|
37
|
+
/**
|
|
38
|
+
* The access method. Omitted is `btree`, which is Postgres' own default and what every index
|
|
39
|
+
* declared before this existed is — so an entity that names none emits the statement it always
|
|
40
|
+
* emitted and nothing regenerates.
|
|
41
|
+
*
|
|
42
|
+
* `'gin'` is the one with a caller, and it is the whole point of the containment operators:
|
|
43
|
+
* measured on Postgres 16 over 20,000 rows, `tags @> …`, `tags <@ …`, `tags && …` and
|
|
44
|
+
* `data @> …` are each a Bitmap Index Scan with one and a Seq Scan without. The set is
|
|
45
|
+
* `@ultimat3/db`'s `INDEX_METHODS`, imported rather than restated — one declaration of one fact.
|
|
46
|
+
*
|
|
47
|
+
* Two Postgres rules ride with it and both are refused HERE, where the author is, rather than at
|
|
48
|
+
* `x db gen` or inside `ROLE=migrate` as the server's own syntax error: a GIN index cannot be
|
|
49
|
+
* unique and cannot order its keys.
|
|
50
|
+
*/
|
|
51
|
+
readonly using?: IndexMethod;
|
|
33
52
|
}
|
|
34
53
|
|
|
35
54
|
export interface EntityInit<C extends ColumnMap> {
|
|
@@ -57,6 +76,12 @@ export interface EntityInit<C extends ColumnMap> {
|
|
|
57
76
|
*/
|
|
58
77
|
readonly invariants?: (columns: InvariantColumns<C>) => readonly InvariantDef[];
|
|
59
78
|
readonly indexes?: readonly IndexInit<C>[];
|
|
79
|
+
/**
|
|
80
|
+
* Full-text search, when the two defaults do not fit: `search_tsv` and `'english'`. WHICH columns
|
|
81
|
+
* are searched is `.searchable()` on the columns themselves, never restated here — this is the
|
|
82
|
+
* adoption escape, exactly as `table` and `.column()` are.
|
|
83
|
+
*/
|
|
84
|
+
readonly search?: SearchInit;
|
|
60
85
|
/** Extra cache tags this entity participates in, beyond its own. */
|
|
61
86
|
readonly tags?: readonly string[];
|
|
62
87
|
}
|
|
@@ -79,6 +104,11 @@ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
|
|
|
79
104
|
readonly $softDelete: boolean;
|
|
80
105
|
/** Property key of the tenant column, or `null`. Presence is what turns tenancy on. */
|
|
81
106
|
readonly $tenantColumn: string | null;
|
|
107
|
+
/**
|
|
108
|
+
* The generated `tsvector` this entity's `.searchable()` columns derive, or `null` when none is.
|
|
109
|
+
* Presence is what makes `.search(text)` legal — both drivers read it, and neither invents one.
|
|
110
|
+
*/
|
|
111
|
+
readonly $search: SearchVector | null;
|
|
82
112
|
/** Phantom: `type Post = typeof posts.$row`. Reading it at runtime throws. */
|
|
83
113
|
readonly $row: Row;
|
|
84
114
|
/** The Standard Schema the columns already describe — forms and actions hand input to it. */
|
|
@@ -109,9 +139,6 @@ export type Entity<Row, C extends ColumnMap = ColumnMap> = EntityCore<Row, C> &
|
|
|
109
139
|
|
|
110
140
|
const MONEY_PARTS = new Set(['minor', 'currency']);
|
|
111
141
|
|
|
112
|
-
const indexName = (table: string, columns: readonly string[], unique: boolean): string =>
|
|
113
|
-
`${table}_${columns.join('_')}_${unique ? 'key' : 'idx'}`;
|
|
114
|
-
|
|
115
142
|
const defaultValue = (meta: ColumnMeta): unknown => {
|
|
116
143
|
const declared = meta.default;
|
|
117
144
|
if (declared === undefined) return undefined;
|
|
@@ -127,11 +154,30 @@ export const entity = <const C extends ColumnMap>(
|
|
|
127
154
|
const entries: readonly (readonly [string, AnyColumn])[] = Object.entries(init.columns);
|
|
128
155
|
for (const [property, column] of entries) bindColumn(column, name, property);
|
|
129
156
|
|
|
130
|
-
|
|
157
|
+
// Both branches. The declared table was checked and the fallback — which is every entity that
|
|
158
|
+
// does not rename its table — was not, so an entity NAME closed the identifier in the same way a
|
|
159
|
+
// column name could: `entity('t" (x int); drop table u; --')` emitted that `drop table` verbatim.
|
|
160
|
+
const table = assertColumnName(init.table ?? name);
|
|
131
161
|
const cacheTag = `entity:${name}`;
|
|
132
162
|
const softDelete = Object.hasOwn(init.columns, SOFT_DELETE_COLUMN);
|
|
133
163
|
const tenantColumn = resolveTenantColumn(name, init.columns, init.tenant);
|
|
134
164
|
|
|
165
|
+
const searchSources: readonly SearchSource[] = entries.flatMap(([property, column]) => {
|
|
166
|
+
const weight = column.$meta.searchable;
|
|
167
|
+
return weight === undefined ? [] : [{ column: columnName(property, column.$meta), weight }];
|
|
168
|
+
});
|
|
169
|
+
const search = searchVectorOf(
|
|
170
|
+
searchSources,
|
|
171
|
+
init.search,
|
|
172
|
+
// `physical`, not `candidate`: a parameter whose NAME reads like a credential is what
|
|
173
|
+
// `bun run secret-compare` refuses an `===` on, and this is a column name.
|
|
174
|
+
(physical) =>
|
|
175
|
+
entries.some(([property, column]) => columnName(property, column.$meta) === physical),
|
|
176
|
+
(subject, detail) => {
|
|
177
|
+
throw invariantViolated(name, subject, detail);
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
|
|
135
181
|
const primaryKey =
|
|
136
182
|
init.primaryKey ?? entries.filter(([, column]) => column.$meta.primaryKey).map(([key]) => key);
|
|
137
183
|
if (primaryKey.length === 0) {
|
|
@@ -187,7 +233,11 @@ export const entity = <const C extends ColumnMap>(
|
|
|
187
233
|
meta.kind === 'money' ? moneyColumns(property, meta).minor : columnName(property, meta),
|
|
188
234
|
];
|
|
189
235
|
return [
|
|
190
|
-
{
|
|
236
|
+
{
|
|
237
|
+
name: indexName(name, table, physical, meta.unique),
|
|
238
|
+
columns: physical,
|
|
239
|
+
unique: meta.unique,
|
|
240
|
+
},
|
|
191
241
|
];
|
|
192
242
|
}),
|
|
193
243
|
...(init.indexes ?? []).map((index) => {
|
|
@@ -206,18 +256,75 @@ export const entity = <const C extends ColumnMap>(
|
|
|
206
256
|
'a partial index predicate must be expressible in SQL; a JS predicate cannot be one',
|
|
207
257
|
);
|
|
208
258
|
}
|
|
259
|
+
// Two rules Postgres has that a declaration can break, refused where the author wrote it.
|
|
260
|
+
// `@ultimat3/db` refuses both again at `createIndex` — that is not a duplicate, it is the
|
|
261
|
+
// guard for a description nobody built here — but its refusal lands at `x db gen` or, if a
|
|
262
|
+
// migration was already written, inside `ROLE=migrate` as the server's own syntax error with
|
|
263
|
+
// none of the entity's words in it.
|
|
264
|
+
if (index.using !== undefined && index.using !== 'btree') {
|
|
265
|
+
if (unique) {
|
|
266
|
+
throw invariantViolated(
|
|
267
|
+
name,
|
|
268
|
+
'index',
|
|
269
|
+
`the index on (${columns.join(', ')}) is unique and ${index.using}; ` +
|
|
270
|
+
`Postgres has no unique ${index.using} index — drop unique, or drop using`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (index.order !== undefined) {
|
|
274
|
+
throw invariantViolated(
|
|
275
|
+
name,
|
|
276
|
+
'index',
|
|
277
|
+
`the index on (${columns.join(', ')}) is ${index.using} and ${index.order}; ` +
|
|
278
|
+
'only a btree orders its keys — drop order, or drop using',
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
209
282
|
return {
|
|
210
|
-
name: indexName(table, columns, unique),
|
|
283
|
+
name: indexName(name, table, columns, unique, index.order, where, index.using),
|
|
211
284
|
columns,
|
|
212
285
|
unique,
|
|
213
286
|
...(index.order === undefined ? {} : { order: index.order }),
|
|
214
287
|
...(where === null ? {} : { where }),
|
|
288
|
+
// Absent stays absent: `btree` written out would be a field every existing snapshot lacks,
|
|
289
|
+
// and `indexMethodOf` reads the two the same way precisely so nothing regenerates.
|
|
290
|
+
...(index.using === undefined || index.using === 'btree' ? {} : { using: index.using }),
|
|
215
291
|
};
|
|
216
292
|
}),
|
|
293
|
+
// The one index nobody declared and every search needs. Through the SAME `IndexInit` path a
|
|
294
|
+
// hand-written `using: 'gin'` takes — `indexName` gives it the method discriminator, so it can
|
|
295
|
+
// never collide with a btree an author declares on the same column.
|
|
296
|
+
...(search === null
|
|
297
|
+
? []
|
|
298
|
+
: [
|
|
299
|
+
{
|
|
300
|
+
name: indexName(name, table, [search.column], false, undefined, null, 'gin'),
|
|
301
|
+
columns: [search.column],
|
|
302
|
+
unique: false,
|
|
303
|
+
using: 'gin' as IndexMethod,
|
|
304
|
+
},
|
|
305
|
+
]),
|
|
217
306
|
];
|
|
218
|
-
|
|
307
|
+
/**
|
|
308
|
+
* A foreign key already indexes its column; naming it again in `indexes` is not two indexes.
|
|
309
|
+
*
|
|
310
|
+
* On the WHOLE definition and not on the name. With the discriminator above the two rules agree
|
|
311
|
+
* exactly, so this is not a behaviour change on its own — it is which one FAILS LOUDLY if the
|
|
312
|
+
* naming is ever weakened again. Matching on the name drops the second index in silence, which
|
|
313
|
+
* is how two different partial indexes became one for three majors; matching on the definition
|
|
314
|
+
* keeps both, and two `create index` statements sharing a name is `42P07` on the next migration.
|
|
315
|
+
*/
|
|
316
|
+
const identity = (index: IndexDef): string =>
|
|
317
|
+
[
|
|
318
|
+
index.name,
|
|
319
|
+
index.columns.join(','),
|
|
320
|
+
index.unique,
|
|
321
|
+
index.order ?? '',
|
|
322
|
+
index.where ?? '',
|
|
323
|
+
index.using ?? '',
|
|
324
|
+
].join('|');
|
|
219
325
|
const indexes: readonly IndexDef[] = declared.filter(
|
|
220
|
-
(index, position) =>
|
|
326
|
+
(index, position) =>
|
|
327
|
+
declared.findIndex((other) => identity(other) === identity(index)) === position,
|
|
221
328
|
);
|
|
222
329
|
|
|
223
330
|
const tags = [cacheTag, ...(init.tags ?? [])];
|
|
@@ -233,6 +340,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
233
340
|
cacheTag,
|
|
234
341
|
softDelete,
|
|
235
342
|
tenantColumn,
|
|
343
|
+
search,
|
|
236
344
|
});
|
|
237
345
|
const references = (): readonly ReferenceDescription[] => describeReferences(name, entries);
|
|
238
346
|
|
|
@@ -277,6 +385,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
277
385
|
$cacheTag: cacheTag,
|
|
278
386
|
$softDelete: softDelete,
|
|
279
387
|
$tenantColumn: tenantColumn,
|
|
388
|
+
$search: search,
|
|
280
389
|
$schema: {
|
|
281
390
|
'~standard': {
|
|
282
391
|
version: 1,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The one column that may declare a state machine, and the only one that could: `enumerated()`
|
|
2
|
+
// already declares the closed set of values a machine moves through, as a CHECK the migration
|
|
3
|
+
// emits. Split from `columns.ts` at the 500-line ceiling, along the seam the extra chain draws.
|
|
4
|
+
|
|
5
|
+
import { BARE, makeColumn } from './column';
|
|
6
|
+
import { got, oneOf } from './column-values';
|
|
7
|
+
import { refuseColumn } from './refuse';
|
|
8
|
+
import { stateMachineOf } from './state-machine';
|
|
9
|
+
import type { ColumnMeta, EnumeratedColumn } from './types';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A closed set of strings, emitted as a CHECK rather than a Postgres `ENUM` type: adding a
|
|
13
|
+
* variant is then a one-line migration instead of `ALTER TYPE`, which cannot run inside a
|
|
14
|
+
* transaction on older servers.
|
|
15
|
+
*/
|
|
16
|
+
export const enumerated = <const V extends readonly string[]>(values: V): EnumeratedColumn<V> => {
|
|
17
|
+
const allowed = new Set<string>(values);
|
|
18
|
+
const parse = (value: unknown): V[number] =>
|
|
19
|
+
typeof value === 'string' && allowed.has(value)
|
|
20
|
+
? value
|
|
21
|
+
: refuseColumn(
|
|
22
|
+
'enum',
|
|
23
|
+
`expected one of ${values.join(' | ')}, ${got(value)}`,
|
|
24
|
+
'store one of the values enumerated() declares, or add the new variant to that list and run x db gen "extend the enum check" — the values are a CHECK constraint, so the table moves with them',
|
|
25
|
+
);
|
|
26
|
+
return enumeratedWith<V, false>(
|
|
27
|
+
{ ...BARE, kind: 'text', values, check: oneOf(values) },
|
|
28
|
+
values,
|
|
29
|
+
parse,
|
|
30
|
+
false,
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every link delegates to the general chain and re-wraps its `$meta`, so there is one definition of
|
|
36
|
+
* what `.default()` accepts and of how `.column()` validates a name — this file adds only the two
|
|
37
|
+
* rules the general chain cannot know: a machine may be declared here, and a column carrying one
|
|
38
|
+
* may not hold NULL.
|
|
39
|
+
*/
|
|
40
|
+
const enumeratedWith = <V extends readonly string[], Optional extends boolean>(
|
|
41
|
+
meta: ColumnMeta,
|
|
42
|
+
values: V,
|
|
43
|
+
parse: (value: unknown) => V[number],
|
|
44
|
+
optional: Optional,
|
|
45
|
+
): EnumeratedColumn<V, Optional> => {
|
|
46
|
+
const base = makeColumn<V[number], Optional>(meta, parse, optional);
|
|
47
|
+
return {
|
|
48
|
+
...base,
|
|
49
|
+
transitions: (table) => {
|
|
50
|
+
// Refused in BOTH directions, so neither order of the chain can produce the column that has
|
|
51
|
+
// no answer: NULL is not one of the declared states, so nothing could say what it may move
|
|
52
|
+
// to — and the compare-and-set the write path uses compares it with `=`, where NULL matches
|
|
53
|
+
// no row at all and every transition out of it would read as a conflict.
|
|
54
|
+
if (!meta.notNull) {
|
|
55
|
+
refuseColumn(
|
|
56
|
+
'transitions',
|
|
57
|
+
'a state machine column may not hold null — null is not one of the declared states',
|
|
58
|
+
'drop .nullable() from this column, or drop .transitions() — a row outside every state has no legal move',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return enumeratedWith<V, Optional>(
|
|
62
|
+
{ ...meta, machine: stateMachineOf(values, table) },
|
|
63
|
+
values,
|
|
64
|
+
parse,
|
|
65
|
+
optional,
|
|
66
|
+
);
|
|
67
|
+
},
|
|
68
|
+
default: (value) => enumeratedWith<V, true>(base.default(value).$meta, values, parse, true),
|
|
69
|
+
column: (name) => enumeratedWith<V, Optional>(base.column(name).$meta, values, parse, optional),
|
|
70
|
+
nullable: () => {
|
|
71
|
+
if (meta.machine !== undefined) {
|
|
72
|
+
refuseColumn(
|
|
73
|
+
'transitions',
|
|
74
|
+
'a state machine column may not hold null — null is not one of the declared states',
|
|
75
|
+
'drop .nullable() from this column, or drop .transitions() — a row outside every state has no legal move',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return base.nullable();
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
};
|
package/src/errors.ts
CHANGED
|
@@ -18,6 +18,14 @@ export const ENTITY_OWNED_ERROR_CODES = [
|
|
|
18
18
|
'X_N_PLUS_ONE_QUERY',
|
|
19
19
|
'X_N_PLUS_ONE_WRITE',
|
|
20
20
|
'X_REPO_CLIENT_PINNED',
|
|
21
|
+
'X_AGGREGATE_UNSUPPORTED',
|
|
22
|
+
'X_AGGREGATE_MIXED_CURRENCY',
|
|
23
|
+
'X_APPROXIMATE_COUNT_FILTERED',
|
|
24
|
+
'X_SEARCH_UNDECLARED',
|
|
25
|
+
'X_SEARCH_IN_MEMORY',
|
|
26
|
+
'X_STATE_UNDECLARED',
|
|
27
|
+
'X_STATE_TRANSITION_ILLEGAL',
|
|
28
|
+
'X_STATE_CONFLICT',
|
|
21
29
|
] as const;
|
|
22
30
|
|
|
23
31
|
/**
|
|
@@ -52,6 +60,14 @@ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityOwnedErrorCode, string>>
|
|
|
52
60
|
X_N_PLUS_ONE_QUERY: 'a read repeated once per row',
|
|
53
61
|
X_N_PLUS_ONE_WRITE: 'a write repeated once per row',
|
|
54
62
|
X_REPO_CLIENT_PINNED: 'a repository pinned to its own client cannot join the open transaction',
|
|
63
|
+
X_AGGREGATE_UNSUPPORTED: 'that column has no aggregate both drivers can answer alike',
|
|
64
|
+
X_AGGREGATE_MIXED_CURRENCY: 'an amount was aggregated across currencies',
|
|
65
|
+
X_APPROXIMATE_COUNT_FILTERED: 'an estimate was asked of a filtered chain',
|
|
66
|
+
X_SEARCH_UNDECLARED: 'this entity has no searchable column',
|
|
67
|
+
X_SEARCH_IN_MEMORY: 'the in-memory driver cannot answer a full-text match',
|
|
68
|
+
X_STATE_UNDECLARED: 'that column declares no state machine',
|
|
69
|
+
X_STATE_TRANSITION_ILLEGAL: 'the machine has no such transition',
|
|
70
|
+
X_STATE_CONFLICT: 'the row is no longer in the state this transition named',
|
|
55
71
|
};
|
|
56
72
|
|
|
57
73
|
// Registered at module load, unconditionally, in one call. Without this the registry humanises the
|