@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/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
- /** Stringified so the cursor is JSON; `revive` restores the type from the column's kind. */
93
- const serializeSortValue = (value: unknown): string => {
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
- return new Date(text);
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 when a cursor is minted as well as when one is decoded: an ordering that cannot carry
129
- * a position is the author's mistake, and reporting it on the *second* page hides it behind
130
- * whatever page size the caller happened to use.
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 nullable and cannot carry a cursor order by a not-null column ` +
145
- `(add .orderBy('${entity.$primaryKey[0] ?? 'id'}') or make ${key.column} not null)`,
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
- /** The cursor that continues this plan after `row`. Signed by core, scoped by the plan. */
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) => serializeSortValue(valueAt(row, entry.column))),
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
- reviveSortValue(kindAt(entity, entry.column), String(key[index])),
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: input.columns.flatMap(([property, column]) =>
174
- describeColumn(input, property, column.$meta, references.get(property)),
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
- const table = init.table === undefined ? name : assertColumnName(init.table);
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
- { name: indexName(table, physical, meta.unique), columns: physical, unique: meta.unique },
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
- // A foreign key already indexes its column; naming it again in `indexes` is not two indexes.
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) => declared.findIndex((other) => other.name === index.name) === 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