@ultimat3/entity 12.0.0 → 14.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/entity.ts CHANGED
@@ -13,10 +13,13 @@ import { describeEntity, describeReferences } from './describe';
13
13
  import { invariantViolated } from './errors';
14
14
  import type { Expr, InvariantColumns, Resolve } from './expr';
15
15
  import { invariantColumns } from './expr';
16
+ import { indexName } from './index-name';
16
17
  import type { Invariant, InvariantDef } from './invariants';
17
- import { assertInvariants, bindInvariant, invariantsToSql } from './invariants';
18
+ import { assertInvariants, bindInvariant } from './invariants';
18
19
  import type { EntityDescription, ReferenceDescription } from './registry';
19
20
  import { registerEntity } from './registry';
21
+ import type { SearchInit, SearchSource, SearchVector } from './search';
22
+ import { searchVectorOf } from './search';
20
23
  import { resolveTenantColumn } from './tenancy';
21
24
  import type { AnyColumn, ColumnMap, ColumnMeta, IndexDef, RowOf } from './types';
22
25
  import type { EntityView } from './view';
@@ -73,6 +76,12 @@ export interface EntityInit<C extends ColumnMap> {
73
76
  */
74
77
  readonly invariants?: (columns: InvariantColumns<C>) => readonly InvariantDef[];
75
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;
76
85
  /** Extra cache tags this entity participates in, beyond its own. */
77
86
  readonly tags?: readonly string[];
78
87
  }
@@ -95,6 +104,11 @@ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
95
104
  readonly $softDelete: boolean;
96
105
  /** Property key of the tenant column, or `null`. Presence is what turns tenancy on. */
97
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;
98
112
  /** Phantom: `type Post = typeof posts.$row`. Reading it at runtime throws. */
99
113
  readonly $row: Row;
100
114
  /** The Standard Schema the columns already describe — forms and actions hand input to it. */
@@ -110,8 +124,10 @@ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
110
124
  $view<K extends keyof Row & string>(keys: readonly K[]): EntityView<Row, K>;
111
125
  /** Runs every invariant. Called by the repository on insert and update. */
112
126
  $assert(row: Row): void;
113
- /** The CHECK/UNIQUE statements the migration emits for this entity. */
114
- $migration(): string;
127
+ // No `$migration()`. The CHECK/UNIQUE statements an entity contributes are `@ultimat3/db`'s to
128
+ // render, off `$describe()`, beside the columns, indexes and foreign keys they have to be
129
+ // ordered against — a fragment of a migration is not one, and the one here named the wrong
130
+ // relation for three majors because nothing but its own test ever read it.
115
131
  $describe(): EntityDescription;
116
132
  /**
117
133
  * The foreign keys this entity declares, resolved — one record per `references()`, both ends
@@ -125,88 +141,6 @@ export type Entity<Row, C extends ColumnMap = ColumnMap> = EntityCore<Row, C> &
125
141
 
126
142
  const MONEY_PARTS = new Set(['minor', 'currency']);
127
143
 
128
- /**
129
- * What separates two indexes on the SAME columns: the predicate and the direction. Eight hex
130
- * characters of sha256 over both — deterministic across processes, so a name is a property of the
131
- * declaration and never of the run that generated it.
132
- */
133
- /**
134
- * What separates two indexes on the SAME columns: the predicate, the direction and the ACCESS
135
- * METHOD. Eight hex characters of sha256 over all three — deterministic across processes, so a
136
- * name is a property of the declaration and never of the run that generated it.
137
- *
138
- * The method belongs here for exactly the reason `where` does. A btree on an `arrayOf()` column
139
- * answers `=` and an ordering; a GIN on the same column answers `@>` / `<@` / `&&`. They are two
140
- * distinct indexes, and without the method in the name both are `<table>_<cols>_idx` — where the
141
- * dedup below drops one in silence (the defect this discriminator was added for) or, since that
142
- * dedup is now on the whole definition, two `create index` statements share one name and the
143
- * migration is `42P07`.
144
- */
145
- const indexDiscriminator = (
146
- order: string | undefined,
147
- where: string | null,
148
- using: string | undefined,
149
- ): string =>
150
- new Bun.CryptoHasher('sha256')
151
- // The method is APPENDED only when one was declared, never as an empty field: every name this
152
- // function has ever minted for a partial or ordered index is therefore unchanged by the method
153
- // existing, and an index that declares no method is byte-identical to the one it was.
154
- .update(`${order ?? ''}|${where ?? ''}${using === undefined ? '' : `|${using}`}`)
155
- .digest('hex')
156
- .slice(0, 8);
157
-
158
- /**
159
- * `<table>_<columns>_idx`, plus a discriminator when — and only when — the index carries a
160
- * predicate, a direction or a non-default access method.
161
- *
162
- * Only then, because the plain name is load-bearing in two places: `unique()` on a column is an
163
- * inline column clause and Postgres names the index it creates exactly `<table>_<column>_key`, so
164
- * a discriminator there would make the generator emit a second `create unique index` for an index
165
- * that already exists (`42P07`); and a foreign key's own index is deduped against a hand-declared
166
- * one by this name.
167
- *
168
- * Without it, two DIFFERENT partial indexes on one column were one name — `posts_author_id_idx`
169
- * for both `where status = 'published'` and `where status = 'draft'` — and the dedup below dropped
170
- * the second with no error, no warning and no drift finding, since a declared index is matched by
171
- * name.
172
- */
173
- /**
174
- * `NAMEDATALEN - 1`. Postgres truncates a longer identifier and says NOTHING, so two index names
175
- * sharing their first 63 bytes become one index on the server — the same silent collapse the
176
- * discriminator above exists to prevent, one layer down, and invisible to a drift check comparing
177
- * DECLARED names because those still differ. Bytes and not characters: 63 is what the server
178
- * counts, and `.length` would stop seeing the truncation the moment a name is not ASCII.
179
- */
180
- const MAX_IDENTIFIER_BYTES = 63;
181
-
182
- const byteLength = (value: string): number => new TextEncoder().encode(value).length;
183
-
184
- const indexName = (
185
- entityName: string,
186
- table: string,
187
- columns: readonly string[],
188
- unique: boolean,
189
- order?: string | undefined,
190
- where: string | null = null,
191
- using?: IndexMethod | undefined,
192
- ): string => {
193
- const suffix = unique ? 'key' : 'idx';
194
- const base = `${table}_${columns.join('_')}`;
195
- const plain = order === undefined && where === null && using === undefined;
196
- const name = plain
197
- ? `${base}_${suffix}`
198
- : `${base}_${indexDiscriminator(order, where, using)}_${suffix}`;
199
- const bytes = byteLength(name);
200
- if (bytes <= MAX_IDENTIFIER_BYTES) return name;
201
- throw invariantViolated(
202
- entityName,
203
- 'index',
204
- `the index on (${columns.join(', ')}) is named "${name}", which is ${bytes} bytes — ` +
205
- `Postgres truncates an identifier at ${MAX_IDENTIFIER_BYTES} and does not say so, ` +
206
- 'so two indexes can silently become one',
207
- );
208
- };
209
-
210
144
  const defaultValue = (meta: ColumnMeta): unknown => {
211
145
  const declared = meta.default;
212
146
  if (declared === undefined) return undefined;
@@ -230,6 +164,22 @@ export const entity = <const C extends ColumnMap>(
230
164
  const softDelete = Object.hasOwn(init.columns, SOFT_DELETE_COLUMN);
231
165
  const tenantColumn = resolveTenantColumn(name, init.columns, init.tenant);
232
166
 
167
+ const searchSources: readonly SearchSource[] = entries.flatMap(([property, column]) => {
168
+ const weight = column.$meta.searchable;
169
+ return weight === undefined ? [] : [{ column: columnName(property, column.$meta), weight }];
170
+ });
171
+ const search = searchVectorOf(
172
+ searchSources,
173
+ init.search,
174
+ // `physical`, not `candidate`: a parameter whose NAME reads like a credential is what
175
+ // `bun run secret-compare` refuses an `===` on, and this is a column name.
176
+ (physical) =>
177
+ entries.some(([property, column]) => columnName(property, column.$meta) === physical),
178
+ (subject, detail) => {
179
+ throw invariantViolated(name, subject, detail);
180
+ },
181
+ );
182
+
233
183
  const primaryKey =
234
184
  init.primaryKey ?? entries.filter(([, column]) => column.$meta.primaryKey).map(([key]) => key);
235
185
  if (primaryKey.length === 0) {
@@ -342,6 +292,19 @@ export const entity = <const C extends ColumnMap>(
342
292
  ...(index.using === undefined || index.using === 'btree' ? {} : { using: index.using }),
343
293
  };
344
294
  }),
295
+ // The one index nobody declared and every search needs. Through the SAME `IndexInit` path a
296
+ // hand-written `using: 'gin'` takes — `indexName` gives it the method discriminator, so it can
297
+ // never collide with a btree an author declares on the same column.
298
+ ...(search === null
299
+ ? []
300
+ : [
301
+ {
302
+ name: indexName(name, table, [search.column], false, undefined, null, 'gin'),
303
+ columns: [search.column],
304
+ unique: false,
305
+ using: 'gin' as IndexMethod,
306
+ },
307
+ ]),
345
308
  ];
346
309
  /**
347
310
  * A foreign key already indexes its column; naming it again in `indexes` is not two indexes.
@@ -379,6 +342,7 @@ export const entity = <const C extends ColumnMap>(
379
342
  cacheTag,
380
343
  softDelete,
381
344
  tenantColumn,
345
+ search,
382
346
  });
383
347
  const references = (): readonly ReferenceDescription[] => describeReferences(name, entries);
384
348
 
@@ -423,6 +387,7 @@ export const entity = <const C extends ColumnMap>(
423
387
  $cacheTag: cacheTag,
424
388
  $softDelete: softDelete,
425
389
  $tenantColumn: tenantColumn,
390
+ $search: search,
426
391
  $schema: {
427
392
  '~standard': {
428
393
  version: 1,
@@ -451,7 +416,6 @@ export const entity = <const C extends ColumnMap>(
451
416
  $view: <K extends keyof Row & string>(keys: readonly K[]) =>
452
417
  viewFor<Row, K>(name, init.columns, keys),
453
418
  $assert: (row) => assertInvariants(name, invariants, row),
454
- $migration: () => invariantsToSql(name, invariants),
455
419
  $describe: describe,
456
420
  $references: references,
457
421
  };
@@ -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
@@ -21,6 +21,11 @@ export const ENTITY_OWNED_ERROR_CODES = [
21
21
  'X_AGGREGATE_UNSUPPORTED',
22
22
  'X_AGGREGATE_MIXED_CURRENCY',
23
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',
24
29
  ] as const;
25
30
 
26
31
  /**
@@ -58,6 +63,11 @@ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityOwnedErrorCode, string>>
58
63
  X_AGGREGATE_UNSUPPORTED: 'that column has no aggregate both drivers can answer alike',
59
64
  X_AGGREGATE_MIXED_CURRENCY: 'an amount was aggregated across currencies',
60
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',
61
71
  };
62
72
 
63
73
  // Registered at module load, unconditionally, in one call. Without this the registry humanises the
@@ -0,0 +1,121 @@
1
+ // The refusals the two DECLARED capabilities raise at call time — full-text search and a state
2
+ // machine. Split from `errors.ts` at the 500-line ceiling; the codes and their titles stay there,
3
+ // because a registry with two homes is a registry that disagrees with itself.
4
+
5
+ import { EntityError } from './errors';
6
+
7
+ /**
8
+ * A `matches` predicate on an entity whose columns declare no `.searchable()`.
9
+ *
10
+ * Raised where the STATEMENT would be built, not where the chain was written, because both drivers
11
+ * reach it: an entity's search vector is derived from its columns, so there is nothing else the
12
+ * predicate could name and no vector to guess at.
13
+ */
14
+ export const searchUndeclared = (entityName: string): EntityError =>
15
+ new EntityError({
16
+ code: 'X_SEARCH_UNDECLARED',
17
+ cause: `${entityName} has no searchable column, so there is no tsvector to match against`,
18
+ fix: `add .searchable() to a text() column of ${entityName}, then: x db gen "search ${entityName}"`,
19
+ });
20
+
21
+ /**
22
+ * A full-text match asked of `memoryDriver()`. REFUSED rather than emulated, and that is the whole
23
+ * decision: `to_tsvector` stems, drops stop words and applies a language's own rules, and
24
+ * `websearch_to_tsquery` parses quoted phrases and `-`negation — a JS token comparison is a
25
+ * DIFFERENT question with the same shape, so it would answer green in a unit test and differently
26
+ * in production, which is the one outcome the two-driver split exists to prevent.
27
+ */
28
+ export const searchInMemory = (entityName: string): EntityError =>
29
+ new EntityError({
30
+ code: 'X_SEARCH_IN_MEMORY',
31
+ cause: `memoryDriver() cannot stem, weight or rank a tsvector, so a search of ${entityName} has no answer it could give that Postgres would agree with`,
32
+ fix: `move this read into a <name>.live.test.ts and run it with TEST_DATABASE_URL set — bun test packages/entity/src/pg-search.live.test.ts is the model`,
33
+ });
34
+
35
+ /**
36
+ * `transition()` on a column that declares no `.transitions()`.
37
+ *
38
+ * A declaration bug and not a caller's, which is why it lists the columns that DO declare one: the
39
+ * repair is naming a different column or writing the table, and both are edits to source.
40
+ */
41
+ export const stateUndeclared = (
42
+ entityName: string,
43
+ column: string,
44
+ machines: readonly string[],
45
+ ): EntityError =>
46
+ new EntityError({
47
+ code: 'X_STATE_UNDECLARED',
48
+ cause: `${entityName}.${column} declares no state machine, so there is no transition to check`,
49
+ fix:
50
+ machines.length === 0
51
+ ? `add .transitions(…) to ${entityName}.${column} — it must be an enumerated() column, and every value that set declares needs an entry`
52
+ : `${entityName} declares a machine on: ${machines.join(', ')}`,
53
+ });
54
+
55
+ /**
56
+ * Why a move is not in the machine. THREE conditions and one code, because they share one repair —
57
+ * the move is not in the table — and each states its own fact, because they are not the same
58
+ * mistake and the fix line differs.
59
+ *
60
+ * `unknown-state` is separate from `terminal` for a reason a test found: an unknown state has no
61
+ * outgoing moves either, so a single "no legal moves" branch reported a typo as "the row is
62
+ * terminal in <typo>" — a sentence about a state that does not exist. Reachable from JS, and from
63
+ * a `from` that came out of parsed JSON.
64
+ *
65
+ * `terminal` is separate from the ordinary case because "no legal moves" reads like a missing
66
+ * declaration and is not one: an empty list is how a terminal state is written.
67
+ */
68
+ export type IllegalTransition =
69
+ | { readonly reason: 'unknown-state'; readonly states: readonly string[] }
70
+ | { readonly reason: 'terminal' }
71
+ | { readonly reason: 'not-declared'; readonly legal: readonly string[] };
72
+
73
+ export const stateTransitionIllegal = (
74
+ entityName: string,
75
+ column: string,
76
+ from: string,
77
+ to: string,
78
+ detail: IllegalTransition,
79
+ ): EntityError => {
80
+ const subject = `${entityName}.${column}`;
81
+ if (detail.reason === 'unknown-state') {
82
+ return new EntityError({
83
+ code: 'X_STATE_TRANSITION_ILLEGAL',
84
+ cause: `"${from}" is not a state of ${subject} — it declares: ${detail.states.join(' | ')}`,
85
+ fix: `${entityName}.transition('${column}', id, { from: '${detail.states[0] ?? from}', to: '${to}' }) # name a state the enumerated() set declares`,
86
+ });
87
+ }
88
+ if (detail.reason === 'terminal') {
89
+ return new EntityError({
90
+ code: 'X_STATE_TRANSITION_ILLEGAL',
91
+ cause: `${subject} is terminal in "${from}": the machine declares no move out of it, so "${to}" is not one`,
92
+ fix: `move the row before it reaches "${from}", or add "${to}" to the "${from}" entry of the transitions() table`,
93
+ });
94
+ }
95
+ return new EntityError({
96
+ code: 'X_STATE_TRANSITION_ILLEGAL',
97
+ cause: `${subject} has no move from "${from}" to "${to}" — from "${from}" it may go to: ${detail.legal.join(', ')}`,
98
+ fix: `${entityName}.transition('${column}', id, { from: '${from}', to: '${detail.legal[0]}' }) # or add "${to}" to the "${from}" entry of the transitions() table`,
99
+ });
100
+ };
101
+
102
+ /**
103
+ * The conditional update matched no row, and the row is in a different state than the caller named.
104
+ *
105
+ * This is the lost update, caught: two callers both read "pending", both found the move legal, and
106
+ * the second one's statement carried `status = 'pending'` in its predicate and matched nothing. The
107
+ * state in the cause is READ BACK after the refusal, so it is a diagnosis and never the decision —
108
+ * the decision was the statement, and it was atomic.
109
+ */
110
+ export const stateConflict = (
111
+ entityName: string,
112
+ column: string,
113
+ id: string,
114
+ expected: string,
115
+ actual: string,
116
+ ): EntityError =>
117
+ new EntityError({
118
+ code: 'X_STATE_CONFLICT',
119
+ cause: `${entityName}.${column} named "${expected}" for row ${id}, which is in "${actual}" — something moved it first`,
120
+ fix: `re-read the row and decide again against "${actual}": ${entityName}.findById(id) — a transition names the state it expects, so a stale read is refused rather than overwritten`,
121
+ });
@@ -0,0 +1,83 @@
1
+ // What an index is CALLED. Split out of `entity.ts` at the 500-line ceiling, and it is one job:
2
+ // two indexes that differ only in their predicate, their direction or their access method must not
3
+ // share a name, and no name may cross the 63 bytes Postgres silently truncates at.
4
+
5
+ import type { IndexMethod } from '@ultimat3/db';
6
+ import { invariantViolated } from './errors';
7
+
8
+ /**
9
+ * What separates two indexes on the SAME columns: the predicate, the direction and the ACCESS
10
+ * METHOD. Eight hex characters of sha256 over all three — deterministic across processes, so a
11
+ * name is a property of the declaration and never of the run that generated it.
12
+ *
13
+ * The method belongs here for exactly the reason `where` does. A btree on an `arrayOf()` column
14
+ * answers `=` and an ordering; a GIN on the same column answers `@>` / `<@` / `&&`. They are two
15
+ * distinct indexes, and without the method in the name both are `<table>_<cols>_idx` — where the
16
+ * dedup below drops one in silence (the defect this discriminator was added for) or, since that
17
+ * dedup is now on the whole definition, two `create index` statements share one name and the
18
+ * migration is `42P07`.
19
+ */
20
+ const indexDiscriminator = (
21
+ order: string | undefined,
22
+ where: string | null,
23
+ using: string | undefined,
24
+ ): string =>
25
+ new Bun.CryptoHasher('sha256')
26
+ // The method is APPENDED only when one was declared, never as an empty field: every name this
27
+ // function has ever minted for a partial or ordered index is therefore unchanged by the method
28
+ // existing, and an index that declares no method is byte-identical to the one it was.
29
+ .update(`${order ?? ''}|${where ?? ''}${using === undefined ? '' : `|${using}`}`)
30
+ .digest('hex')
31
+ .slice(0, 8);
32
+
33
+ /**
34
+ * `<table>_<columns>_idx`, plus a discriminator when — and only when — the index carries a
35
+ * predicate, a direction or a non-default access method.
36
+ *
37
+ * Only then, because the plain name is load-bearing in two places: `unique()` on a column is an
38
+ * inline column clause and Postgres names the index it creates exactly `<table>_<column>_key`, so
39
+ * a discriminator there would make the generator emit a second `create unique index` for an index
40
+ * that already exists (`42P07`); and a foreign key's own index is deduped against a hand-declared
41
+ * one by this name.
42
+ *
43
+ * Without it, two DIFFERENT partial indexes on one column were one name — `posts_author_id_idx`
44
+ * for both `where status = 'published'` and `where status = 'draft'` — and the dedup below dropped
45
+ * the second with no error, no warning and no drift finding, since a declared index is matched by
46
+ * name.
47
+ */
48
+ /**
49
+ * `NAMEDATALEN - 1`. Postgres truncates a longer identifier and says NOTHING, so two index names
50
+ * sharing their first 63 bytes become one index on the server — the same silent collapse the
51
+ * discriminator above exists to prevent, one layer down, and invisible to a drift check comparing
52
+ * DECLARED names because those still differ. Bytes and not characters: 63 is what the server
53
+ * counts, and `.length` would stop seeing the truncation the moment a name is not ASCII.
54
+ */
55
+ const MAX_IDENTIFIER_BYTES = 63;
56
+
57
+ const byteLength = (value: string): number => new TextEncoder().encode(value).length;
58
+
59
+ export const indexName = (
60
+ entityName: string,
61
+ table: string,
62
+ columns: readonly string[],
63
+ unique: boolean,
64
+ order?: string | undefined,
65
+ where: string | null = null,
66
+ using?: IndexMethod | undefined,
67
+ ): string => {
68
+ const suffix = unique ? 'key' : 'idx';
69
+ const base = `${table}_${columns.join('_')}`;
70
+ const plain = order === undefined && where === null && using === undefined;
71
+ const name = plain
72
+ ? `${base}_${suffix}`
73
+ : `${base}_${indexDiscriminator(order, where, using)}_${suffix}`;
74
+ const bytes = byteLength(name);
75
+ if (bytes <= MAX_IDENTIFIER_BYTES) return name;
76
+ throw invariantViolated(
77
+ entityName,
78
+ 'index',
79
+ `the index on (${columns.join(', ')}) is named "${name}", which is ${bytes} bytes — ` +
80
+ `Postgres truncates an identifier at ${MAX_IDENTIFIER_BYTES} and does not say so, ` +
81
+ 'so two indexes can silently become one',
82
+ );
83
+ };
package/src/index.ts CHANGED
@@ -22,9 +22,6 @@ export {
22
22
  url,
23
23
  uuid,
24
24
  } from './columns';
25
- // The vocabulary an EXISTING schema needs. Separate from the blessed builders on purpose: those
26
- // are decisions this framework made for a table it was going to create, and these are the shapes
27
- // a table already has (`docs`: Entities-And-Migrations, "Adopting an existing database").
28
25
  export type { DecimalOptions } from './columns-data';
29
26
  export { arrayOf, bigint, bytes, date, decimal, json } from './columns-data';
30
27
  // `crossTenantReason` stays internal: an app that could read the flag would have a second way to
@@ -36,6 +33,9 @@ export type { DescribeInput } from './describe';
36
33
  export { sqlTypeOf } from './describe';
37
34
  export type { Entity, EntityCore, EntityInit, IndexInit } from './entity';
38
35
  export { entity, SOFT_DELETE_COLUMN } from './entity';
36
+ // The vocabulary an EXISTING schema needs. Separate from the blessed builders on purpose: those
37
+ // are decisions this framework made for a table it was going to create, and these are the shapes
38
+ // a table already has (`docs`: Entities-And-Migrations, "Adopting an existing database").
39
39
  export type {
40
40
  EntityErrorCode,
41
41
  PreloadCandidate,
@@ -65,15 +65,17 @@ export {
65
65
  writeUnfiltered,
66
66
  } from './errors';
67
67
  export type { ColumnExpr, Expr, InvariantColumns, Resolve } from './expr';
68
- export type { Invariant, InvariantDef, InvariantKind } from './invariants';
68
+ /** The two DECLARED capabilities' refusals a third-party driver raises the same ones. */
69
+ export type { IllegalTransition } from './feature-errors';
69
70
  export {
70
- assertInvariants,
71
- constraintName,
72
- invariant,
73
- invariantsToSql,
74
- MAX_ASSERTED_ROWS,
75
- toSql,
76
- } from './invariants';
71
+ searchInMemory,
72
+ searchUndeclared,
73
+ stateConflict,
74
+ stateTransitionIllegal,
75
+ stateUndeclared,
76
+ } from './feature-errors';
77
+ export type { Invariant, InvariantDef, InvariantKind } from './invariants';
78
+ export { assertInvariants, invariant, MAX_ASSERTED_ROWS } from './invariants';
77
79
  export { memoryRepo, memoryTransactor } from './memory-repo';
78
80
  export type { StatementLoop } from './n-plus-one';
79
81
  export { N_PLUS_ONE_THRESHOLD, nPlusOne, preloadsFor } from './n-plus-one';
@@ -116,6 +118,20 @@ export type {
116
118
  } from './repo';
117
119
  export type { RowBulkChange, RowChange, RowChangeOp, RowObserver } from './row-observer';
118
120
  export { observedRepo, rowObserver, setRowObserver } from './row-observer';
121
+ // Full-text search. The LANGUAGE set and the weights are values an app reads to build a form;
122
+ // `SEARCH_PROPERTY` is what a `matches` predicate names, which a hand-built `QueryPlan` needs.
123
+ export type { SearchInit, SearchLanguage, SearchSource, SearchVector } from './search';
124
+ export {
125
+ DEFAULT_SEARCH_COLUMN,
126
+ DEFAULT_SEARCH_LANGUAGE,
127
+ DEFAULT_SEARCH_WEIGHT,
128
+ isSearchLanguage,
129
+ isSearchWeight,
130
+ SEARCH_LANGUAGES,
131
+ SEARCH_PROPERTY,
132
+ SEARCH_WEIGHTS,
133
+ searchExpression,
134
+ } from './search';
119
135
  export type {
120
136
  Seed,
121
137
  SeedContext,
@@ -128,6 +144,16 @@ export type {
128
144
  SeedWrite,
129
145
  } from './seed';
130
146
  export { defineSeed, isSeed, SEED_TIERS, seedId, seedTiersFor } from './seed';
147
+ // A state machine over a column. The MECHANISM only: the table, the refusal, the terminal concept.
148
+ // The states are the app's `enumerated()` set and nothing here names one.
149
+ export type { StateMachine, TransitionTable } from './state-machine';
150
+ export {
151
+ canMove,
152
+ isState,
153
+ isTerminal,
154
+ movesFrom,
155
+ stateMachineOf,
156
+ } from './state-machine';
131
157
  export type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
132
158
  export {
133
159
  assertRowTenant,
@@ -141,6 +167,7 @@ export {
141
167
  scopedPlan,
142
168
  tenantColumnOf,
143
169
  } from './tenancy';
170
+ export type { Move } from './transition';
144
171
  export type {
145
172
  AnyColumn,
146
173
  Column,
@@ -148,6 +175,7 @@ export type {
148
175
  ColumnKind,
149
176
  ColumnMap,
150
177
  ColumnMeta,
178
+ EnumeratedColumn,
151
179
  IdOf,
152
180
  IndexDef,
153
181
  Insertable,
@@ -158,6 +186,8 @@ export type {
158
186
  ReferenceOptions,
159
187
  RowOf,
160
188
  RowPatch,
189
+ RowWrite,
190
+ SearchWeight,
161
191
  TimestampColumn,
162
192
  TypeOf,
163
193
  UuidColumn,
package/src/invariants.ts CHANGED
@@ -69,28 +69,13 @@ export const bindInvariant = <T>(
69
69
  };
70
70
  };
71
71
 
72
- export const constraintName = (
73
- table: string,
74
- inv: { readonly name: string; readonly kind: InvariantKind },
75
- ): string => `${table}_${inv.name}_${inv.kind === 'unique' ? 'key' : 'check'}`;
76
-
77
- /** The DDL the migration emits. One statement, terminated, ready to diff. */
78
- export const toSql = <T>(table: string, inv: Invariant<T>): string | null => {
79
- if (inv.sql === null) return null;
80
- const name = constraintName(table, inv);
81
- if (inv.kind === 'check') {
82
- return `ALTER TABLE "${table}" ADD CONSTRAINT "${name}" CHECK (${inv.sql});`;
83
- }
84
- const where = inv.where === undefined ? '' : ` WHERE ${inv.where}`;
85
- const columns = inv.columns.map((column) => `"${column}"`).join(', ');
86
- return `CREATE UNIQUE INDEX "${name}" ON "${table}" (${columns})${where};`;
87
- };
88
-
89
- export const invariantsToSql = <T>(table: string, invariants: readonly Invariant<T>[]): string =>
90
- invariants
91
- .map((inv) => toSql(table, inv))
92
- .filter((statement): statement is string => statement !== null)
93
- .join('\n');
72
+ // The DDL an invariant becomes is NOT rendered here. `@ultimat3/db` owns it — `constraintNameFor`,
73
+ // `declaredChecks` and `declaredIndexes` (`invariant-ddl.ts`), reading `$describe()` — and this
74
+ // package rendered a second copy of the same fact until 2026-08-25. The copy is what made the case
75
+ // for one renderer: it was reachable only through `entity.$migration()`, which passed the entity
76
+ // NAME where the table belongs, so `entity('account', { table: 'legacy_accounts' })` rendered
77
+ // `ALTER TABLE "account" ADD CONSTRAINT "account_seats_non_negative_check" …` a relation that
78
+ // does not exist and a constraint name no migration ever wrote. Never re-render it here.
94
79
 
95
80
  /**
96
81
  * Whether any rule here can only be judged in the app. This is what decides whether a FILTERED