Sign In

@ultimat3/entity

Package Overview
Dependencies
Maintainers
1
Versions
21
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ultimat3/entity - npm Package Compare versions

Comparing version
12.0.0
to
13.0.0
+29
src/column-values.ts
// 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',
);
};
// 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"
}
}

@@ -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 @@

@@ -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 @@

@@ -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';

@@ -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,

@@ -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': {

@@ -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 @@

@@ -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.

@@ -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;

@@ -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),
});

@@ -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 @@

@@ -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 {

@@ -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