@ultimat3/entity 12.0.0 → 13.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +53 -0
- package/README.md +86 -0
- package/package.json +5 -5
- package/src/column-values.ts +29 -0
- package/src/column.ts +22 -0
- package/src/columns.ts +5 -46
- package/src/describe.ts +35 -3
- package/src/entity.ts +45 -82
- package/src/enum-column.ts +81 -0
- package/src/errors.ts +10 -0
- package/src/feature-errors.ts +121 -0
- package/src/index-name.ts +83 -0
- package/src/index.ts +39 -3
- package/src/memory-match.ts +5 -0
- package/src/pg-sql.ts +32 -0
- package/src/query.ts +61 -0
- package/src/registry.ts +9 -0
- package/src/search.ts +153 -0
- package/src/state-machine.ts +132 -0
- package/src/tenancy.ts +6 -1
- package/src/transition.ts +119 -0
- package/src/types.ts +50 -0
package/src/errors.ts
CHANGED
|
@@ -21,6 +21,11 @@ export const ENTITY_OWNED_ERROR_CODES = [
|
|
|
21
21
|
'X_AGGREGATE_UNSUPPORTED',
|
|
22
22
|
'X_AGGREGATE_MIXED_CURRENCY',
|
|
23
23
|
'X_APPROXIMATE_COUNT_FILTERED',
|
|
24
|
+
'X_SEARCH_UNDECLARED',
|
|
25
|
+
'X_SEARCH_IN_MEMORY',
|
|
26
|
+
'X_STATE_UNDECLARED',
|
|
27
|
+
'X_STATE_TRANSITION_ILLEGAL',
|
|
28
|
+
'X_STATE_CONFLICT',
|
|
24
29
|
] as const;
|
|
25
30
|
|
|
26
31
|
/**
|
|
@@ -58,6 +63,11 @@ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityOwnedErrorCode, string>>
|
|
|
58
63
|
X_AGGREGATE_UNSUPPORTED: 'that column has no aggregate both drivers can answer alike',
|
|
59
64
|
X_AGGREGATE_MIXED_CURRENCY: 'an amount was aggregated across currencies',
|
|
60
65
|
X_APPROXIMATE_COUNT_FILTERED: 'an estimate was asked of a filtered chain',
|
|
66
|
+
X_SEARCH_UNDECLARED: 'this entity has no searchable column',
|
|
67
|
+
X_SEARCH_IN_MEMORY: 'the in-memory driver cannot answer a full-text match',
|
|
68
|
+
X_STATE_UNDECLARED: 'that column declares no state machine',
|
|
69
|
+
X_STATE_TRANSITION_ILLEGAL: 'the machine has no such transition',
|
|
70
|
+
X_STATE_CONFLICT: 'the row is no longer in the state this transition named',
|
|
61
71
|
};
|
|
62
72
|
|
|
63
73
|
// Registered at module load, unconditionally, in one call. Without this the registry humanises the
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The refusals the two DECLARED capabilities raise at call time — full-text search and a state
|
|
2
|
+
// machine. Split from `errors.ts` at the 500-line ceiling; the codes and their titles stay there,
|
|
3
|
+
// because a registry with two homes is a registry that disagrees with itself.
|
|
4
|
+
|
|
5
|
+
import { EntityError } from './errors';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A `matches` predicate on an entity whose columns declare no `.searchable()`.
|
|
9
|
+
*
|
|
10
|
+
* Raised where the STATEMENT would be built, not where the chain was written, because both drivers
|
|
11
|
+
* reach it: an entity's search vector is derived from its columns, so there is nothing else the
|
|
12
|
+
* predicate could name and no vector to guess at.
|
|
13
|
+
*/
|
|
14
|
+
export const searchUndeclared = (entityName: string): EntityError =>
|
|
15
|
+
new EntityError({
|
|
16
|
+
code: 'X_SEARCH_UNDECLARED',
|
|
17
|
+
cause: `${entityName} has no searchable column, so there is no tsvector to match against`,
|
|
18
|
+
fix: `add .searchable() to a text() column of ${entityName}, then: x db gen "search ${entityName}"`,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A full-text match asked of `memoryDriver()`. REFUSED rather than emulated, and that is the whole
|
|
23
|
+
* decision: `to_tsvector` stems, drops stop words and applies a language's own rules, and
|
|
24
|
+
* `websearch_to_tsquery` parses quoted phrases and `-`negation — a JS token comparison is a
|
|
25
|
+
* DIFFERENT question with the same shape, so it would answer green in a unit test and differently
|
|
26
|
+
* in production, which is the one outcome the two-driver split exists to prevent.
|
|
27
|
+
*/
|
|
28
|
+
export const searchInMemory = (entityName: string): EntityError =>
|
|
29
|
+
new EntityError({
|
|
30
|
+
code: 'X_SEARCH_IN_MEMORY',
|
|
31
|
+
cause: `memoryDriver() cannot stem, weight or rank a tsvector, so a search of ${entityName} has no answer it could give that Postgres would agree with`,
|
|
32
|
+
fix: `move this read into a <name>.live.test.ts and run it with TEST_DATABASE_URL set — bun test packages/entity/src/pg-search.live.test.ts is the model`,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `transition()` on a column that declares no `.transitions()`.
|
|
37
|
+
*
|
|
38
|
+
* A declaration bug and not a caller's, which is why it lists the columns that DO declare one: the
|
|
39
|
+
* repair is naming a different column or writing the table, and both are edits to source.
|
|
40
|
+
*/
|
|
41
|
+
export const stateUndeclared = (
|
|
42
|
+
entityName: string,
|
|
43
|
+
column: string,
|
|
44
|
+
machines: readonly string[],
|
|
45
|
+
): EntityError =>
|
|
46
|
+
new EntityError({
|
|
47
|
+
code: 'X_STATE_UNDECLARED',
|
|
48
|
+
cause: `${entityName}.${column} declares no state machine, so there is no transition to check`,
|
|
49
|
+
fix:
|
|
50
|
+
machines.length === 0
|
|
51
|
+
? `add .transitions(…) to ${entityName}.${column} — it must be an enumerated() column, and every value that set declares needs an entry`
|
|
52
|
+
: `${entityName} declares a machine on: ${machines.join(', ')}`,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Why a move is not in the machine. THREE conditions and one code, because they share one repair —
|
|
57
|
+
* the move is not in the table — and each states its own fact, because they are not the same
|
|
58
|
+
* mistake and the fix line differs.
|
|
59
|
+
*
|
|
60
|
+
* `unknown-state` is separate from `terminal` for a reason a test found: an unknown state has no
|
|
61
|
+
* outgoing moves either, so a single "no legal moves" branch reported a typo as "the row is
|
|
62
|
+
* terminal in <typo>" — a sentence about a state that does not exist. Reachable from JS, and from
|
|
63
|
+
* a `from` that came out of parsed JSON.
|
|
64
|
+
*
|
|
65
|
+
* `terminal` is separate from the ordinary case because "no legal moves" reads like a missing
|
|
66
|
+
* declaration and is not one: an empty list is how a terminal state is written.
|
|
67
|
+
*/
|
|
68
|
+
export type IllegalTransition =
|
|
69
|
+
| { readonly reason: 'unknown-state'; readonly states: readonly string[] }
|
|
70
|
+
| { readonly reason: 'terminal' }
|
|
71
|
+
| { readonly reason: 'not-declared'; readonly legal: readonly string[] };
|
|
72
|
+
|
|
73
|
+
export const stateTransitionIllegal = (
|
|
74
|
+
entityName: string,
|
|
75
|
+
column: string,
|
|
76
|
+
from: string,
|
|
77
|
+
to: string,
|
|
78
|
+
detail: IllegalTransition,
|
|
79
|
+
): EntityError => {
|
|
80
|
+
const subject = `${entityName}.${column}`;
|
|
81
|
+
if (detail.reason === 'unknown-state') {
|
|
82
|
+
return new EntityError({
|
|
83
|
+
code: 'X_STATE_TRANSITION_ILLEGAL',
|
|
84
|
+
cause: `"${from}" is not a state of ${subject} — it declares: ${detail.states.join(' | ')}`,
|
|
85
|
+
fix: `${entityName}.transition('${column}', id, { from: '${detail.states[0] ?? from}', to: '${to}' }) # name a state the enumerated() set declares`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (detail.reason === 'terminal') {
|
|
89
|
+
return new EntityError({
|
|
90
|
+
code: 'X_STATE_TRANSITION_ILLEGAL',
|
|
91
|
+
cause: `${subject} is terminal in "${from}": the machine declares no move out of it, so "${to}" is not one`,
|
|
92
|
+
fix: `move the row before it reaches "${from}", or add "${to}" to the "${from}" entry of the transitions() table`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return new EntityError({
|
|
96
|
+
code: 'X_STATE_TRANSITION_ILLEGAL',
|
|
97
|
+
cause: `${subject} has no move from "${from}" to "${to}" — from "${from}" it may go to: ${detail.legal.join(', ')}`,
|
|
98
|
+
fix: `${entityName}.transition('${column}', id, { from: '${from}', to: '${detail.legal[0]}' }) # or add "${to}" to the "${from}" entry of the transitions() table`,
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The conditional update matched no row, and the row is in a different state than the caller named.
|
|
104
|
+
*
|
|
105
|
+
* This is the lost update, caught: two callers both read "pending", both found the move legal, and
|
|
106
|
+
* the second one's statement carried `status = 'pending'` in its predicate and matched nothing. The
|
|
107
|
+
* state in the cause is READ BACK after the refusal, so it is a diagnosis and never the decision —
|
|
108
|
+
* the decision was the statement, and it was atomic.
|
|
109
|
+
*/
|
|
110
|
+
export const stateConflict = (
|
|
111
|
+
entityName: string,
|
|
112
|
+
column: string,
|
|
113
|
+
id: string,
|
|
114
|
+
expected: string,
|
|
115
|
+
actual: string,
|
|
116
|
+
): EntityError =>
|
|
117
|
+
new EntityError({
|
|
118
|
+
code: 'X_STATE_CONFLICT',
|
|
119
|
+
cause: `${entityName}.${column} named "${expected}" for row ${id}, which is in "${actual}" — something moved it first`,
|
|
120
|
+
fix: `re-read the row and decide again against "${actual}": ${entityName}.findById(id) — a transition names the state it expects, so a stale read is refused rather than overwritten`,
|
|
121
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// What an index is CALLED. Split out of `entity.ts` at the 500-line ceiling, and it is one job:
|
|
2
|
+
// two indexes that differ only in their predicate, their direction or their access method must not
|
|
3
|
+
// share a name, and no name may cross the 63 bytes Postgres silently truncates at.
|
|
4
|
+
|
|
5
|
+
import type { IndexMethod } from '@ultimat3/db';
|
|
6
|
+
import { invariantViolated } from './errors';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What separates two indexes on the SAME columns: the predicate, the direction and the ACCESS
|
|
10
|
+
* METHOD. Eight hex characters of sha256 over all three — deterministic across processes, so a
|
|
11
|
+
* name is a property of the declaration and never of the run that generated it.
|
|
12
|
+
*
|
|
13
|
+
* The method belongs here for exactly the reason `where` does. A btree on an `arrayOf()` column
|
|
14
|
+
* answers `=` and an ordering; a GIN on the same column answers `@>` / `<@` / `&&`. They are two
|
|
15
|
+
* distinct indexes, and without the method in the name both are `<table>_<cols>_idx` — where the
|
|
16
|
+
* dedup below drops one in silence (the defect this discriminator was added for) or, since that
|
|
17
|
+
* dedup is now on the whole definition, two `create index` statements share one name and the
|
|
18
|
+
* migration is `42P07`.
|
|
19
|
+
*/
|
|
20
|
+
const indexDiscriminator = (
|
|
21
|
+
order: string | undefined,
|
|
22
|
+
where: string | null,
|
|
23
|
+
using: string | undefined,
|
|
24
|
+
): string =>
|
|
25
|
+
new Bun.CryptoHasher('sha256')
|
|
26
|
+
// The method is APPENDED only when one was declared, never as an empty field: every name this
|
|
27
|
+
// function has ever minted for a partial or ordered index is therefore unchanged by the method
|
|
28
|
+
// existing, and an index that declares no method is byte-identical to the one it was.
|
|
29
|
+
.update(`${order ?? ''}|${where ?? ''}${using === undefined ? '' : `|${using}`}`)
|
|
30
|
+
.digest('hex')
|
|
31
|
+
.slice(0, 8);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `<table>_<columns>_idx`, plus a discriminator when — and only when — the index carries a
|
|
35
|
+
* predicate, a direction or a non-default access method.
|
|
36
|
+
*
|
|
37
|
+
* Only then, because the plain name is load-bearing in two places: `unique()` on a column is an
|
|
38
|
+
* inline column clause and Postgres names the index it creates exactly `<table>_<column>_key`, so
|
|
39
|
+
* a discriminator there would make the generator emit a second `create unique index` for an index
|
|
40
|
+
* that already exists (`42P07`); and a foreign key's own index is deduped against a hand-declared
|
|
41
|
+
* one by this name.
|
|
42
|
+
*
|
|
43
|
+
* Without it, two DIFFERENT partial indexes on one column were one name — `posts_author_id_idx`
|
|
44
|
+
* for both `where status = 'published'` and `where status = 'draft'` — and the dedup below dropped
|
|
45
|
+
* the second with no error, no warning and no drift finding, since a declared index is matched by
|
|
46
|
+
* name.
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* `NAMEDATALEN - 1`. Postgres truncates a longer identifier and says NOTHING, so two index names
|
|
50
|
+
* sharing their first 63 bytes become one index on the server — the same silent collapse the
|
|
51
|
+
* discriminator above exists to prevent, one layer down, and invisible to a drift check comparing
|
|
52
|
+
* DECLARED names because those still differ. Bytes and not characters: 63 is what the server
|
|
53
|
+
* counts, and `.length` would stop seeing the truncation the moment a name is not ASCII.
|
|
54
|
+
*/
|
|
55
|
+
const MAX_IDENTIFIER_BYTES = 63;
|
|
56
|
+
|
|
57
|
+
const byteLength = (value: string): number => new TextEncoder().encode(value).length;
|
|
58
|
+
|
|
59
|
+
export const indexName = (
|
|
60
|
+
entityName: string,
|
|
61
|
+
table: string,
|
|
62
|
+
columns: readonly string[],
|
|
63
|
+
unique: boolean,
|
|
64
|
+
order?: string | undefined,
|
|
65
|
+
where: string | null = null,
|
|
66
|
+
using?: IndexMethod | undefined,
|
|
67
|
+
): string => {
|
|
68
|
+
const suffix = unique ? 'key' : 'idx';
|
|
69
|
+
const base = `${table}_${columns.join('_')}`;
|
|
70
|
+
const plain = order === undefined && where === null && using === undefined;
|
|
71
|
+
const name = plain
|
|
72
|
+
? `${base}_${suffix}`
|
|
73
|
+
: `${base}_${indexDiscriminator(order, where, using)}_${suffix}`;
|
|
74
|
+
const bytes = byteLength(name);
|
|
75
|
+
if (bytes <= MAX_IDENTIFIER_BYTES) return name;
|
|
76
|
+
throw invariantViolated(
|
|
77
|
+
entityName,
|
|
78
|
+
'index',
|
|
79
|
+
`the index on (${columns.join(', ')}) is named "${name}", which is ${bytes} bytes — ` +
|
|
80
|
+
`Postgres truncates an identifier at ${MAX_IDENTIFIER_BYTES} and does not say so, ` +
|
|
81
|
+
'so two indexes can silently become one',
|
|
82
|
+
);
|
|
83
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -22,9 +22,6 @@ export {
|
|
|
22
22
|
url,
|
|
23
23
|
uuid,
|
|
24
24
|
} from './columns';
|
|
25
|
-
// The vocabulary an EXISTING schema needs. Separate from the blessed builders on purpose: those
|
|
26
|
-
// are decisions this framework made for a table it was going to create, and these are the shapes
|
|
27
|
-
// a table already has (`docs`: Entities-And-Migrations, "Adopting an existing database").
|
|
28
25
|
export type { DecimalOptions } from './columns-data';
|
|
29
26
|
export { arrayOf, bigint, bytes, date, decimal, json } from './columns-data';
|
|
30
27
|
// `crossTenantReason` stays internal: an app that could read the flag would have a second way to
|
|
@@ -36,6 +33,9 @@ export type { DescribeInput } from './describe';
|
|
|
36
33
|
export { sqlTypeOf } from './describe';
|
|
37
34
|
export type { Entity, EntityCore, EntityInit, IndexInit } from './entity';
|
|
38
35
|
export { entity, SOFT_DELETE_COLUMN } from './entity';
|
|
36
|
+
// The vocabulary an EXISTING schema needs. Separate from the blessed builders on purpose: those
|
|
37
|
+
// are decisions this framework made for a table it was going to create, and these are the shapes
|
|
38
|
+
// a table already has (`docs`: Entities-And-Migrations, "Adopting an existing database").
|
|
39
39
|
export type {
|
|
40
40
|
EntityErrorCode,
|
|
41
41
|
PreloadCandidate,
|
|
@@ -65,6 +65,15 @@ export {
|
|
|
65
65
|
writeUnfiltered,
|
|
66
66
|
} from './errors';
|
|
67
67
|
export type { ColumnExpr, Expr, InvariantColumns, Resolve } from './expr';
|
|
68
|
+
/** The two DECLARED capabilities' refusals — a third-party driver raises the same ones. */
|
|
69
|
+
export type { IllegalTransition } from './feature-errors';
|
|
70
|
+
export {
|
|
71
|
+
searchInMemory,
|
|
72
|
+
searchUndeclared,
|
|
73
|
+
stateConflict,
|
|
74
|
+
stateTransitionIllegal,
|
|
75
|
+
stateUndeclared,
|
|
76
|
+
} from './feature-errors';
|
|
68
77
|
export type { Invariant, InvariantDef, InvariantKind } from './invariants';
|
|
69
78
|
export {
|
|
70
79
|
assertInvariants,
|
|
@@ -116,6 +125,20 @@ export type {
|
|
|
116
125
|
} from './repo';
|
|
117
126
|
export type { RowBulkChange, RowChange, RowChangeOp, RowObserver } from './row-observer';
|
|
118
127
|
export { observedRepo, rowObserver, setRowObserver } from './row-observer';
|
|
128
|
+
// Full-text search. The LANGUAGE set and the weights are values an app reads to build a form;
|
|
129
|
+
// `SEARCH_PROPERTY` is what a `matches` predicate names, which a hand-built `QueryPlan` needs.
|
|
130
|
+
export type { SearchInit, SearchLanguage, SearchSource, SearchVector } from './search';
|
|
131
|
+
export {
|
|
132
|
+
DEFAULT_SEARCH_COLUMN,
|
|
133
|
+
DEFAULT_SEARCH_LANGUAGE,
|
|
134
|
+
DEFAULT_SEARCH_WEIGHT,
|
|
135
|
+
isSearchLanguage,
|
|
136
|
+
isSearchWeight,
|
|
137
|
+
SEARCH_LANGUAGES,
|
|
138
|
+
SEARCH_PROPERTY,
|
|
139
|
+
SEARCH_WEIGHTS,
|
|
140
|
+
searchExpression,
|
|
141
|
+
} from './search';
|
|
119
142
|
export type {
|
|
120
143
|
Seed,
|
|
121
144
|
SeedContext,
|
|
@@ -128,6 +151,16 @@ export type {
|
|
|
128
151
|
SeedWrite,
|
|
129
152
|
} from './seed';
|
|
130
153
|
export { defineSeed, isSeed, SEED_TIERS, seedId, seedTiersFor } from './seed';
|
|
154
|
+
// A state machine over a column. The MECHANISM only: the table, the refusal, the terminal concept.
|
|
155
|
+
// The states are the app's `enumerated()` set and nothing here names one.
|
|
156
|
+
export type { StateMachine, TransitionTable } from './state-machine';
|
|
157
|
+
export {
|
|
158
|
+
canMove,
|
|
159
|
+
isState,
|
|
160
|
+
isTerminal,
|
|
161
|
+
movesFrom,
|
|
162
|
+
stateMachineOf,
|
|
163
|
+
} from './state-machine';
|
|
131
164
|
export type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
|
|
132
165
|
export {
|
|
133
166
|
assertRowTenant,
|
|
@@ -141,6 +174,7 @@ export {
|
|
|
141
174
|
scopedPlan,
|
|
142
175
|
tenantColumnOf,
|
|
143
176
|
} from './tenancy';
|
|
177
|
+
export type { Move } from './transition';
|
|
144
178
|
export type {
|
|
145
179
|
AnyColumn,
|
|
146
180
|
Column,
|
|
@@ -148,6 +182,7 @@ export type {
|
|
|
148
182
|
ColumnKind,
|
|
149
183
|
ColumnMap,
|
|
150
184
|
ColumnMeta,
|
|
185
|
+
EnumeratedColumn,
|
|
151
186
|
IdOf,
|
|
152
187
|
IndexDef,
|
|
153
188
|
Insertable,
|
|
@@ -158,6 +193,7 @@ export type {
|
|
|
158
193
|
ReferenceOptions,
|
|
159
194
|
RowOf,
|
|
160
195
|
RowPatch,
|
|
196
|
+
SearchWeight,
|
|
161
197
|
TimestampColumn,
|
|
162
198
|
TypeOf,
|
|
163
199
|
UuidColumn,
|
package/src/memory-match.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { arrayContains, arrayOverlaps, jsonContains, jsonHasKey } from './contai
|
|
|
10
10
|
import { kindOf, valueAt } from './cursor';
|
|
11
11
|
import type { EntityCore } from './entity';
|
|
12
12
|
import { EntityError } from './errors';
|
|
13
|
+
import { searchInMemory } from './feature-errors';
|
|
13
14
|
import { instantMicros } from './instant';
|
|
14
15
|
import type { Predicate } from './tenancy';
|
|
15
16
|
import type { ColumnKind } from './types';
|
|
@@ -158,6 +159,10 @@ export const matchesPredicate = <Row>(
|
|
|
158
159
|
row: unknown,
|
|
159
160
|
predicate: Predicate,
|
|
160
161
|
): boolean => {
|
|
162
|
+
// BEFORE anything is read off the row. A full-text match has no in-memory meaning — see
|
|
163
|
+
// `searchInMemory` — and `valueAt(row, '$search')` would answer `undefined`, which every
|
|
164
|
+
// comparison below reads as NULL and silently turns into "no rows".
|
|
165
|
+
if (predicate.op === 'matches') throw searchInMemory(entity.$name);
|
|
161
166
|
// The column's declared kind, resolved once — `price.minor` included, which is the path a money
|
|
162
167
|
// predicate and a money sort key both name.
|
|
163
168
|
const kind = kindOf(entity, predicate.column);
|
package/src/pg-sql.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { columnFor } from './column';
|
|
|
10
10
|
import { isNullableKey, kindOf } from './cursor';
|
|
11
11
|
import type { EntityCore } from './entity';
|
|
12
12
|
import { SOFT_DELETE_COLUMN } from './entity';
|
|
13
|
+
import { searchUndeclared } from './feature-errors';
|
|
13
14
|
import { microsToIso, seekAlias } from './instant';
|
|
14
15
|
import { allColumns, arrayLiteral, columnsOf, physicalName } from './pg-row';
|
|
15
16
|
import type { Predicate, QueryPlan, SortKey } from './tenancy';
|
|
@@ -28,7 +29,38 @@ export interface ReadShape {
|
|
|
28
29
|
const columnRef = <Row>(entity: EntityCore<Row>, path: string): SqlFragment =>
|
|
29
30
|
identifier(physicalName(entity, path));
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* `websearch_to_tsquery`, and the choice is the point of the whole feature.
|
|
34
|
+
*
|
|
35
|
+
* `to_tsquery` reads its argument as tsquery SYNTAX — `&`, `|`, `!`, `<->`, `:*`, parentheses — so
|
|
36
|
+
* a term straight out of a search box is either a `42601` on the first unbalanced paren or, worse,
|
|
37
|
+
* an operator the caller did not write. `plainto_tsquery` is safe but ANDs every word and throws
|
|
38
|
+
* the user's own operators away silently. `websearch_to_tsquery` is the parser Postgres ships for
|
|
39
|
+
* untrusted input: it never raises a syntax error, and it gives `"quoted phrase"`, `or` and a
|
|
40
|
+
* leading `-` the meaning every search box on the web already has. Cats & dogs is three terms.
|
|
41
|
+
*
|
|
42
|
+
* The term crosses as a BOUND PARAMETER either way — nothing here interpolates it — so the choice
|
|
43
|
+
* is not what stops an injection; the parameter is. What the parser decides is whether the user's
|
|
44
|
+
* punctuation is read as syntax, which is the second half of the same question.
|
|
45
|
+
*
|
|
46
|
+
* The configuration is spliced through `raw()`, from `SEARCH_LANGUAGES`, exactly as `asc|desc` is:
|
|
47
|
+
* a closed set of one word, chosen by this file from the ENTITY's declaration and never from a
|
|
48
|
+
* value on the wire. `regconfig` cannot be a bound parameter and stay index-matchable anyway.
|
|
49
|
+
*/
|
|
50
|
+
const searchSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFragment => {
|
|
51
|
+
const vector = entity.$search;
|
|
52
|
+
if (vector === null) throw searchUndeclared(entity.$name);
|
|
53
|
+
const column = identifier(vector.column);
|
|
54
|
+
// The term as TEXT, whatever arrived: `websearch_to_tsquery` takes text, and a number or a null
|
|
55
|
+
// reaching it as a parameter is a `42883` where the caller is owed "no rows".
|
|
56
|
+
const term = predicate.value === null || predicate.value === undefined ? '' : predicate.value;
|
|
57
|
+
return sql`${column} @@ websearch_to_tsquery(${raw(`'${vector.language}'`)}, ${String(term)})`;
|
|
58
|
+
};
|
|
59
|
+
|
|
31
60
|
const predicateSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFragment => {
|
|
61
|
+
// BEFORE the column is resolved: a `matches` predicate names `SEARCH_PROPERTY`, which is not a
|
|
62
|
+
// column and must never be looked up as one.
|
|
63
|
+
if (predicate.op === 'matches') return searchSql(entity, predicate);
|
|
32
64
|
const column = columnRef(entity, predicate.column);
|
|
33
65
|
const value = predicate.value;
|
|
34
66
|
switch (predicate.op) {
|
package/src/query.ts
CHANGED
|
@@ -8,13 +8,16 @@ import type { BatchIterator } from './batch';
|
|
|
8
8
|
import { assertBatchable, batchIterator } from './batch';
|
|
9
9
|
import { entityNow } from './clock';
|
|
10
10
|
import type { EntityCore } from './entity';
|
|
11
|
+
import { searchUndeclared } from './feature-errors';
|
|
11
12
|
import { assertPageSize, DEFAULT_PAGE_SIZE, namedColumns } from './plan';
|
|
12
13
|
import type { RelatedTables } from './preload';
|
|
13
14
|
import { preloaded } from './preload';
|
|
14
15
|
import type { Relation } from './relations';
|
|
15
16
|
import { relationNamed } from './relations';
|
|
16
17
|
import type { Page, Repo, RepoOptions, UpsertArgs } from './repo';
|
|
18
|
+
import { SEARCH_PROPERTY } from './search';
|
|
17
19
|
import type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
|
|
20
|
+
import { transitionRow } from './transition';
|
|
18
21
|
import type { ColumnMap, IdOf, Insertable, MoneyValue, RowPatch } from './types';
|
|
19
22
|
|
|
20
23
|
/**
|
|
@@ -28,6 +31,23 @@ export interface ReadBuilder<Row> {
|
|
|
28
31
|
/** Equality on the columns given. `where({ orgId })` is what satisfies the tenancy guard. */
|
|
29
32
|
where(filter: RowPatch<Row>): ReadBuilder<Row>;
|
|
30
33
|
andWhere(column: keyof Row & string, op: Operator, value: unknown): ReadBuilder<Row>;
|
|
34
|
+
/**
|
|
35
|
+
* Full-text search over the entity's generated `tsvector` — every `.searchable()` column at
|
|
36
|
+
* once, one GIN index, one predicate. `posts.where({ orgId }).search(input.q).limit(20).page()`.
|
|
37
|
+
*
|
|
38
|
+
* `term` is USER TEXT and is treated as such end to end: it crosses as a bound parameter and is
|
|
39
|
+
* parsed by `websearch_to_tsquery`, so `&`, `|`, `!`, `:*` and an unbalanced paren are characters
|
|
40
|
+
* to be matched, never operators and never a syntax error. There is deliberately no way to hand
|
|
41
|
+
* this layer a tsquery — a query language inside the query language is a second way to ask, and
|
|
42
|
+
* the one caller that would want it is the injection this method exists to make unreachable.
|
|
43
|
+
*
|
|
44
|
+
* It is an ordinary predicate, so the chain's tenancy, soft delete, projection, order, cursor
|
|
45
|
+
* and page size all mean here exactly what they mean without it. RELEVANCE is not an order this
|
|
46
|
+
* chain can serve: `ts_rank` is a computed value and the cursor carries columns, so the order
|
|
47
|
+
* stays the one the caller declared. An entity with no searchable column is `X_SEARCH_UNDECLARED`
|
|
48
|
+
* and the in-memory driver is `X_SEARCH_IN_MEMORY` — never a different answer from the two.
|
|
49
|
+
*/
|
|
50
|
+
search(term: string): ReadBuilder<Row>;
|
|
31
51
|
orderBy(column: keyof Row & string, direction?: SortDirection): ReadBuilder<Row>;
|
|
32
52
|
limit(rows: number): ReadBuilder<Row>;
|
|
33
53
|
/** The cursor from the previous page. */
|
|
@@ -128,6 +148,32 @@ export interface ReadBuilder<Row> {
|
|
|
128
148
|
}
|
|
129
149
|
|
|
130
150
|
export interface Table<Row, C extends ColumnMap = ColumnMap> extends ReadBuilder<Row> {
|
|
151
|
+
/**
|
|
152
|
+
* Move one row through the state machine `column` declares, in ONE statement.
|
|
153
|
+
*
|
|
154
|
+
* `from` is the state the caller believes the row is in, and it rides in the statement's own
|
|
155
|
+
* predicate — so the state that was observed and the state that was written are one decision,
|
|
156
|
+
* made under the row's lock. A read-then-check-then-write is the same call with a window in it,
|
|
157
|
+
* and under two concurrent callers the second one writes a transition out of a state the row had
|
|
158
|
+
* already left. Here the second statement matches no row: `X_STATE_CONFLICT`, naming the state
|
|
159
|
+
* the row is really in.
|
|
160
|
+
*
|
|
161
|
+
* A move the machine does not hold is `X_STATE_TRANSITION_ILLEGAL` and never reaches the
|
|
162
|
+
* database — the table is a property of the declaration. A move out of a TERMINAL state is the
|
|
163
|
+
* same code saying so; a terminal state is one whose outgoing list is empty, which is the whole
|
|
164
|
+
* of the concept and the only part of it the framework owns. Which state is terminal, what any
|
|
165
|
+
* of them mean, who may make a move and what happens on arrival are the app's, every one.
|
|
166
|
+
*
|
|
167
|
+
* Tenant-scoped exactly as `updateWhere` is, because it IS one: a row in another org matches no
|
|
168
|
+
* statement and reads back as absent, so the answer is `X_NOT_FOUND` rather than a conflict that
|
|
169
|
+
* would confirm it exists.
|
|
170
|
+
*/
|
|
171
|
+
transition<K extends keyof Row & string>(
|
|
172
|
+
column: K,
|
|
173
|
+
id: IdOf<Row>,
|
|
174
|
+
move: { readonly from: Row[K] & string; readonly to: Row[K] & string },
|
|
175
|
+
options?: RepoOptions,
|
|
176
|
+
): Promise<Row>;
|
|
131
177
|
insert(values: Insertable<C>, options?: RepoOptions): Promise<Row>;
|
|
132
178
|
/**
|
|
133
179
|
* Many rows, one statement — the bulk write a per-row `insert` loop is the N+1 of, and the line
|
|
@@ -241,6 +287,16 @@ const builder = <Source, Row>(
|
|
|
241
287
|
|
|
242
288
|
andWhere: (column, op, value) => next({ where: [...state.where, { column, op, value }] }),
|
|
243
289
|
|
|
290
|
+
// Refused HERE as well as at the statement, because this is the line the author wrote: a chain
|
|
291
|
+
// over an entity that declares nothing searchable can never produce a match, and the repair is
|
|
292
|
+
// one edit to the schema rather than anything about this call.
|
|
293
|
+
search: (term) => {
|
|
294
|
+
if (entity.$search === null) throw searchUndeclared(entity.$name);
|
|
295
|
+
return next({
|
|
296
|
+
where: [...state.where, { column: SEARCH_PROPERTY, op: 'matches', value: term }],
|
|
297
|
+
});
|
|
298
|
+
},
|
|
299
|
+
|
|
244
300
|
orderBy: (column, direction = 'asc') =>
|
|
245
301
|
next({ orderBy: [...state.orderBy, { column, direction }] }),
|
|
246
302
|
|
|
@@ -401,4 +457,9 @@ export const tableFor = <Row, C extends ColumnMap>(
|
|
|
401
457
|
deleteWhere: async (filter, options) => repo.deleteWhere(filter, options),
|
|
402
458
|
updateWhere: async (filter, patch, options) =>
|
|
403
459
|
repo.updateWhere(filter, touch(entity, patch), options),
|
|
460
|
+
// `touch` is passed rather than applied here: a transition IS an update, so an `onUpdateNow()`
|
|
461
|
+
// column has to move exactly as `update(id, patch)` moves it — which is also the audit of WHEN
|
|
462
|
+
// the row moved, using the stamp already declared instead of a second one beside it.
|
|
463
|
+
transition: async (column, id, move, options) =>
|
|
464
|
+
transitionRow(entity, repo, column, id, move, (patch) => touch(entity, patch), options),
|
|
404
465
|
});
|
package/src/registry.ts
CHANGED
|
@@ -25,6 +25,15 @@ export interface ColumnDescription {
|
|
|
25
25
|
* projection reaches no `alter table` at all. It reached none until 3.0.
|
|
26
26
|
*/
|
|
27
27
|
readonly onDelete: OnDelete | null;
|
|
28
|
+
/**
|
|
29
|
+
* The `generated always as (<expr>) stored` body, when the DATABASE computes this column rather
|
|
30
|
+
* than a writer. Absent on every ordinary column, matching `IndexDescription.using`: a
|
|
31
|
+
* description written before this existed reads the same, so nothing regenerates.
|
|
32
|
+
*
|
|
33
|
+
* `@ultimat3/db` is tier 1 and cannot import this package, so — exactly like `onDelete` — a
|
|
34
|
+
* field that is not on this projection reaches no DDL at all.
|
|
35
|
+
*/
|
|
36
|
+
readonly generated?: string;
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
/**
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// The full-text search vector an entity DERIVES from its `.searchable()` columns: one generated
|
|
2
|
+
// `tsvector` column, one language, one weight per source. Everything spliced into the expression
|
|
3
|
+
// here comes from a closed set or from a physical column name `assertColumnName` already checked —
|
|
4
|
+
// a search TERM never reaches this file, because a term is bound as a parameter (`pg-sql.ts`).
|
|
5
|
+
|
|
6
|
+
import type { SearchWeight } from './types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Postgres' own default text search configurations, as `\dF` lists them on 13 and later. A CLOSED
|
|
10
|
+
* set because the configuration is the one part of `to_tsvector(config, text)` that cannot be a
|
|
11
|
+
* bound parameter inside a generated column — it is spliced — so it may only ever be a value this
|
|
12
|
+
* file already contains. A server without one of these answers `3F000` at `create table`, which is
|
|
13
|
+
* loud and lands on the author, not on a search.
|
|
14
|
+
*/
|
|
15
|
+
export const SEARCH_LANGUAGES = [
|
|
16
|
+
'arabic',
|
|
17
|
+
'armenian',
|
|
18
|
+
'basque',
|
|
19
|
+
'catalan',
|
|
20
|
+
'danish',
|
|
21
|
+
'dutch',
|
|
22
|
+
'english',
|
|
23
|
+
'finnish',
|
|
24
|
+
'french',
|
|
25
|
+
'german',
|
|
26
|
+
'greek',
|
|
27
|
+
'hindi',
|
|
28
|
+
'hungarian',
|
|
29
|
+
'indonesian',
|
|
30
|
+
'irish',
|
|
31
|
+
'italian',
|
|
32
|
+
'lithuanian',
|
|
33
|
+
'nepali',
|
|
34
|
+
'norwegian',
|
|
35
|
+
'portuguese',
|
|
36
|
+
'romanian',
|
|
37
|
+
'russian',
|
|
38
|
+
'serbian',
|
|
39
|
+
'simple',
|
|
40
|
+
'spanish',
|
|
41
|
+
'swedish',
|
|
42
|
+
'tamil',
|
|
43
|
+
'turkish',
|
|
44
|
+
'yiddish',
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
export type SearchLanguage = (typeof SEARCH_LANGUAGES)[number];
|
|
48
|
+
|
|
49
|
+
/** The one membership test. `includes` on the tuple, never a computed read of a table. */
|
|
50
|
+
export const isSearchLanguage = (value: unknown): value is SearchLanguage =>
|
|
51
|
+
typeof value === 'string' && (SEARCH_LANGUAGES as readonly string[]).includes(value);
|
|
52
|
+
|
|
53
|
+
export const SEARCH_WEIGHTS = ['A', 'B', 'C', 'D'] as const;
|
|
54
|
+
|
|
55
|
+
export const isSearchWeight = (value: unknown): value is SearchWeight =>
|
|
56
|
+
typeof value === 'string' && (SEARCH_WEIGHTS as readonly string[]).includes(value);
|
|
57
|
+
|
|
58
|
+
/** Postgres' own default weight, so an unweighted source ranks exactly as an unweighted vector. */
|
|
59
|
+
export const DEFAULT_SEARCH_WEIGHT: SearchWeight = 'D';
|
|
60
|
+
|
|
61
|
+
export const DEFAULT_SEARCH_LANGUAGE: SearchLanguage = 'english';
|
|
62
|
+
|
|
63
|
+
export const DEFAULT_SEARCH_COLUMN = 'search_tsv';
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* What a `matches` predicate names instead of a column. `$`-prefixed for the reason every member
|
|
67
|
+
* of `EntityCore` is: `assertColumnName` requires `[a-z_]` first, so no declared column can ever
|
|
68
|
+
* be spelled this, and a `matches` predicate can therefore never be confused with one on a real
|
|
69
|
+
* column. Nothing resolves it through `physicalName` — both drivers branch on the OPERATOR.
|
|
70
|
+
*/
|
|
71
|
+
export const SEARCH_PROPERTY = '$search';
|
|
72
|
+
|
|
73
|
+
export interface SearchSource {
|
|
74
|
+
/** Physical column, already through `assertColumnName`. */
|
|
75
|
+
readonly column: string;
|
|
76
|
+
readonly weight: SearchWeight;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** How an entity's search is declared, when the defaults do not fit the table it adopted. */
|
|
80
|
+
export interface SearchInit {
|
|
81
|
+
/** The physical vector column, when `search_tsv` is taken or the table already named one. */
|
|
82
|
+
readonly column?: string;
|
|
83
|
+
readonly language?: SearchLanguage;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface SearchVector {
|
|
87
|
+
/** The physical `tsvector` column. Never a row property. */
|
|
88
|
+
readonly column: string;
|
|
89
|
+
readonly language: SearchLanguage;
|
|
90
|
+
readonly sources: readonly SearchSource[];
|
|
91
|
+
/** The `generated always as (…) stored` body. Deterministic in declaration order. */
|
|
92
|
+
readonly expression: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* One `setweight(to_tsvector(…))` per source, concatenated in DECLARATION order.
|
|
97
|
+
*
|
|
98
|
+
* `setweight` even for a single unweighted source, so adding a second column never rewrites the
|
|
99
|
+
* first one's spelling — and a spelling change here is a `drop column` + `add column` on a table
|
|
100
|
+
* that may hold every row an app has. `coalesce(…, '')` because `to_tsvector` of NULL is NULL and
|
|
101
|
+
* `NULL || tsvector` is NULL: one nullable source would erase the whole vector for that row.
|
|
102
|
+
*
|
|
103
|
+
* Every function in it is immutable, which is what Postgres requires of a generated column —
|
|
104
|
+
* `to_tsvector(text)` with no configuration is NOT (it reads `default_text_search_config`), which
|
|
105
|
+
* is why the language is named here and never left to the server.
|
|
106
|
+
*/
|
|
107
|
+
export const searchExpression = (
|
|
108
|
+
language: SearchLanguage,
|
|
109
|
+
sources: readonly SearchSource[],
|
|
110
|
+
): string =>
|
|
111
|
+
sources
|
|
112
|
+
.map(
|
|
113
|
+
(source) =>
|
|
114
|
+
`setweight(to_tsvector('${language}', coalesce("${source.column}", '')), '${source.weight}')`,
|
|
115
|
+
)
|
|
116
|
+
.join(' || ');
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The vector a set of already-resolved sources describes, or `null` when there are none.
|
|
120
|
+
*
|
|
121
|
+
* The physical names arrive resolved and the collision check arrives as `taken`, so this module
|
|
122
|
+
* imports nothing from `column.ts` — which imports THIS one for `.searchable()`. A cycle between
|
|
123
|
+
* the column chain and the thing a column modifier declares is avoidable, so it is avoided.
|
|
124
|
+
*/
|
|
125
|
+
export const searchVectorOf = (
|
|
126
|
+
sources: readonly SearchSource[],
|
|
127
|
+
init: SearchInit | undefined,
|
|
128
|
+
taken: (column: string) => boolean,
|
|
129
|
+
refuse: (subject: string, detail: string) => never,
|
|
130
|
+
): SearchVector | null => {
|
|
131
|
+
if (sources.length === 0) {
|
|
132
|
+
if (init === undefined) return null;
|
|
133
|
+
return refuse(
|
|
134
|
+
'search',
|
|
135
|
+
'search is declared but no column is searchable — add .searchable() to a text() column, or drop the search option',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
const language = init?.language ?? DEFAULT_SEARCH_LANGUAGE;
|
|
139
|
+
if (!isSearchLanguage(language)) {
|
|
140
|
+
return refuse(
|
|
141
|
+
'search',
|
|
142
|
+
`"${String(language)}" is not a Postgres text search configuration — one of: ${SEARCH_LANGUAGES.join(', ')}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const column = init?.column ?? DEFAULT_SEARCH_COLUMN;
|
|
146
|
+
if (taken(column)) {
|
|
147
|
+
return refuse(
|
|
148
|
+
'search',
|
|
149
|
+
`the search vector column "${column}" is already a declared column — rename it, or name another with search: { column: '<name>' }`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return { column, language, sources, expression: searchExpression(language, sources) };
|
|
153
|
+
};
|