@ultimat3/entity
Advanced tools
| // Two things every column builder needs and neither owns: how a rejected value is DESCRIBED, and | ||
| // the CHECK a closed set of values emits. Here rather than in `columns.ts` so `enum-column.ts` can | ||
| // read them without importing the file that imports it. | ||
| import { describeValue } from '@ultimat3/schema'; | ||
| /** | ||
| * The rejected value, rendered as its SHAPE and never its content — `@ultimat3/schema`'s | ||
| * `describeValue`, the same renderer every builtin validator fails through, so a column and a | ||
| * schema describe one bad value the same way. | ||
| * | ||
| * WHY it is not `String(value)`: a column rejection is not a private diagnostic. It becomes | ||
| * `X_INVARIANT_VIOLATED`'s `cause` and a `$view` issue, which `@ultimat3/http` returns to the | ||
| * caller AND writes into the log line — and core's logger redacts by KEY, so a value baked into a | ||
| * message has no key left to redact. `text()` on a password field wrote the mistyped password to | ||
| * the central log index in cleartext and into the user's own network tab; a `uuid()` holding an | ||
| * API key surrogate does the same. A column is the worse half of that pair, because the value can | ||
| * arrive from the DATABASE — so the leak is not bounded by what someone just typed. | ||
| * | ||
| * `got` stays `got` and the "expected …" half is untouched: only what follows it changes. | ||
| */ | ||
| export const got = (value: unknown): string => `got ${describeValue(value)}`; | ||
| const quote = (value: string): string => `'${value.replaceAll("'", "''")}'`; | ||
| export const oneOf = | ||
| (values: readonly string[]) => | ||
| (name: string): string => | ||
| `${name} in (${values.map(quote).join(', ')})`; |
| // The one column that may declare a state machine, and the only one that could: `enumerated()` | ||
| // already declares the closed set of values a machine moves through, as a CHECK the migration | ||
| // emits. Split from `columns.ts` at the 500-line ceiling, along the seam the extra chain draws. | ||
| import { BARE, makeColumn } from './column'; | ||
| import { got, oneOf } from './column-values'; | ||
| import { refuseColumn } from './refuse'; | ||
| import { stateMachineOf } from './state-machine'; | ||
| import type { ColumnMeta, EnumeratedColumn } from './types'; | ||
| /** | ||
| * A closed set of strings, emitted as a CHECK rather than a Postgres `ENUM` type: adding a | ||
| * variant is then a one-line migration instead of `ALTER TYPE`, which cannot run inside a | ||
| * transaction on older servers. | ||
| */ | ||
| export const enumerated = <const V extends readonly string[]>(values: V): EnumeratedColumn<V> => { | ||
| const allowed = new Set<string>(values); | ||
| const parse = (value: unknown): V[number] => | ||
| typeof value === 'string' && allowed.has(value) | ||
| ? value | ||
| : refuseColumn( | ||
| 'enum', | ||
| `expected one of ${values.join(' | ')}, ${got(value)}`, | ||
| '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', | ||
| ); | ||
| return enumeratedWith<V, false>( | ||
| { ...BARE, kind: 'text', values, check: oneOf(values) }, | ||
| values, | ||
| parse, | ||
| false, | ||
| ); | ||
| }; | ||
| /** | ||
| * Every link delegates to the general chain and re-wraps its `$meta`, so there is one definition of | ||
| * what `.default()` accepts and of how `.column()` validates a name — this file adds only the two | ||
| * rules the general chain cannot know: a machine may be declared here, and a column carrying one | ||
| * may not hold NULL. | ||
| */ | ||
| const enumeratedWith = <V extends readonly string[], Optional extends boolean>( | ||
| meta: ColumnMeta, | ||
| values: V, | ||
| parse: (value: unknown) => V[number], | ||
| optional: Optional, | ||
| ): EnumeratedColumn<V, Optional> => { | ||
| const base = makeColumn<V[number], Optional>(meta, parse, optional); | ||
| return { | ||
| ...base, | ||
| transitions: (table) => { | ||
| // Refused in BOTH directions, so neither order of the chain can produce the column that has | ||
| // no answer: NULL is not one of the declared states, so nothing could say what it may move | ||
| // to — and the compare-and-set the write path uses compares it with `=`, where NULL matches | ||
| // no row at all and every transition out of it would read as a conflict. | ||
| if (!meta.notNull) { | ||
| refuseColumn( | ||
| 'transitions', | ||
| 'a state machine column may not hold null — null is not one of the declared states', | ||
| 'drop .nullable() from this column, or drop .transitions() — a row outside every state has no legal move', | ||
| ); | ||
| } | ||
| return enumeratedWith<V, Optional>( | ||
| { ...meta, machine: stateMachineOf(values, table) }, | ||
| values, | ||
| parse, | ||
| optional, | ||
| ); | ||
| }, | ||
| default: (value) => enumeratedWith<V, true>(base.default(value).$meta, values, parse, true), | ||
| column: (name) => enumeratedWith<V, Optional>(base.column(name).$meta, values, parse, optional), | ||
| nullable: () => { | ||
| if (meta.machine !== undefined) { | ||
| refuseColumn( | ||
| 'transitions', | ||
| 'a state machine column may not hold null — null is not one of the declared states', | ||
| 'drop .nullable() from this column, or drop .transitions() — a row outside every state has no legal move', | ||
| ); | ||
| } | ||
| return base.nullable(); | ||
| }, | ||
| }; | ||
| }; |
| // The refusals the two DECLARED capabilities raise at call time — full-text search and a state | ||
| // machine. Split from `errors.ts` at the 500-line ceiling; the codes and their titles stay there, | ||
| // because a registry with two homes is a registry that disagrees with itself. | ||
| import { EntityError } from './errors'; | ||
| /** | ||
| * A `matches` predicate on an entity whose columns declare no `.searchable()`. | ||
| * | ||
| * Raised where the STATEMENT would be built, not where the chain was written, because both drivers | ||
| * reach it: an entity's search vector is derived from its columns, so there is nothing else the | ||
| * predicate could name and no vector to guess at. | ||
| */ | ||
| export const searchUndeclared = (entityName: string): EntityError => | ||
| new EntityError({ | ||
| code: 'X_SEARCH_UNDECLARED', | ||
| cause: `${entityName} has no searchable column, so there is no tsvector to match against`, | ||
| fix: `add .searchable() to a text() column of ${entityName}, then: x db gen "search ${entityName}"`, | ||
| }); | ||
| /** | ||
| * A full-text match asked of `memoryDriver()`. REFUSED rather than emulated, and that is the whole | ||
| * decision: `to_tsvector` stems, drops stop words and applies a language's own rules, and | ||
| * `websearch_to_tsquery` parses quoted phrases and `-`negation — a JS token comparison is a | ||
| * DIFFERENT question with the same shape, so it would answer green in a unit test and differently | ||
| * in production, which is the one outcome the two-driver split exists to prevent. | ||
| */ | ||
| export const searchInMemory = (entityName: string): EntityError => | ||
| new EntityError({ | ||
| code: 'X_SEARCH_IN_MEMORY', | ||
| 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`, | ||
| 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`, | ||
| }); | ||
| /** | ||
| * `transition()` on a column that declares no `.transitions()`. | ||
| * | ||
| * A declaration bug and not a caller's, which is why it lists the columns that DO declare one: the | ||
| * repair is naming a different column or writing the table, and both are edits to source. | ||
| */ | ||
| export const stateUndeclared = ( | ||
| entityName: string, | ||
| column: string, | ||
| machines: readonly string[], | ||
| ): EntityError => | ||
| new EntityError({ | ||
| code: 'X_STATE_UNDECLARED', | ||
| cause: `${entityName}.${column} declares no state machine, so there is no transition to check`, | ||
| fix: | ||
| machines.length === 0 | ||
| ? `add .transitions(…) to ${entityName}.${column} — it must be an enumerated() column, and every value that set declares needs an entry` | ||
| : `${entityName} declares a machine on: ${machines.join(', ')}`, | ||
| }); | ||
| /** | ||
| * Why a move is not in the machine. THREE conditions and one code, because they share one repair — | ||
| * the move is not in the table — and each states its own fact, because they are not the same | ||
| * mistake and the fix line differs. | ||
| * | ||
| * `unknown-state` is separate from `terminal` for a reason a test found: an unknown state has no | ||
| * outgoing moves either, so a single "no legal moves" branch reported a typo as "the row is | ||
| * terminal in <typo>" — a sentence about a state that does not exist. Reachable from JS, and from | ||
| * a `from` that came out of parsed JSON. | ||
| * | ||
| * `terminal` is separate from the ordinary case because "no legal moves" reads like a missing | ||
| * declaration and is not one: an empty list is how a terminal state is written. | ||
| */ | ||
| export type IllegalTransition = | ||
| | { readonly reason: 'unknown-state'; readonly states: readonly string[] } | ||
| | { readonly reason: 'terminal' } | ||
| | { readonly reason: 'not-declared'; readonly legal: readonly string[] }; | ||
| export const stateTransitionIllegal = ( | ||
| entityName: string, | ||
| column: string, | ||
| from: string, | ||
| to: string, | ||
| detail: IllegalTransition, | ||
| ): EntityError => { | ||
| const subject = `${entityName}.${column}`; | ||
| if (detail.reason === 'unknown-state') { | ||
| return new EntityError({ | ||
| code: 'X_STATE_TRANSITION_ILLEGAL', | ||
| cause: `"${from}" is not a state of ${subject} — it declares: ${detail.states.join(' | ')}`, | ||
| fix: `${entityName}.transition('${column}', id, { from: '${detail.states[0] ?? from}', to: '${to}' }) # name a state the enumerated() set declares`, | ||
| }); | ||
| } | ||
| if (detail.reason === 'terminal') { | ||
| return new EntityError({ | ||
| code: 'X_STATE_TRANSITION_ILLEGAL', | ||
| cause: `${subject} is terminal in "${from}": the machine declares no move out of it, so "${to}" is not one`, | ||
| fix: `move the row before it reaches "${from}", or add "${to}" to the "${from}" entry of the transitions() table`, | ||
| }); | ||
| } | ||
| return new EntityError({ | ||
| code: 'X_STATE_TRANSITION_ILLEGAL', | ||
| cause: `${subject} has no move from "${from}" to "${to}" — from "${from}" it may go to: ${detail.legal.join(', ')}`, | ||
| fix: `${entityName}.transition('${column}', id, { from: '${from}', to: '${detail.legal[0]}' }) # or add "${to}" to the "${from}" entry of the transitions() table`, | ||
| }); | ||
| }; | ||
| /** | ||
| * The conditional update matched no row, and the row is in a different state than the caller named. | ||
| * | ||
| * This is the lost update, caught: two callers both read "pending", both found the move legal, and | ||
| * the second one's statement carried `status = 'pending'` in its predicate and matched nothing. The | ||
| * state in the cause is READ BACK after the refusal, so it is a diagnosis and never the decision — | ||
| * the decision was the statement, and it was atomic. | ||
| */ | ||
| export const stateConflict = ( | ||
| entityName: string, | ||
| column: string, | ||
| id: string, | ||
| expected: string, | ||
| actual: string, | ||
| ): EntityError => | ||
| new EntityError({ | ||
| code: 'X_STATE_CONFLICT', | ||
| cause: `${entityName}.${column} named "${expected}" for row ${id}, which is in "${actual}" — something moved it first`, | ||
| 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`, | ||
| }); |
| // What an index is CALLED. Split out of `entity.ts` at the 500-line ceiling, and it is one job: | ||
| // two indexes that differ only in their predicate, their direction or their access method must not | ||
| // share a name, and no name may cross the 63 bytes Postgres silently truncates at. | ||
| import type { IndexMethod } from '@ultimat3/db'; | ||
| import { invariantViolated } from './errors'; | ||
| /** | ||
| * What separates two indexes on the SAME columns: the predicate, the direction and the ACCESS | ||
| * METHOD. Eight hex characters of sha256 over all three — deterministic across processes, so a | ||
| * name is a property of the declaration and never of the run that generated it. | ||
| * | ||
| * The method belongs here for exactly the reason `where` does. A btree on an `arrayOf()` column | ||
| * answers `=` and an ordering; a GIN on the same column answers `@>` / `<@` / `&&`. They are two | ||
| * distinct indexes, and without the method in the name both are `<table>_<cols>_idx` — where the | ||
| * dedup below drops one in silence (the defect this discriminator was added for) or, since that | ||
| * dedup is now on the whole definition, two `create index` statements share one name and the | ||
| * migration is `42P07`. | ||
| */ | ||
| const indexDiscriminator = ( | ||
| order: string | undefined, | ||
| where: string | null, | ||
| using: string | undefined, | ||
| ): string => | ||
| new Bun.CryptoHasher('sha256') | ||
| // The method is APPENDED only when one was declared, never as an empty field: every name this | ||
| // function has ever minted for a partial or ordered index is therefore unchanged by the method | ||
| // existing, and an index that declares no method is byte-identical to the one it was. | ||
| .update(`${order ?? ''}|${where ?? ''}${using === undefined ? '' : `|${using}`}`) | ||
| .digest('hex') | ||
| .slice(0, 8); | ||
| /** | ||
| * `<table>_<columns>_idx`, plus a discriminator when — and only when — the index carries a | ||
| * predicate, a direction or a non-default access method. | ||
| * | ||
| * Only then, because the plain name is load-bearing in two places: `unique()` on a column is an | ||
| * inline column clause and Postgres names the index it creates exactly `<table>_<column>_key`, so | ||
| * a discriminator there would make the generator emit a second `create unique index` for an index | ||
| * that already exists (`42P07`); and a foreign key's own index is deduped against a hand-declared | ||
| * one by this name. | ||
| * | ||
| * Without it, two DIFFERENT partial indexes on one column were one name — `posts_author_id_idx` | ||
| * for both `where status = 'published'` and `where status = 'draft'` — and the dedup below dropped | ||
| * the second with no error, no warning and no drift finding, since a declared index is matched by | ||
| * name. | ||
| */ | ||
| /** | ||
| * `NAMEDATALEN - 1`. Postgres truncates a longer identifier and says NOTHING, so two index names | ||
| * sharing their first 63 bytes become one index on the server — the same silent collapse the | ||
| * discriminator above exists to prevent, one layer down, and invisible to a drift check comparing | ||
| * DECLARED names because those still differ. Bytes and not characters: 63 is what the server | ||
| * counts, and `.length` would stop seeing the truncation the moment a name is not ASCII. | ||
| */ | ||
| const MAX_IDENTIFIER_BYTES = 63; | ||
| const byteLength = (value: string): number => new TextEncoder().encode(value).length; | ||
| export const indexName = ( | ||
| entityName: string, | ||
| table: string, | ||
| columns: readonly string[], | ||
| unique: boolean, | ||
| order?: string | undefined, | ||
| where: string | null = null, | ||
| using?: IndexMethod | undefined, | ||
| ): string => { | ||
| const suffix = unique ? 'key' : 'idx'; | ||
| const base = `${table}_${columns.join('_')}`; | ||
| const plain = order === undefined && where === null && using === undefined; | ||
| const name = plain | ||
| ? `${base}_${suffix}` | ||
| : `${base}_${indexDiscriminator(order, where, using)}_${suffix}`; | ||
| const bytes = byteLength(name); | ||
| if (bytes <= MAX_IDENTIFIER_BYTES) return name; | ||
| throw invariantViolated( | ||
| entityName, | ||
| 'index', | ||
| `the index on (${columns.join(', ')}) is named "${name}", which is ${bytes} bytes — ` + | ||
| `Postgres truncates an identifier at ${MAX_IDENTIFIER_BYTES} and does not say so, ` + | ||
| 'so two indexes can silently become one', | ||
| ); | ||
| }; |
+153
| // The full-text search vector an entity DERIVES from its `.searchable()` columns: one generated | ||
| // `tsvector` column, one language, one weight per source. Everything spliced into the expression | ||
| // here comes from a closed set or from a physical column name `assertColumnName` already checked — | ||
| // a search TERM never reaches this file, because a term is bound as a parameter (`pg-sql.ts`). | ||
| import type { SearchWeight } from './types'; | ||
| /** | ||
| * Postgres' own default text search configurations, as `\dF` lists them on 13 and later. A CLOSED | ||
| * set because the configuration is the one part of `to_tsvector(config, text)` that cannot be a | ||
| * bound parameter inside a generated column — it is spliced — so it may only ever be a value this | ||
| * file already contains. A server without one of these answers `3F000` at `create table`, which is | ||
| * loud and lands on the author, not on a search. | ||
| */ | ||
| export const SEARCH_LANGUAGES = [ | ||
| 'arabic', | ||
| 'armenian', | ||
| 'basque', | ||
| 'catalan', | ||
| 'danish', | ||
| 'dutch', | ||
| 'english', | ||
| 'finnish', | ||
| 'french', | ||
| 'german', | ||
| 'greek', | ||
| 'hindi', | ||
| 'hungarian', | ||
| 'indonesian', | ||
| 'irish', | ||
| 'italian', | ||
| 'lithuanian', | ||
| 'nepali', | ||
| 'norwegian', | ||
| 'portuguese', | ||
| 'romanian', | ||
| 'russian', | ||
| 'serbian', | ||
| 'simple', | ||
| 'spanish', | ||
| 'swedish', | ||
| 'tamil', | ||
| 'turkish', | ||
| 'yiddish', | ||
| ] as const; | ||
| export type SearchLanguage = (typeof SEARCH_LANGUAGES)[number]; | ||
| /** The one membership test. `includes` on the tuple, never a computed read of a table. */ | ||
| export const isSearchLanguage = (value: unknown): value is SearchLanguage => | ||
| typeof value === 'string' && (SEARCH_LANGUAGES as readonly string[]).includes(value); | ||
| export const SEARCH_WEIGHTS = ['A', 'B', 'C', 'D'] as const; | ||
| export const isSearchWeight = (value: unknown): value is SearchWeight => | ||
| typeof value === 'string' && (SEARCH_WEIGHTS as readonly string[]).includes(value); | ||
| /** Postgres' own default weight, so an unweighted source ranks exactly as an unweighted vector. */ | ||
| export const DEFAULT_SEARCH_WEIGHT: SearchWeight = 'D'; | ||
| export const DEFAULT_SEARCH_LANGUAGE: SearchLanguage = 'english'; | ||
| export const DEFAULT_SEARCH_COLUMN = 'search_tsv'; | ||
| /** | ||
| * What a `matches` predicate names instead of a column. `$`-prefixed for the reason every member | ||
| * of `EntityCore` is: `assertColumnName` requires `[a-z_]` first, so no declared column can ever | ||
| * be spelled this, and a `matches` predicate can therefore never be confused with one on a real | ||
| * column. Nothing resolves it through `physicalName` — both drivers branch on the OPERATOR. | ||
| */ | ||
| export const SEARCH_PROPERTY = '$search'; | ||
| export interface SearchSource { | ||
| /** Physical column, already through `assertColumnName`. */ | ||
| readonly column: string; | ||
| readonly weight: SearchWeight; | ||
| } | ||
| /** How an entity's search is declared, when the defaults do not fit the table it adopted. */ | ||
| export interface SearchInit { | ||
| /** The physical vector column, when `search_tsv` is taken or the table already named one. */ | ||
| readonly column?: string; | ||
| readonly language?: SearchLanguage; | ||
| } | ||
| export interface SearchVector { | ||
| /** The physical `tsvector` column. Never a row property. */ | ||
| readonly column: string; | ||
| readonly language: SearchLanguage; | ||
| readonly sources: readonly SearchSource[]; | ||
| /** The `generated always as (…) stored` body. Deterministic in declaration order. */ | ||
| readonly expression: string; | ||
| } | ||
| /** | ||
| * One `setweight(to_tsvector(…))` per source, concatenated in DECLARATION order. | ||
| * | ||
| * `setweight` even for a single unweighted source, so adding a second column never rewrites the | ||
| * first one's spelling — and a spelling change here is a `drop column` + `add column` on a table | ||
| * that may hold every row an app has. `coalesce(…, '')` because `to_tsvector` of NULL is NULL and | ||
| * `NULL || tsvector` is NULL: one nullable source would erase the whole vector for that row. | ||
| * | ||
| * Every function in it is immutable, which is what Postgres requires of a generated column — | ||
| * `to_tsvector(text)` with no configuration is NOT (it reads `default_text_search_config`), which | ||
| * is why the language is named here and never left to the server. | ||
| */ | ||
| export const searchExpression = ( | ||
| language: SearchLanguage, | ||
| sources: readonly SearchSource[], | ||
| ): string => | ||
| sources | ||
| .map( | ||
| (source) => | ||
| `setweight(to_tsvector('${language}', coalesce("${source.column}", '')), '${source.weight}')`, | ||
| ) | ||
| .join(' || '); | ||
| /** | ||
| * The vector a set of already-resolved sources describes, or `null` when there are none. | ||
| * | ||
| * The physical names arrive resolved and the collision check arrives as `taken`, so this module | ||
| * imports nothing from `column.ts` — which imports THIS one for `.searchable()`. A cycle between | ||
| * the column chain and the thing a column modifier declares is avoidable, so it is avoided. | ||
| */ | ||
| export const searchVectorOf = ( | ||
| sources: readonly SearchSource[], | ||
| init: SearchInit | undefined, | ||
| taken: (column: string) => boolean, | ||
| refuse: (subject: string, detail: string) => never, | ||
| ): SearchVector | null => { | ||
| if (sources.length === 0) { | ||
| if (init === undefined) return null; | ||
| return refuse( | ||
| 'search', | ||
| 'search is declared but no column is searchable — add .searchable() to a text() column, or drop the search option', | ||
| ); | ||
| } | ||
| const language = init?.language ?? DEFAULT_SEARCH_LANGUAGE; | ||
| if (!isSearchLanguage(language)) { | ||
| return refuse( | ||
| 'search', | ||
| `"${String(language)}" is not a Postgres text search configuration — one of: ${SEARCH_LANGUAGES.join(', ')}`, | ||
| ); | ||
| } | ||
| const column = init?.column ?? DEFAULT_SEARCH_COLUMN; | ||
| if (taken(column)) { | ||
| return refuse( | ||
| 'search', | ||
| `the search vector column "${column}" is already a declared column — rename it, or name another with search: { column: '<name>' }`, | ||
| ); | ||
| } | ||
| return { column, language, sources, expression: searchExpression(language, sources) }; | ||
| }; |
| // The MECHANISM half of a state machine on a column: the transition table, what a terminal state | ||
| // is, and the one legality question. The states themselves never ship — they arrive as the | ||
| // `enumerated()` set the column already declares, and nothing in this file knows what any of them | ||
| // means. An illegal transition is a defect in every business; an approval chain is not. | ||
| import { refuseColumn } from './refuse'; | ||
| /** | ||
| * Every state names the states it may move to. A MAPPED type over the union, so the exhaustiveness | ||
| * is the compiler's: a state left out, a key that is not a state and a target that is not a state | ||
| * are each a compile error at the declaration, and the runtime checks below are what a JS caller | ||
| * and a table built from parsed JSON get instead. | ||
| * | ||
| * A state with an empty list is TERMINAL. That is the whole of the terminal concept — nothing to | ||
| * declare, nothing to name, and no way for the framework to have an opinion about which one it is. | ||
| */ | ||
| export type TransitionTable<S extends string> = { readonly [K in S]: readonly S[] }; | ||
| export interface StateMachine<S extends string = string> { | ||
| /** The declared states, in declaration order. */ | ||
| readonly states: readonly S[]; | ||
| /** | ||
| * A `Map`, never the table object itself: `table[from]` with a caller's string answers an | ||
| * `Object.prototype` member, so `canMove(machine, 'constructor', …)` would read the `Object` | ||
| * function and every guard downstream would pass. The rule `bun run proto-index` enforces. | ||
| */ | ||
| readonly moves: ReadonlyMap<S, ReadonlySet<S>>; | ||
| /** Derived: every state whose outgoing set is empty. */ | ||
| readonly terminal: ReadonlySet<S>; | ||
| } | ||
| /** | ||
| * One `refuseColumn` site, five conditions, and the FIX comes from the caller — because a fix line | ||
| * carrying a `<placeholder>` is advice, not an edit, and `refuse.test.ts` refuses one. Every caller | ||
| * below names real states out of the set the column already declared, so each answer is pasteable. | ||
| */ | ||
| const refuse = (detail: string, fix: string): never => refuseColumn('transitions', detail, fix); | ||
| /** | ||
| * The machine a set of states and a table describe, validated once at declaration. | ||
| * | ||
| * Every rule here is structural: it is about whether the table describes a machine at all, never | ||
| * about which machine is the right one. A table missing a state cannot answer "may this row move", | ||
| * a self-loop is a transition that transitions nothing — and under the compare-and-set the write | ||
| * path uses it would report a move that did not happen — and a repeated target is a typo whose | ||
| * only effect is to make the declaration read as though it meant something. | ||
| */ | ||
| export const stateMachineOf = <S extends string>( | ||
| states: readonly S[], | ||
| table: TransitionTable<S>, | ||
| ): StateMachine<S> => { | ||
| const declared = new Set<string>(states); | ||
| const keys = Object.keys(table); | ||
| const unknown = keys.filter((key) => !declared.has(key)); | ||
| if (unknown.length > 0) { | ||
| refuse( | ||
| `${unknown.join(', ')} ${unknown.length === 1 ? 'is not one of' : 'are not among'} the declared states: ${states.join(' | ')}`, | ||
| `delete the ${unknown.map((key) => `"${key}"`).join(', ')} entry from transitions(), or add it to the enumerated([${states.map((state) => `'${state}'`).join(', ')}]) set on this column`, | ||
| ); | ||
| } | ||
| const named = new Set(keys); | ||
| const missing = states.filter((state) => !named.has(state)); | ||
| if (missing.length > 0) { | ||
| refuse( | ||
| `no entry for ${missing.join(', ')} — every state needs one, and a terminal state is written as an empty list`, | ||
| `add ${missing.map((state) => `${state}: []`).join(', ')} to transitions() — an empty list is how a state nothing leaves is written`, | ||
| ); | ||
| } | ||
| const moves = new Map<S, ReadonlySet<S>>(); | ||
| // `origin` and not `state`, which is the word this loop is about: `bun run secret-compare` reads | ||
| // a NAME, and `state` is in its vocabulary because an OAuth CSRF `state` is a credential compared | ||
| // with `===` — so a state machine, whose domain word is literally that, trips a rule written for | ||
| // a different thing. Renaming is the honest repair; a package-wide pin would spend the rule. | ||
| for (const origin of states) { | ||
| // Through `Object.hasOwn` even though the keys were just checked: this is the one read of a | ||
| // caller's object literal by a name, and the guard is what makes it a read of DATA. | ||
| const targets: readonly string[] = Object.hasOwn(table, origin) ? table[origin] : []; | ||
| const seen = new Set<S>(); | ||
| for (const target of targets) { | ||
| if (!declared.has(target)) { | ||
| refuse( | ||
| `${origin} may move to ${target}, which is not one of: ${states.join(' | ')}`, | ||
| `remove '${target}' from the ${origin} entry of transitions(), or add it to the enumerated([${states.map((each) => `'${each}'`).join(', ')}]) set on this column`, | ||
| ); | ||
| } | ||
| if (target === origin) { | ||
| refuse( | ||
| `${origin} lists itself as a target; a transition that changes nothing is not one`, | ||
| `remove '${origin}' from its own entry of transitions() — write ${origin}: [] if nothing leaves it`, | ||
| ); | ||
| } | ||
| if (seen.has(target as S)) { | ||
| refuse( | ||
| `${origin} lists ${target} twice`, | ||
| `remove the second '${target}' from the ${origin} entry of transitions()`, | ||
| ); | ||
| } | ||
| seen.add(target as S); | ||
| } | ||
| moves.set(origin, seen); | ||
| } | ||
| const terminal = new Set<S>(states.filter((state) => (moves.get(state)?.size ?? 0) === 0)); | ||
| return { states: [...states], moves, terminal }; | ||
| }; | ||
| /** Whether the machine holds this exact move. Unknown states answer `false`, never throw. */ | ||
| export const canMove = <S extends string>( | ||
| machine: StateMachine<S>, | ||
| from: string, | ||
| to: string, | ||
| ): boolean => machine.moves.get(from as S)?.has(to as S) === true; | ||
| export const isTerminal = <S extends string>(machine: StateMachine<S>, state: string): boolean => | ||
| machine.terminal.has(state as S); | ||
| /** | ||
| * Whether the machine declares this state at all — the question `isTerminal` cannot answer, and the | ||
| * reason it is asked FIRST at the call site. An unknown state has no outgoing moves either, so | ||
| * without this the refusal for a typo read "the row is terminal in <typo>", which is a sentence | ||
| * about a state that does not exist. | ||
| */ | ||
| export const isState = <S extends string>(machine: StateMachine<S>, state: string): boolean => | ||
| machine.moves.has(state as S); | ||
| /** Everywhere this state may go, in declaration order — what a refusal lists back at the caller. */ | ||
| export const movesFrom = <S extends string>( | ||
| machine: StateMachine<S>, | ||
| from: string, | ||
| ): readonly S[] => { | ||
| const targets = machine.moves.get(from as S); | ||
| return targets === undefined ? [] : machine.states.filter((state) => targets.has(state)); | ||
| }; |
| // One atomic move of one row through its column's state machine: the legality question answered | ||
| // from the declaration, the move made by a single conditional statement, and the diagnosis of a | ||
| // statement that matched nothing. Split from `query.ts` for the line ceiling and because this is | ||
| // the one write whose refusal is a READ — see `diagnose`. | ||
| import { columnFor } from './column'; | ||
| import type { EntityCore } from './entity'; | ||
| import { notFound } from './errors'; | ||
| import type { IllegalTransition } from './feature-errors'; | ||
| import { stateConflict, stateTransitionIllegal, stateUndeclared } from './feature-errors'; | ||
| import type { Repo, RepoOptions } from './repo'; | ||
| import { canMove, isState, isTerminal, movesFrom, type StateMachine } from './state-machine'; | ||
| import type { ColumnMap, IdOf, RowPatch } from './types'; | ||
| /** What a caller names: the state it believes the row is in, and the one it wants. */ | ||
| export interface Move<S extends string = string> { | ||
| readonly from: S; | ||
| readonly to: S; | ||
| } | ||
| /** Every property whose column declares a machine — what `X_STATE_UNDECLARED` lists back. */ | ||
| export const machineColumns = <Row, C extends ColumnMap>( | ||
| entity: EntityCore<Row, C>, | ||
| ): readonly string[] => | ||
| Object.entries(entity.$columns) | ||
| .filter(([, column]) => column.$meta.machine !== undefined) | ||
| .map(([property]) => property); | ||
| /** | ||
| * The machine on a named column, or the refusal. `columnFor` and not `$columns[property]`: the name | ||
| * is caller data on this path, and a plain read answers an `Object.prototype` member. | ||
| */ | ||
| export const machineFor = <Row, C extends ColumnMap>( | ||
| entity: EntityCore<Row, C>, | ||
| property: string, | ||
| ): StateMachine => { | ||
| const machine = columnFor(entity.$columns, property)?.$meta.machine; | ||
| if (machine === undefined) { | ||
| throw stateUndeclared(entity.$name, property, machineColumns(entity)); | ||
| } | ||
| return machine; | ||
| }; | ||
| /** | ||
| * Why the statement matched no row, asked only once it already has. | ||
| * | ||
| * A read AFTER the decision, never before one: the conditional update is what refused, and this | ||
| * exists so the refusal carries the state the row is really in instead of "0 rows". It is | ||
| * tenant-scoped like every other read, so a row belonging to another org reads as absent and the | ||
| * caller is told `X_NOT_FOUND` — which is the truth available to them, and the only answer that | ||
| * does not confirm the row exists somewhere. | ||
| */ | ||
| const diagnose = async <Row>( | ||
| entity: EntityCore<Row>, | ||
| repo: Repo<Row>, | ||
| property: string, | ||
| id: IdOf<Row>, | ||
| move: Move, | ||
| options: RepoOptions | undefined, | ||
| ): Promise<Error> => { | ||
| const row = await repo.findById(id, options); | ||
| if (row === null) return notFound(entity.$name, String(id)); | ||
| const actual = (row as Readonly<Record<string, unknown>>)[property]; | ||
| return stateConflict(entity.$name, property, String(id), move.from, String(actual)); | ||
| }; | ||
| const whyNot = (machine: StateMachine, move: Move): IllegalTransition => { | ||
| if (!isState(machine, move.from)) return { reason: 'unknown-state', states: machine.states }; | ||
| if (isTerminal(machine, move.from)) return { reason: 'terminal' }; | ||
| return { reason: 'not-declared', legal: movesFrom(machine, move.from) }; | ||
| }; | ||
| /** | ||
| * The move, made by ONE statement. | ||
| * | ||
| * The predicate carries the state the caller expects, so the state that was OBSERVED and the state | ||
| * that was WRITTEN are one decision the database made under its own row lock. A read-then-check- | ||
| * then-write is the same code with a window in it: two callers both read `pending`, both find the | ||
| * move legal, and both write — and the second write is a transition out of a state the row had | ||
| * already left. Here the second statement's `status = 'pending'` matches nothing, and no rows is | ||
| * the refusal. | ||
| * | ||
| * The legality question is answered before the statement rather than inside it, because the | ||
| * transition table is not in the database and does not belong there: it is a property of the | ||
| * declaration, so an illegal move is refused without a round trip and without touching the row. | ||
| * | ||
| * The row is READ BACK afterwards rather than returned by the statement. `updateWhere` answers a | ||
| * count in both drivers, and a second read is honest about what it is — the row as it stands now, | ||
| * which is the row this call moved unless something moved it again, and something moving it again | ||
| * is exactly what this design permits and reports. | ||
| */ | ||
| export const transitionRow = async <Row, C extends ColumnMap>( | ||
| entity: EntityCore<Row, C>, | ||
| repo: Repo<Row>, | ||
| property: string, | ||
| id: IdOf<Row>, | ||
| move: Move, | ||
| patch: (values: RowPatch<Row>) => RowPatch<Row>, | ||
| options: RepoOptions | undefined, | ||
| ): Promise<Row> => { | ||
| const machine = machineFor(entity, property); | ||
| if (!canMove(machine, move.from, move.to)) { | ||
| // Asked in this order and no other: an unknown state is terminal-looking (no outgoing moves) | ||
| // and a terminal state is legal-list-looking (an empty list), so a check that skipped either | ||
| // one would answer a true sentence about the wrong thing. | ||
| throw stateTransitionIllegal(entity.$name, property, move.from, move.to, whyNot(machine, move)); | ||
| } | ||
| // `as unknown as`, and the double step is the honest one: `RowPatch<Row>` is a mapped type over | ||
| // an UNRESOLVED `Row`, so it never reduces and no object literal is ever assignable to it — the | ||
| // same reason `expr.ts` and `@ultimat3/query`'s `paginate` spell theirs the same way. The column | ||
| // name came from `machineFor`, which resolved it against the entity, so the shape is a real one. | ||
| const filter = { id, [property]: move.from } as unknown as RowPatch<Row>; | ||
| const values = { [property]: move.to } as unknown as RowPatch<Row>; | ||
| const written = await repo.updateWhere(filter, patch(values), options); | ||
| if (written === 0) throw await diagnose(entity, repo, property, id, move, options); | ||
| const row = await repo.findById(id, options); | ||
| if (row === null) throw notFound(entity.$name, String(id)); | ||
| return row; | ||
| }; |
+5
-5
| { | ||
| "name": "@ultimat3/entity", | ||
| "version": "12.0.0", | ||
| "version": "13.0.0", | ||
| "description": "A table + its domain type + invariants the database also enforces", | ||
@@ -34,7 +34,7 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/core": "12.0.0", | ||
| "@ultimat3/db": "12.0.0", | ||
| "@ultimat3/schema": "12.0.0", | ||
| "@ultimat3/time": "12.0.0" | ||
| "@ultimat3/core": "13.0.0", | ||
| "@ultimat3/db": "13.0.0", | ||
| "@ultimat3/schema": "13.0.0", | ||
| "@ultimat3/time": "13.0.0" | ||
| } | ||
| } |
+86
-0
@@ -328,2 +328,88 @@ # @ultimat3/entity 🗄️ | ||
| ## Full-text search | ||
| `.searchable()` on a `text()` column puts it in the entity's **one** generated `tsvector`, with a | ||
| GIN index on it. Nothing else to declare, and no second column on the row. | ||
| ```ts | ||
| import { database, entity, text, timestamp, uuid } from '@ultimat3/entity'; | ||
| declare const orgId: string; | ||
| declare const term: string; // what the user typed, verbatim | ||
| const posts = entity('posts', { | ||
| columns: { | ||
| id: uuid().primaryKey(), | ||
| orgId: uuid().tenant(), | ||
| title: text({ max: 120 }).searchable('A'), // 'A' outranks 'D' under ts_rank | ||
| body: text().nullable().searchable(), // 'D' by default, Postgres' own | ||
| createdAt: timestamp().defaultNow(), | ||
| }, | ||
| // Only when the defaults do not fit: the column is `search_tsv`, the language is 'english'. | ||
| search: { column: 'search_tsv', language: 'english' }, | ||
| }); | ||
| const db = database({ posts }); | ||
| await db.posts.where({ orgId }).search(term).orderBy('createdAt', 'desc').limit(20).page(); | ||
| ``` | ||
| | Fact | Why | | ||
| |---|---| | ||
| | the term is a **bound parameter**, parsed by `websearch_to_tsquery` | `&`, `\|`, `!`, `:*` and an unbalanced paren are characters to match, never operators and never a `42601`. `plainto_tsquery` is safe too and silently discards `"a phrase"` and `-negation`; bare `to_tsquery` on user text is the injection | | ||
| | the language is spliced from a closed set (`SEARCH_LANGUAGES`) | `regconfig` cannot be a bound parameter inside a generated column, and `to_tsvector(text)` with no configuration is not immutable, so Postgres refuses it there | | ||
| | the column is `generated always as (…) stored`, `not null` | the database computes it on every write, including one made from psql | | ||
| | tenancy, soft delete, the projection, the order and the cursor are unchanged | `.search()` is one more predicate on the chain you already had | | ||
| | `memoryDriver()` **refuses** it — `X_SEARCH_IN_MEMORY` | stemming, stop words and a phrase parser are not a JS token comparison, and an answer memory could give is one Postgres would contradict. Assert a search in a `.live.test.ts` | | ||
| | relevance is **not** an order the chain serves | `ts_rank` is a computed value and a cursor carries columns; the order is the one you declared, and it pages | | ||
| ## A state machine over a column | ||
| `.transitions()` on an `enumerated()` column. The states are yours; the machine is the framework's. | ||
| ```ts | ||
| import { database, entity, enumerated, timestamp, uuid } from '@ultimat3/entity'; | ||
| declare const id: string; | ||
| const ORDER_STATES = ['pending', 'paid', 'shipped', 'delivered', 'cancelled'] as const; | ||
| const orders = entity('orders', { | ||
| columns: { | ||
| id: uuid().primaryKey(), | ||
| orgId: uuid().tenant(), | ||
| status: enumerated(ORDER_STATES) | ||
| .transitions({ | ||
| pending: ['paid', 'cancelled'], | ||
| paid: ['shipped', 'cancelled'], | ||
| shipped: ['delivered'], | ||
| delivered: [], // terminal — nothing leaves it, and an empty list is how you say so | ||
| cancelled: [], | ||
| }) | ||
| .default('pending'), | ||
| updatedAt: timestamp().defaultNow().onUpdateNow(), | ||
| }, | ||
| }); | ||
| const db = database({ orders }); | ||
| // One statement. `from` is the state you believe the row is in, and it rides in the predicate. | ||
| const shipped = await db.orders.transition('status', id, { from: 'paid', to: 'shipped' }); | ||
| ``` | ||
| | Fact | Why | | ||
| |---|---| | ||
| | the table is a **mapped type** over your `enumerated()` set | a missing state, an unknown key and an unknown target are compile errors — the framework never names a state | | ||
| | **one statement**, with `from` in its predicate | the state observed and the state written are one decision, under the row's lock. Two callers who both read `pending` cannot both move it: the second matches no row | | ||
| | a move the table does not hold is `X_STATE_TRANSITION_ILLEGAL`, before any statement | the table is a property of the declaration, so an illegal move never reaches the database | | ||
| | a row that moved first is `X_STATE_CONFLICT`, naming the state it is really in | read back **after** the refusal — a diagnosis, never the decision | | ||
| | another org's row is `X_NOT_FOUND`, never a conflict | a conflict would confirm the row exists and name its state | | ||
| | a terminal state is one with an empty list | derived. *Which* state is terminal is yours | | ||
| | `onUpdateNow()` moves, because a transition is an update | the audit of *when* it moved, with no second mechanism beside it | | ||
| | the CHECK comes from `enumerated()` | the machine emits no DDL of its own — one declaration of what a legal value is | | ||
| | `memoryDriver()` answers it exactly as Postgres does | a compare-and-set over a map is the same question; unlike a `tsvector` match, there is nothing to fake | | ||
| What is deliberately **not** here: who may make a move, what happens on arrival, an approval chain, | ||
| a reason code. Those differ per app — wrap `transition()` in your own function and put them there. | ||
| ## Counting by a column | ||
@@ -330,0 +416,0 @@ |
+22
-0
@@ -11,2 +11,3 @@ // The chain every column builder is made of. Each link returns a new column, so a chain reads | ||
| import { refuseColumn } from './refuse'; | ||
| import { DEFAULT_SEARCH_WEIGHT, isSearchWeight } from './search'; | ||
| import type { | ||
@@ -185,2 +186,23 @@ AnyColumn, | ||
| searchable: (weight = DEFAULT_SEARCH_WEIGHT) => { | ||
| // Refused where the chain was written, because the alternative is a `to_tsvector` over a cast | ||
| // the DDL cannot express: `to_tsvector` takes text, and a `jsonb` or a `timestamptz` reaching | ||
| // it is a `42883` inside `ROLE=migrate`, with the server's words and none of the column's. | ||
| if (meta.kind !== 'text') { | ||
| refuseColumn( | ||
| 'searchable', | ||
| `a ${meta.kind} column is not searchable — full text search reads text`, | ||
| 'text().searchable() — index a text() column, and store the searchable projection of a structured value in one of its own', | ||
| ); | ||
| } | ||
| if (!isSearchWeight(weight)) { | ||
| refuseColumn( | ||
| 'searchable', | ||
| `"${String(weight)}" is not a search weight`, | ||
| "text().searchable('A') — one of A, B, C or D, biggest first; omit it for D", | ||
| ); | ||
| } | ||
| return makeColumn<T, Optional>({ ...meta, searchable: weight }, parse, optional); | ||
| }, | ||
| tenant: () => makeColumn<T, Optional>({ ...meta, tenant: true, index: true }, parse, optional), | ||
@@ -187,0 +209,0 @@ |
+5
-46
@@ -8,3 +8,2 @@ // The blessed column builders. There is exactly one way to store an id, an instant, money, a | ||
| CURRENCY_CODE_PATTERN, | ||
| describeValue, | ||
| isCurrencyCode, | ||
@@ -23,2 +22,3 @@ isMoneyScale, | ||
| } from './column'; | ||
| import { got, oneOf } from './column-values'; | ||
| import { refuseColumn } from './refuse'; | ||
@@ -36,19 +36,2 @@ import type { | ||
| /** | ||
| * The rejected value, rendered as its SHAPE and never its content — `@ultimat3/schema`'s | ||
| * `describeValue`, the same renderer every builtin validator fails through, so a column and a | ||
| * schema describe one bad value the same way. | ||
| * | ||
| * WHY it is not `String(value)`: a column rejection is not a private diagnostic. It becomes | ||
| * `X_INVARIANT_VIOLATED`'s `cause` and a `$view` issue, which `@ultimat3/http` returns to the | ||
| * caller AND writes into the log line — and core's logger redacts by KEY, so a value baked into a | ||
| * message has no key left to redact. `text()` on a password field wrote the mistyped password to | ||
| * the central log index in cleartext and into the user's own network tab; a `uuid()` holding an | ||
| * API key surrogate does the same. A column is the worse half of that pair, because the value can | ||
| * arrive from the DATABASE — so the leak is not bounded by what someone just typed. | ||
| * | ||
| * `got` stays `got` and the "expected …" half is untouched: only what follows it changes. | ||
| */ | ||
| const got = (value: unknown): string => `got ${describeValue(value)}`; | ||
| /** uuid v7: time-ordered, so a primary key index stays append-friendly. */ | ||
@@ -158,31 +141,3 @@ export const newId = (): string => uuidV7(); | ||
| const quote = (value: string): string => `'${value.replaceAll("'", "''")}'`; | ||
| const oneOf = | ||
| (values: readonly string[]) => | ||
| (name: string): string => | ||
| `${name} in (${values.map(quote).join(', ')})`; | ||
| /** | ||
| * A closed set of strings, emitted as a CHECK rather than a Postgres `ENUM` type: adding a | ||
| * variant is then a one-line migration instead of `ALTER TYPE`, which cannot run inside a | ||
| * transaction on older servers. | ||
| */ | ||
| export const enumerated = <const V extends readonly string[]>(values: V): Column<V[number]> => { | ||
| const allowed = new Set<string>(values); | ||
| return column<V[number]>( | ||
| 'text', | ||
| (value) => | ||
| typeof value === 'string' && allowed.has(value) | ||
| ? value | ||
| : refuseColumn( | ||
| 'enum', | ||
| `expected one of ${values.join(' | ')}, ${got(value)}`, | ||
| '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', | ||
| ), | ||
| { values, check: oneOf(values) }, | ||
| ); | ||
| }; | ||
| /** | ||
| * An absolute http(s) URL, validated on write rather than on render: a bad URL stored once is | ||
@@ -461,1 +416,5 @@ * served to every reader, and `<img src>` fails silently in the browser. | ||
| `${scaleColumn} is null or (${scaleColumn} >= 0 and ${scaleColumn} <= ${MAX_MONEY_SCALE})`; | ||
| // `enumerated()` lives in `enum-column.ts` — it is the one builder with a chain of its own, and | ||
| // splitting it is what kept this file under the ceiling. Re-exported so no caller had to move. | ||
| export { enumerated } from './enum-column'; |
+35
-3
@@ -12,2 +12,3 @@ // The projection an entity hands the rest of the toolchain: `x.manifest.json`, the migration | ||
| import type { ColumnDescription, EntityDescription, ReferenceDescription } from './registry'; | ||
| import type { SearchVector } from './search'; | ||
| import type { AnyColumn, ColumnMeta, IndexDef } from './types'; | ||
@@ -27,5 +28,31 @@ | ||
| readonly tenantColumn: string | null; | ||
| /** The generated `tsvector`, when any column is `.searchable()`. */ | ||
| readonly search?: SearchVector | null; | ||
| } | ||
| /** | ||
| * The search vector as a physical column: `tsvector`, computed by the database, never written. | ||
| * | ||
| * `notNull` is what makes a missing `generated` clause LOUD rather than silent. Every function in | ||
| * the expression is total over a coalesced text, so the value can never be NULL — and if a | ||
| * generator that does not yet render `generated` emits the column as a plain `tsvector`, the first | ||
| * insert is a `23502` naming this column, instead of a table of NULL vectors where every search | ||
| * quietly answers nothing. | ||
| */ | ||
| const describeSearchColumn = (search: SearchVector): ColumnDescription => ({ | ||
| // `$`-prefixed: a property key no column can be spelled as, because nothing may address it. | ||
| property: '$search', | ||
| column: search.column, | ||
| kind: 'tsvector', | ||
| notNull: true, | ||
| primaryKey: false, | ||
| unique: false, | ||
| hasDefault: false, | ||
| check: null, | ||
| references: null, | ||
| onDelete: null, | ||
| generated: search.expression, | ||
| }); | ||
| /** | ||
| * The foreign keys an entity declares, resolved through the one binding resolver. Money is | ||
@@ -175,5 +202,10 @@ * skipped for the reason the DDL projection drops a reference there too: one property is two | ||
| primaryKey: input.primaryKey.map(physicalOf), | ||
| columns: input.columns.flatMap(([property, column]) => | ||
| describeColumn(input, property, column.$meta, references.get(property)), | ||
| ), | ||
| columns: [ | ||
| ...input.columns.flatMap(([property, column]) => | ||
| describeColumn(input, property, column.$meta, references.get(property)), | ||
| ), | ||
| // LAST, so every column an author declared keeps the position it had and no snapshot of an | ||
| // entity without a search vector moves. | ||
| ...(input.search == null ? [] : [describeSearchColumn(input.search)]), | ||
| ], | ||
| invariants: input.invariants.map((inv) => ({ | ||
@@ -180,0 +212,0 @@ name: inv.name, |
+45
-82
@@ -16,2 +16,3 @@ // `entity(name, { columns })` is the first primitive. The row type is DERIVED from the columns — | ||
| import { invariantColumns } from './expr'; | ||
| import { indexName } from './index-name'; | ||
| import type { Invariant, InvariantDef } from './invariants'; | ||
@@ -21,2 +22,4 @@ import { assertInvariants, bindInvariant, invariantsToSql } from './invariants'; | ||
| import { registerEntity } from './registry'; | ||
| import type { SearchInit, SearchSource, SearchVector } from './search'; | ||
| import { searchVectorOf } from './search'; | ||
| import { resolveTenantColumn } from './tenancy'; | ||
@@ -78,2 +81,8 @@ import type { AnyColumn, ColumnMap, ColumnMeta, IndexDef, RowOf } from './types'; | ||
| readonly indexes?: readonly IndexInit<C>[]; | ||
| /** | ||
| * Full-text search, when the two defaults do not fit: `search_tsv` and `'english'`. WHICH columns | ||
| * are searched is `.searchable()` on the columns themselves, never restated here — this is the | ||
| * adoption escape, exactly as `table` and `.column()` are. | ||
| */ | ||
| readonly search?: SearchInit; | ||
| /** Extra cache tags this entity participates in, beyond its own. */ | ||
@@ -101,2 +110,7 @@ readonly tags?: readonly string[]; | ||
| readonly $tenantColumn: string | null; | ||
| /** | ||
| * The generated `tsvector` this entity's `.searchable()` columns derive, or `null` when none is. | ||
| * Presence is what makes `.search(text)` legal — both drivers read it, and neither invents one. | ||
| */ | ||
| readonly $search: SearchVector | null; | ||
| /** Phantom: `type Post = typeof posts.$row`. Reading it at runtime throws. */ | ||
@@ -132,84 +146,2 @@ readonly $row: Row; | ||
| /** | ||
| * What separates two indexes on the SAME columns: the predicate and the direction. Eight hex | ||
| * characters of sha256 over both — deterministic across processes, so a name is a property of the | ||
| * declaration and never of the run that generated it. | ||
| */ | ||
| /** | ||
| * What separates two indexes on the SAME columns: the predicate, the direction and the ACCESS | ||
| * METHOD. Eight hex characters of sha256 over all three — deterministic across processes, so a | ||
| * name is a property of the declaration and never of the run that generated it. | ||
| * | ||
| * The method belongs here for exactly the reason `where` does. A btree on an `arrayOf()` column | ||
| * answers `=` and an ordering; a GIN on the same column answers `@>` / `<@` / `&&`. They are two | ||
| * distinct indexes, and without the method in the name both are `<table>_<cols>_idx` — where the | ||
| * dedup below drops one in silence (the defect this discriminator was added for) or, since that | ||
| * dedup is now on the whole definition, two `create index` statements share one name and the | ||
| * migration is `42P07`. | ||
| */ | ||
| const indexDiscriminator = ( | ||
| order: string | undefined, | ||
| where: string | null, | ||
| using: string | undefined, | ||
| ): string => | ||
| new Bun.CryptoHasher('sha256') | ||
| // The method is APPENDED only when one was declared, never as an empty field: every name this | ||
| // function has ever minted for a partial or ordered index is therefore unchanged by the method | ||
| // existing, and an index that declares no method is byte-identical to the one it was. | ||
| .update(`${order ?? ''}|${where ?? ''}${using === undefined ? '' : `|${using}`}`) | ||
| .digest('hex') | ||
| .slice(0, 8); | ||
| /** | ||
| * `<table>_<columns>_idx`, plus a discriminator when — and only when — the index carries a | ||
| * predicate, a direction or a non-default access method. | ||
| * | ||
| * Only then, because the plain name is load-bearing in two places: `unique()` on a column is an | ||
| * inline column clause and Postgres names the index it creates exactly `<table>_<column>_key`, so | ||
| * a discriminator there would make the generator emit a second `create unique index` for an index | ||
| * that already exists (`42P07`); and a foreign key's own index is deduped against a hand-declared | ||
| * one by this name. | ||
| * | ||
| * Without it, two DIFFERENT partial indexes on one column were one name — `posts_author_id_idx` | ||
| * for both `where status = 'published'` and `where status = 'draft'` — and the dedup below dropped | ||
| * the second with no error, no warning and no drift finding, since a declared index is matched by | ||
| * name. | ||
| */ | ||
| /** | ||
| * `NAMEDATALEN - 1`. Postgres truncates a longer identifier and says NOTHING, so two index names | ||
| * sharing their first 63 bytes become one index on the server — the same silent collapse the | ||
| * discriminator above exists to prevent, one layer down, and invisible to a drift check comparing | ||
| * DECLARED names because those still differ. Bytes and not characters: 63 is what the server | ||
| * counts, and `.length` would stop seeing the truncation the moment a name is not ASCII. | ||
| */ | ||
| const MAX_IDENTIFIER_BYTES = 63; | ||
| const byteLength = (value: string): number => new TextEncoder().encode(value).length; | ||
| const indexName = ( | ||
| entityName: string, | ||
| table: string, | ||
| columns: readonly string[], | ||
| unique: boolean, | ||
| order?: string | undefined, | ||
| where: string | null = null, | ||
| using?: IndexMethod | undefined, | ||
| ): string => { | ||
| const suffix = unique ? 'key' : 'idx'; | ||
| const base = `${table}_${columns.join('_')}`; | ||
| const plain = order === undefined && where === null && using === undefined; | ||
| const name = plain | ||
| ? `${base}_${suffix}` | ||
| : `${base}_${indexDiscriminator(order, where, using)}_${suffix}`; | ||
| const bytes = byteLength(name); | ||
| if (bytes <= MAX_IDENTIFIER_BYTES) return name; | ||
| throw invariantViolated( | ||
| entityName, | ||
| 'index', | ||
| `the index on (${columns.join(', ')}) is named "${name}", which is ${bytes} bytes — ` + | ||
| `Postgres truncates an identifier at ${MAX_IDENTIFIER_BYTES} and does not say so, ` + | ||
| 'so two indexes can silently become one', | ||
| ); | ||
| }; | ||
| const defaultValue = (meta: ColumnMeta): unknown => { | ||
@@ -238,2 +170,18 @@ const declared = meta.default; | ||
| const searchSources: readonly SearchSource[] = entries.flatMap(([property, column]) => { | ||
| const weight = column.$meta.searchable; | ||
| return weight === undefined ? [] : [{ column: columnName(property, column.$meta), weight }]; | ||
| }); | ||
| const search = searchVectorOf( | ||
| searchSources, | ||
| init.search, | ||
| // `physical`, not `candidate`: a parameter whose NAME reads like a credential is what | ||
| // `bun run secret-compare` refuses an `===` on, and this is a column name. | ||
| (physical) => | ||
| entries.some(([property, column]) => columnName(property, column.$meta) === physical), | ||
| (subject, detail) => { | ||
| throw invariantViolated(name, subject, detail); | ||
| }, | ||
| ); | ||
| const primaryKey = | ||
@@ -351,2 +299,15 @@ init.primaryKey ?? entries.filter(([, column]) => column.$meta.primaryKey).map(([key]) => key); | ||
| }), | ||
| // The one index nobody declared and every search needs. Through the SAME `IndexInit` path a | ||
| // hand-written `using: 'gin'` takes — `indexName` gives it the method discriminator, so it can | ||
| // never collide with a btree an author declares on the same column. | ||
| ...(search === null | ||
| ? [] | ||
| : [ | ||
| { | ||
| name: indexName(name, table, [search.column], false, undefined, null, 'gin'), | ||
| columns: [search.column], | ||
| unique: false, | ||
| using: 'gin' as IndexMethod, | ||
| }, | ||
| ]), | ||
| ]; | ||
@@ -389,2 +350,3 @@ /** | ||
| tenantColumn, | ||
| search, | ||
| }); | ||
@@ -434,2 +396,3 @@ const references = (): readonly ReferenceDescription[] => describeReferences(name, entries); | ||
| $tenantColumn: tenantColumn, | ||
| $search: search, | ||
| $schema: { | ||
@@ -436,0 +399,0 @@ '~standard': { |
+10
-0
@@ -24,2 +24,7 @@ // The entity layer's stable error codes. Each factory produces the exact command | ||
| 'X_APPROXIMATE_COUNT_FILTERED', | ||
| 'X_SEARCH_UNDECLARED', | ||
| 'X_SEARCH_IN_MEMORY', | ||
| 'X_STATE_UNDECLARED', | ||
| 'X_STATE_TRANSITION_ILLEGAL', | ||
| 'X_STATE_CONFLICT', | ||
| ] as const; | ||
@@ -62,2 +67,7 @@ | ||
| X_APPROXIMATE_COUNT_FILTERED: 'an estimate was asked of a filtered chain', | ||
| X_SEARCH_UNDECLARED: 'this entity has no searchable column', | ||
| X_SEARCH_IN_MEMORY: 'the in-memory driver cannot answer a full-text match', | ||
| X_STATE_UNDECLARED: 'that column declares no state machine', | ||
| X_STATE_TRANSITION_ILLEGAL: 'the machine has no such transition', | ||
| X_STATE_CONFLICT: 'the row is no longer in the state this transition named', | ||
| }; | ||
@@ -64,0 +74,0 @@ |
+39
-3
@@ -25,5 +25,2 @@ // The public surface of @ultimat3/entity. Explicit, never `export *`. | ||
| } from './columns'; | ||
| // The vocabulary an EXISTING schema needs. Separate from the blessed builders on purpose: those | ||
| // are decisions this framework made for a table it was going to create, and these are the shapes | ||
| // a table already has (`docs`: Entities-And-Migrations, "Adopting an existing database"). | ||
| export type { DecimalOptions } from './columns-data'; | ||
@@ -40,2 +37,5 @@ export { arrayOf, bigint, bytes, date, decimal, json } from './columns-data'; | ||
| export { entity, SOFT_DELETE_COLUMN } from './entity'; | ||
| // The vocabulary an EXISTING schema needs. Separate from the blessed builders on purpose: those | ||
| // are decisions this framework made for a table it was going to create, and these are the shapes | ||
| // a table already has (`docs`: Entities-And-Migrations, "Adopting an existing database"). | ||
| export type { | ||
@@ -70,2 +70,11 @@ EntityErrorCode, | ||
| export type { ColumnExpr, Expr, InvariantColumns, Resolve } from './expr'; | ||
| /** The two DECLARED capabilities' refusals — a third-party driver raises the same ones. */ | ||
| export type { IllegalTransition } from './feature-errors'; | ||
| export { | ||
| searchInMemory, | ||
| searchUndeclared, | ||
| stateConflict, | ||
| stateTransitionIllegal, | ||
| stateUndeclared, | ||
| } from './feature-errors'; | ||
| export type { Invariant, InvariantDef, InvariantKind } from './invariants'; | ||
@@ -122,2 +131,16 @@ export { | ||
| export { observedRepo, rowObserver, setRowObserver } from './row-observer'; | ||
| // Full-text search. The LANGUAGE set and the weights are values an app reads to build a form; | ||
| // `SEARCH_PROPERTY` is what a `matches` predicate names, which a hand-built `QueryPlan` needs. | ||
| export type { SearchInit, SearchLanguage, SearchSource, SearchVector } from './search'; | ||
| export { | ||
| DEFAULT_SEARCH_COLUMN, | ||
| DEFAULT_SEARCH_LANGUAGE, | ||
| DEFAULT_SEARCH_WEIGHT, | ||
| isSearchLanguage, | ||
| isSearchWeight, | ||
| SEARCH_LANGUAGES, | ||
| SEARCH_PROPERTY, | ||
| SEARCH_WEIGHTS, | ||
| searchExpression, | ||
| } from './search'; | ||
| export type { | ||
@@ -135,2 +158,12 @@ Seed, | ||
| export { defineSeed, isSeed, SEED_TIERS, seedId, seedTiersFor } from './seed'; | ||
| // A state machine over a column. The MECHANISM only: the table, the refusal, the terminal concept. | ||
| // The states are the app's `enumerated()` set and nothing here names one. | ||
| export type { StateMachine, TransitionTable } from './state-machine'; | ||
| export { | ||
| canMove, | ||
| isState, | ||
| isTerminal, | ||
| movesFrom, | ||
| stateMachineOf, | ||
| } from './state-machine'; | ||
| export type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy'; | ||
@@ -149,2 +182,3 @@ export { | ||
| } from './tenancy'; | ||
| export type { Move } from './transition'; | ||
| export type { | ||
@@ -157,2 +191,3 @@ AnyColumn, | ||
| ColumnMeta, | ||
| EnumeratedColumn, | ||
| IdOf, | ||
@@ -168,2 +203,3 @@ IndexDef, | ||
| RowPatch, | ||
| SearchWeight, | ||
| TimestampColumn, | ||
@@ -170,0 +206,0 @@ TypeOf, |
@@ -13,2 +13,3 @@ // Single responsibility: what a `Predicate` MEANS in the in-memory driver — equality, ordering and | ||
| import { EntityError } from './errors'; | ||
| import { searchInMemory } from './feature-errors'; | ||
| import { instantMicros } from './instant'; | ||
@@ -162,2 +163,6 @@ import type { Predicate } from './tenancy'; | ||
| ): boolean => { | ||
| // BEFORE anything is read off the row. A full-text match has no in-memory meaning — see | ||
| // `searchInMemory` — and `valueAt(row, '$search')` would answer `undefined`, which every | ||
| // comparison below reads as NULL and silently turns into "no rows". | ||
| if (predicate.op === 'matches') throw searchInMemory(entity.$name); | ||
| // The column's declared kind, resolved once — `price.minor` included, which is the path a money | ||
@@ -164,0 +169,0 @@ // predicate and a money sort key both name. |
+32
-0
@@ -13,2 +13,3 @@ // Single responsibility: compile a `QueryPlan` into parameterised SQL. Nothing here builds a | ||
| import { SOFT_DELETE_COLUMN } from './entity'; | ||
| import { searchUndeclared } from './feature-errors'; | ||
| import { microsToIso, seekAlias } from './instant'; | ||
@@ -32,3 +33,34 @@ import { allColumns, arrayLiteral, columnsOf, physicalName } from './pg-row'; | ||
| /** | ||
| * `websearch_to_tsquery`, and the choice is the point of the whole feature. | ||
| * | ||
| * `to_tsquery` reads its argument as tsquery SYNTAX — `&`, `|`, `!`, `<->`, `:*`, parentheses — so | ||
| * a term straight out of a search box is either a `42601` on the first unbalanced paren or, worse, | ||
| * an operator the caller did not write. `plainto_tsquery` is safe but ANDs every word and throws | ||
| * the user's own operators away silently. `websearch_to_tsquery` is the parser Postgres ships for | ||
| * untrusted input: it never raises a syntax error, and it gives `"quoted phrase"`, `or` and a | ||
| * leading `-` the meaning every search box on the web already has. Cats & dogs is three terms. | ||
| * | ||
| * The term crosses as a BOUND PARAMETER either way — nothing here interpolates it — so the choice | ||
| * is not what stops an injection; the parameter is. What the parser decides is whether the user's | ||
| * punctuation is read as syntax, which is the second half of the same question. | ||
| * | ||
| * The configuration is spliced through `raw()`, from `SEARCH_LANGUAGES`, exactly as `asc|desc` is: | ||
| * a closed set of one word, chosen by this file from the ENTITY's declaration and never from a | ||
| * value on the wire. `regconfig` cannot be a bound parameter and stay index-matchable anyway. | ||
| */ | ||
| const searchSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFragment => { | ||
| const vector = entity.$search; | ||
| if (vector === null) throw searchUndeclared(entity.$name); | ||
| const column = identifier(vector.column); | ||
| // The term as TEXT, whatever arrived: `websearch_to_tsquery` takes text, and a number or a null | ||
| // reaching it as a parameter is a `42883` where the caller is owed "no rows". | ||
| const term = predicate.value === null || predicate.value === undefined ? '' : predicate.value; | ||
| return sql`${column} @@ websearch_to_tsquery(${raw(`'${vector.language}'`)}, ${String(term)})`; | ||
| }; | ||
| const predicateSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFragment => { | ||
| // BEFORE the column is resolved: a `matches` predicate names `SEARCH_PROPERTY`, which is not a | ||
| // column and must never be looked up as one. | ||
| if (predicate.op === 'matches') return searchSql(entity, predicate); | ||
| const column = columnRef(entity, predicate.column); | ||
@@ -35,0 +67,0 @@ const value = predicate.value; |
+61
-0
@@ -11,2 +11,3 @@ // The chainable read. Every chain terminates in a cursor page — `page()` returns rows plus the | ||
| import type { EntityCore } from './entity'; | ||
| import { searchUndeclared } from './feature-errors'; | ||
| import { assertPageSize, DEFAULT_PAGE_SIZE, namedColumns } from './plan'; | ||
@@ -18,3 +19,5 @@ import type { RelatedTables } from './preload'; | ||
| import type { Page, Repo, RepoOptions, UpsertArgs } from './repo'; | ||
| import { SEARCH_PROPERTY } from './search'; | ||
| import type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy'; | ||
| import { transitionRow } from './transition'; | ||
| import type { ColumnMap, IdOf, Insertable, MoneyValue, RowPatch } from './types'; | ||
@@ -33,2 +36,19 @@ | ||
| andWhere(column: keyof Row & string, op: Operator, value: unknown): ReadBuilder<Row>; | ||
| /** | ||
| * Full-text search over the entity's generated `tsvector` — every `.searchable()` column at | ||
| * once, one GIN index, one predicate. `posts.where({ orgId }).search(input.q).limit(20).page()`. | ||
| * | ||
| * `term` is USER TEXT and is treated as such end to end: it crosses as a bound parameter and is | ||
| * parsed by `websearch_to_tsquery`, so `&`, `|`, `!`, `:*` and an unbalanced paren are characters | ||
| * to be matched, never operators and never a syntax error. There is deliberately no way to hand | ||
| * this layer a tsquery — a query language inside the query language is a second way to ask, and | ||
| * the one caller that would want it is the injection this method exists to make unreachable. | ||
| * | ||
| * It is an ordinary predicate, so the chain's tenancy, soft delete, projection, order, cursor | ||
| * and page size all mean here exactly what they mean without it. RELEVANCE is not an order this | ||
| * chain can serve: `ts_rank` is a computed value and the cursor carries columns, so the order | ||
| * stays the one the caller declared. An entity with no searchable column is `X_SEARCH_UNDECLARED` | ||
| * and the in-memory driver is `X_SEARCH_IN_MEMORY` — never a different answer from the two. | ||
| */ | ||
| search(term: string): ReadBuilder<Row>; | ||
| orderBy(column: keyof Row & string, direction?: SortDirection): ReadBuilder<Row>; | ||
@@ -134,2 +154,28 @@ limit(rows: number): ReadBuilder<Row>; | ||
| export interface Table<Row, C extends ColumnMap = ColumnMap> extends ReadBuilder<Row> { | ||
| /** | ||
| * Move one row through the state machine `column` declares, in ONE statement. | ||
| * | ||
| * `from` is the state the caller believes the row is in, and it rides in the statement's own | ||
| * predicate — so the state that was observed and the state that was written are one decision, | ||
| * made under the row's lock. A read-then-check-then-write is the same call with a window in it, | ||
| * and under two concurrent callers the second one writes a transition out of a state the row had | ||
| * already left. Here the second statement matches no row: `X_STATE_CONFLICT`, naming the state | ||
| * the row is really in. | ||
| * | ||
| * A move the machine does not hold is `X_STATE_TRANSITION_ILLEGAL` and never reaches the | ||
| * database — the table is a property of the declaration. A move out of a TERMINAL state is the | ||
| * same code saying so; a terminal state is one whose outgoing list is empty, which is the whole | ||
| * of the concept and the only part of it the framework owns. Which state is terminal, what any | ||
| * of them mean, who may make a move and what happens on arrival are the app's, every one. | ||
| * | ||
| * Tenant-scoped exactly as `updateWhere` is, because it IS one: a row in another org matches no | ||
| * statement and reads back as absent, so the answer is `X_NOT_FOUND` rather than a conflict that | ||
| * would confirm it exists. | ||
| */ | ||
| transition<K extends keyof Row & string>( | ||
| column: K, | ||
| id: IdOf<Row>, | ||
| move: { readonly from: Row[K] & string; readonly to: Row[K] & string }, | ||
| options?: RepoOptions, | ||
| ): Promise<Row>; | ||
| insert(values: Insertable<C>, options?: RepoOptions): Promise<Row>; | ||
@@ -248,2 +294,12 @@ /** | ||
| // Refused HERE as well as at the statement, because this is the line the author wrote: a chain | ||
| // over an entity that declares nothing searchable can never produce a match, and the repair is | ||
| // one edit to the schema rather than anything about this call. | ||
| search: (term) => { | ||
| if (entity.$search === null) throw searchUndeclared(entity.$name); | ||
| return next({ | ||
| where: [...state.where, { column: SEARCH_PROPERTY, op: 'matches', value: term }], | ||
| }); | ||
| }, | ||
| orderBy: (column, direction = 'asc') => | ||
@@ -409,2 +465,7 @@ next({ orderBy: [...state.orderBy, { column, direction }] }), | ||
| repo.updateWhere(filter, touch(entity, patch), options), | ||
| // `touch` is passed rather than applied here: a transition IS an update, so an `onUpdateNow()` | ||
| // column has to move exactly as `update(id, patch)` moves it — which is also the audit of WHEN | ||
| // the row moved, using the stamp already declared instead of a second one beside it. | ||
| transition: async (column, id, move, options) => | ||
| transitionRow(entity, repo, column, id, move, (patch) => touch(entity, patch), options), | ||
| }); |
+9
-0
@@ -28,2 +28,11 @@ // The entity registry. Every `entity()` call registers here, which is what makes | ||
| readonly onDelete: OnDelete | null; | ||
| /** | ||
| * The `generated always as (<expr>) stored` body, when the DATABASE computes this column rather | ||
| * than a writer. Absent on every ordinary column, matching `IndexDescription.using`: a | ||
| * description written before this existed reads the same, so nothing regenerates. | ||
| * | ||
| * `@ultimat3/db` is tier 1 and cannot import this package, so — exactly like `onDelete` — a | ||
| * field that is not on this projection reaches no DDL at all. | ||
| */ | ||
| readonly generated?: string; | ||
| } | ||
@@ -30,0 +39,0 @@ |
+6
-1
@@ -42,3 +42,8 @@ // Multi-tenancy is a guard, not a convention. An entity with a tenant column is read AND written | ||
| | 'overlaps' | ||
| | 'has-key'; | ||
| | 'has-key' | ||
| // The FULL-TEXT half, added 2026-08-24. `column` is not a column: it is `SEARCH_PROPERTY`, and | ||
| // both drivers branch on the OPERATOR and read the entity's own `$search` — a `tsvector` is a | ||
| // physical column no row carries, so resolving it as a property would be a lie in two places. | ||
| // The operand is a search TERM, always bound, never parsed as tsquery syntax. | ||
| | 'matches'; | ||
@@ -45,0 +50,0 @@ export interface Predicate { |
+50
-0
@@ -51,2 +51,9 @@ // The structural vocabulary of a column. The physical layer is this package's own hand-written | ||
| /** | ||
| * A source column's contribution to the entity's search vector — Postgres' own four labels, which | ||
| * `ts_rank` weights `{D, C, B, A}` = `{0.1, 0.2, 0.4, 1.0}` by default. `D` is what an unweighted | ||
| * `to_tsvector` produces, so it is the default here too. | ||
| */ | ||
| export type SearchWeight = 'A' | 'B' | 'C' | 'D'; | ||
| export interface ReferenceOptions { | ||
@@ -67,2 +74,3 @@ readonly onDelete?: OnDelete; | ||
| import type { MoneyValue } from '@ultimat3/schema'; | ||
| import type { StateMachine, TransitionTable } from './state-machine'; | ||
@@ -138,2 +146,16 @@ export type { MoneyValue }; | ||
| readonly onDelete?: OnDelete; | ||
| /** | ||
| * Presence is what puts this column in the entity's generated `tsvector`, and the value is its | ||
| * weight. A modifier and not a column of its own: the vector is derived from every searchable | ||
| * column at once (`search.ts`), so declaring it per column would be one vector per column and | ||
| * one GIN index per column. | ||
| */ | ||
| readonly searchable?: SearchWeight; | ||
| /** | ||
| * The state machine this column's values move through, when one was declared. Built once at | ||
| * declaration (`stateMachineOf`) so an illegal table is a refusal where it was written, and held | ||
| * as the built machine rather than the literal table because the literal is an object a caller | ||
| * indexes by a data key. | ||
| */ | ||
| readonly machine?: StateMachine; | ||
| } | ||
@@ -165,5 +187,33 @@ | ||
| column(name: string): Column<T, Optional>; | ||
| /** | ||
| * Adds this column to the entity's one generated `tsvector`, at `weight` (default `D`). Text | ||
| * only — every other kind is refused here, where the chain was written. | ||
| */ | ||
| searchable(weight?: SearchWeight): Column<T, Optional>; | ||
| } | ||
| /** | ||
| * `enumerated()`'s own column: the one that may declare a state machine, because it is the one | ||
| * that already declares a closed set of values for the machine to move through. | ||
| * | ||
| * The links below are overridden for the reason `UuidColumn`'s and `TimestampColumn`'s are — the | ||
| * generic chain answers the general `Column`, so `enumerated(S).default('draft')` would lose | ||
| * `transitions` and `enumerated(S).transitions(T).column('c')` would lose the machine's own type. | ||
| */ | ||
| export interface EnumeratedColumn<V extends readonly string[], Optional extends boolean = false> | ||
| extends Column<V[number], Optional> { | ||
| /** | ||
| * Declares which values may follow which. The states are `V` — this column's own — so the | ||
| * framework never names one: a missing state, an unknown key and an unknown target are each a | ||
| * compile error against the set the app already wrote. | ||
| * | ||
| * A state machine column may not be nullable: NULL is not a state, so nothing could say what it | ||
| * may move to. | ||
| */ | ||
| transitions(table: TransitionTable<V[number]>): EnumeratedColumn<V, Optional>; | ||
| default(value: V[number]): EnumeratedColumn<V, true>; | ||
| column(name: string): EnumeratedColumn<V, Optional>; | ||
| } | ||
| /** | ||
| * A uuid primary key is generated (v7) when omitted, which is why it narrows to `true`. | ||
@@ -170,0 +220,0 @@ * |
Sorry, the diff of this file is too big to display
661159
8.89%56
14.29%10104
9.21%819
11.73%+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated
Updated