@ultimat3/entity 11.3.0 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -3,6 +3,8 @@
3
3
  /** Re-exported so an `entity` file needs one import, not two. Same object as schema's. */
4
4
  export type { Infer } from '@ultimat3/schema';
5
5
  export { t } from '@ultimat3/schema';
6
+ export type { AggregateFn } from './aggregate';
7
+ export { AVG_SCALE } from './aggregate';
6
8
  export type { BatchIterator } from './batch';
7
9
  export type { MoneyColumns } from './column';
8
10
  export { columnName, moneyColumns, snake } from './column';
@@ -20,9 +22,6 @@ export {
20
22
  url,
21
23
  uuid,
22
24
  } from './columns';
23
- // The vocabulary an EXISTING schema needs. Separate from the blessed builders on purpose: those
24
- // are decisions this framework made for a table it was going to create, and these are the shapes
25
- // a table already has (`docs`: Entities-And-Migrations, "Adopting an existing database").
26
25
  export type { DecimalOptions } from './columns-data';
27
26
  export { arrayOf, bigint, bytes, date, decimal, json } from './columns-data';
28
27
  // `crossTenantReason` stays internal: an app that could read the flag would have a second way to
@@ -34,6 +33,9 @@ export type { DescribeInput } from './describe';
34
33
  export { sqlTypeOf } from './describe';
35
34
  export type { Entity, EntityCore, EntityInit, IndexInit } from './entity';
36
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").
37
39
  export type {
38
40
  EntityErrorCode,
39
41
  PreloadCandidate,
@@ -63,6 +65,15 @@ export {
63
65
  writeUnfiltered,
64
66
  } from './errors';
65
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';
66
77
  export type { Invariant, InvariantDef, InvariantKind } from './invariants';
67
78
  export {
68
79
  assertInvariants,
@@ -72,6 +83,7 @@ export {
72
83
  MAX_ASSERTED_ROWS,
73
84
  toSql,
74
85
  } from './invariants';
86
+ export { memoryRepo, memoryTransactor } from './memory-repo';
75
87
  export type { StatementLoop } from './n-plus-one';
76
88
  export { N_PLUS_ONE_THRESHOLD, nPlusOne, preloadsFor } from './n-plus-one';
77
89
  export type { PostgresDriverOptions } from './pg-driver';
@@ -111,9 +123,22 @@ export type {
111
123
  Tx,
112
124
  UpsertArgs,
113
125
  } from './repo';
114
- export { memoryRepo, memoryTransactor } from './repo';
115
126
  export type { RowBulkChange, RowChange, RowChangeOp, RowObserver } from './row-observer';
116
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';
117
142
  export type {
118
143
  Seed,
119
144
  SeedContext,
@@ -126,6 +151,16 @@ export type {
126
151
  SeedWrite,
127
152
  } from './seed';
128
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';
129
164
  export type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
130
165
  export {
131
166
  assertRowTenant,
@@ -139,6 +174,7 @@ export {
139
174
  scopedPlan,
140
175
  tenantColumnOf,
141
176
  } from './tenancy';
177
+ export type { Move } from './transition';
142
178
  export type {
143
179
  AnyColumn,
144
180
  Column,
@@ -146,6 +182,7 @@ export type {
146
182
  ColumnKind,
147
183
  ColumnMap,
148
184
  ColumnMeta,
185
+ EnumeratedColumn,
149
186
  IdOf,
150
187
  IndexDef,
151
188
  Insertable,
@@ -156,6 +193,7 @@ export type {
156
193
  ReferenceOptions,
157
194
  RowOf,
158
195
  RowPatch,
196
+ SearchWeight,
159
197
  TimestampColumn,
160
198
  TypeOf,
161
199
  UuidColumn,
package/src/instant.ts ADDED
@@ -0,0 +1,93 @@
1
+ // Single responsibility: a `timestamptz` value at the precision the COLUMN stores it, rather than
2
+ // the precision a JS `Date` holds. The column keeps microseconds and a `Date` keeps milliseconds,
3
+ // so a page position minted from a decoded row ranks rows differently from the `order by` that
4
+ // produced them. Microseconds since the epoch is the one form both sides can be exact in, and this
5
+ // file is the only place the two representations meet.
6
+
7
+ /** A `Date` is exactly this much coarser than the column it came out of. */
8
+ const MICROS_PER_MILLI = 1000n;
9
+
10
+ const MICROS_PER_SECOND = 1_000_000n;
11
+
12
+ const FRACTION_DIGITS = 6;
13
+
14
+ /**
15
+ * What `(col at time zone 'UTC')::text` prints: `2026-01-01 00:00:00.123456`, with the fraction
16
+ * omitted entirely when every digit of it is zero and TRUNCATED when the trailing ones are —
17
+ * `.1` is a tenth of a second, not one microsecond, which is why the fraction is padded on the
18
+ * right and never on the left.
19
+ *
20
+ * The year is `\d{4,}` because Postgres prints one wider than four digits unpadded; a `BC` suffix
21
+ * matches nothing here on purpose, and an unmatched text falls back to the decoded `Date`.
22
+ */
23
+ const PG_INSTANT_TEXT = /^(\d{4,})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?$/;
24
+
25
+ /**
26
+ * `Date.UTC` maps years 0–99 into the 1900s, so the epoch is built by assignment instead. Whole
27
+ * seconds only: the fraction is added in the microsecond domain, where it is exact.
28
+ */
29
+ const utcSecondMillis = (parts: readonly number[]): number => {
30
+ const [year = 0, month = 1, day = 1, hour = 0, minute = 0, second = 0] = parts;
31
+ const at = new Date(0);
32
+ at.setUTCFullYear(year, month - 1, day);
33
+ at.setUTCHours(hour, minute, second, 0);
34
+ return at.getTime();
35
+ };
36
+
37
+ /** Floor division: `-1n / 2n` truncates toward zero, which would place a pre-1970 instant late. */
38
+ const floorDiv = (value: bigint, by: bigint): bigint => {
39
+ const remainder = ((value % by) + by) % by;
40
+ return (value - remainder) / by;
41
+ };
42
+
43
+ /**
44
+ * The exact microsecond epoch of a `timestamptz` Postgres rendered as text, or `undefined` when
45
+ * the text is not one — a caller with nothing to read falls back to the decoded `Date`, which is
46
+ * the position it always had.
47
+ */
48
+ export const pgInstantMicros = (text: unknown): bigint | undefined => {
49
+ if (typeof text !== 'string') return undefined;
50
+ const match = PG_INSTANT_TEXT.exec(text);
51
+ if (match === null) return undefined;
52
+ const [, year = '', month = '', day = '', hour = '', minute = '', second = '', fraction] = match;
53
+ const millis = utcSecondMillis([year, month, day, hour, minute, second].map(Number));
54
+ if (!Number.isFinite(millis)) return undefined;
55
+ // The fraction is always forward in time, so it ADDS even when the second boundary is negative.
56
+ return BigInt(millis) * MICROS_PER_MILLI + BigInt((fraction ?? '').padEnd(FRACTION_DIGITS, '0'));
57
+ };
58
+
59
+ /**
60
+ * The microsecond epoch of whatever a sort key is holding: a decoded row's `Date` (milliseconds,
61
+ * so the last three digits are zero), a value already counted in microseconds, or the decimal a
62
+ * cursor carries. `undefined` for anything else, so a caller decides rather than guessing at `0`.
63
+ */
64
+ export const instantMicros = (value: unknown): bigint | undefined => {
65
+ if (typeof value === 'bigint') return value;
66
+ if (value instanceof Date) {
67
+ const millis = value.getTime();
68
+ return Number.isNaN(millis) ? undefined : BigInt(millis) * MICROS_PER_MILLI;
69
+ }
70
+ if (typeof value === 'string' && /^-?\d+$/.test(value)) return BigInt(value);
71
+ return undefined;
72
+ };
73
+
74
+ /**
75
+ * The instant a seek binds, spelled so Postgres parses it back to the same microsecond. ISO 8601
76
+ * in UTC with all six fraction digits — `toISOString()` alone is milliseconds, which is the whole
77
+ * defect this file exists to close, so the fraction is written here rather than read off the
78
+ * `Date`.
79
+ */
80
+ export const microsToIso = (micros: bigint): string => {
81
+ const second = floorDiv(micros, MICROS_PER_SECOND);
82
+ const fraction = micros - second * MICROS_PER_SECOND;
83
+ const whole = new Date(Number(second) * 1000).toISOString();
84
+ return `${whole.slice(0, whole.indexOf('.'))}.${String(fraction).padStart(FRACTION_DIGITS, '0')}Z`;
85
+ };
86
+
87
+ /**
88
+ * The output name the microsecond half of a sort key comes back under. Every physical column name
89
+ * in this framework is lower case — `columnName` is either `snake(property)`, which lower-cases,
90
+ * or a `.column()` name `assertColumnName` refuses unless it matches `[a-z_][a-z0-9_$]*` — so an
91
+ * UPPER-CASE suffix is a name no entity can declare and this alias can never shadow a column.
92
+ */
93
+ export const seekAlias = (physicalColumn: string): string => `${physicalColumn}$US`;
@@ -6,9 +6,12 @@
6
6
 
7
7
  import { compareDecimalText } from '@ultimat3/core';
8
8
  import { keyOf } from './batch-read';
9
+ import { arrayContains, arrayOverlaps, jsonContains, jsonHasKey } from './containment';
9
10
  import { kindOf, valueAt } from './cursor';
10
11
  import type { EntityCore } from './entity';
11
12
  import { EntityError } from './errors';
13
+ import { searchInMemory } from './feature-errors';
14
+ import { instantMicros } from './instant';
12
15
  import type { Predicate } from './tenancy';
13
16
  import type { ColumnKind } from './types';
14
17
 
@@ -46,6 +49,25 @@ export const compareByKind = (
46
49
  left: unknown,
47
50
  right: unknown,
48
51
  ): number => {
52
+ // NULL is the LARGEST value, which is what `order by` means in Postgres and what `nulls last`
53
+ // ascending / `nulls first` descending spell out (`pg-sql.ts`'s `orderSql`, `@ultimat3/query`'s
54
+ // `compareValues`). Two absences are equal, so the next sort key decides — without this rule a
55
+ // NULL fell through to `String(left) < String(right)` and sorted as the four characters `null`,
56
+ // somewhere in the middle of the alphabet, which is a different listing from the one the
57
+ // database returns and a page boundary cut where the server cuts none.
58
+ if (isNull(left) || isNull(right)) {
59
+ return isNull(left) && isNull(right) ? 0 : isNull(left) ? 1 : -1;
60
+ }
61
+ // A `timestamptz` is compared in MICROSECONDS, which is what the column holds and what a cursor
62
+ // now carries (`cursor.ts`). The two sides are not the same shape and that is the point: a
63
+ // stored row here is a `Date` and a keyset position is a microsecond count, so a `Date`/`Date`
64
+ // test alone would fall through to `String(left) < String(right)` and order a page by the text
65
+ // of an ISO string against a decimal.
66
+ if (kind === 'timestamptz') {
67
+ const before = instantMicros(left);
68
+ const after = instantMicros(right);
69
+ if (before !== undefined && after !== undefined) return sign(before, after);
70
+ }
49
71
  if (left instanceof Date && right instanceof Date) return sign(left.getTime(), right.getTime());
50
72
  if (kind !== undefined && DECIMAL_TEXT.has(kind)) {
51
73
  // `undefined` when either side is not a plain decimal — that pair is not a numeric comparison,
@@ -137,6 +159,10 @@ export const matchesPredicate = <Row>(
137
159
  row: unknown,
138
160
  predicate: Predicate,
139
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);
140
166
  // The column's declared kind, resolved once — `price.minor` included, which is the path a money
141
167
  // predicate and a money sort key both name.
142
168
  const kind = kindOf(entity, predicate.column);
@@ -199,5 +225,51 @@ export const matchesPredicate = <Row>(
199
225
  return isNull(actual);
200
226
  case 'is-not-null':
201
227
  return !isNull(actual);
228
+ // The containment half, decided by the column's DECLARED kind exactly as everything above it
229
+ // is: `@>` on a `jsonb` is recursive structural containment and `@>` on an array is plain
230
+ // element containment, and those are two different operators that happen to share a symbol.
231
+ // A NULL column value matches nothing, which is what the SQL answers too.
232
+ case 'contains':
233
+ return !isNull(actual) && containsBy(kind, actual, predicate.value);
234
+ case 'contained-by':
235
+ return !isNull(actual) && containsBy(kind, predicate.value, actual);
236
+ // `&&` is arrays only. A `jsonb` column reaching it is refused rather than guessed at: there
237
+ // is no `jsonb && jsonb` in Postgres, so any answer here would be one no statement can make.
238
+ case 'overlaps':
239
+ return (
240
+ !isNull(actual) &&
241
+ arrayOverlaps(
242
+ asArray(entity, predicate, actual),
243
+ asArray(entity, predicate, predicate.value),
244
+ )
245
+ );
246
+ case 'has-key':
247
+ return jsonHasKey(actual, predicate.value);
248
+ }
249
+ };
250
+
251
+ /** `left @> right`, under the rule the LEFT column's kind decides. */
252
+ const containsBy = (kind: ColumnKind | undefined, left: unknown, right: unknown): boolean =>
253
+ kind === 'jsonb'
254
+ ? jsonContains(left, right)
255
+ : arrayContains(Array.isArray(left) ? left : [left], Array.isArray(right) ? right : [right]);
256
+
257
+ /**
258
+ * The operand of an array-only operator. A `jsonb` column here is the caller asking for an
259
+ * operator Postgres does not have on that type, so it is refused where they wrote it rather than
260
+ * answered with something the database never would.
261
+ */
262
+ const asArray = <Row>(
263
+ entity: EntityCore<Row>,
264
+ predicate: Predicate,
265
+ value: unknown,
266
+ ): readonly unknown[] => {
267
+ if (kindOf(entity, predicate.column) === 'jsonb') {
268
+ throw new EntityError({
269
+ code: 'X_INVARIANT_VIOLATED',
270
+ cause: `${entity.$name}.${predicate.column} is jsonb, and Postgres has no && (overlaps) operator for jsonb`,
271
+ fix: `${entity.$name}.andWhere('${predicate.column}', 'contains', <value>) # @> matches nested structure; && is for arrayOf() columns`,
272
+ });
202
273
  }
274
+ return Array.isArray(value) ? value : [value];
203
275
  };