@ultimat3/entity 27.13.0 → 27.13.1

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/README.md CHANGED
@@ -465,6 +465,16 @@ visibility, never its page.
465
465
  | Refused | `min`/`max` on `text` (ordering is the database's collation there and JS code-unit order here); `avg` over money (every answer would be a silent rounding of a fraction of a minor unit — `sum()` and `count()` instead, dividing where the rounding is a decision); an amount covering more than one currency **or scale**; a money total past ±2^53 minor units |
466
466
  | `approximateCount()` | `reltuples` out of `pg_class`, constant time, because `count(*)` walks every visible row and no index can make it cheaper. The whole **table** — a filtered chain and every tenant-scoped entity are `X_APPROXIMATE_COUNT_FILTERED`, and `null` means the table has never been analysed |
467
467
 
468
+ ## The operator vocabulary
469
+
470
+ `andWhere(column, op, value)` takes one of `OPERATORS` — `eq`, `neq`, `in`, `gt`, `gte`, `lt`,
471
+ `lte`, `like`, `is-null`, `is-not-null`, `contains`, `contained-by`, `overlaps`, `has-key`,
472
+ `matches` — and the `Operator` type is derived from that one list. An op outside it (`'>'`, `'GT'`)
473
+ is `X_ENTITY_OPERATOR_UNKNOWN` on the `andWhere` line, and in both drivers for a hand-built plan —
474
+ never an empty result. The `fix` names the operator meant: `>`→`gt`, `>=`→`gte`, `<`→`lt`,
475
+ `<=`→`lte`, `=`→`eq`, `!=`/`<>`→`neq`. `isOperator(value)` is the same test, for an op read off
476
+ the wire.
477
+
468
478
  ## Filtering inside a json() or arrayOf() column
469
479
 
470
480
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "27.13.0",
3
+ "version": "27.13.1",
4
4
  "description": "A table + its domain type + invariants the database also enforces",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,9 +33,9 @@
33
33
  "test": "bun test"
34
34
  },
35
35
  "dependencies": {
36
- "@ultimat3/core": "27.13.0",
37
- "@ultimat3/db": "27.13.0",
38
- "@ultimat3/schema": "27.13.0",
39
- "@ultimat3/time": "27.13.0"
36
+ "@ultimat3/core": "27.13.1",
37
+ "@ultimat3/db": "27.13.1",
38
+ "@ultimat3/schema": "27.13.1",
39
+ "@ultimat3/time": "27.13.1"
40
40
  }
41
41
  }
@@ -39,6 +39,7 @@ export const ENTITY_ERROR_CODES = [
39
39
  'X_ENTITY_SEALED_PREDICATE',
40
40
  'X_ENTITY_SEALED_IN_VIEW',
41
41
  'X_ENTITY_APPEND_ONLY',
42
+ 'X_ENTITY_OPERATOR_UNKNOWN',
42
43
  ] as const;
43
44
 
44
45
  export type EntityErrorCode = (typeof ENTITY_ERROR_CODES)[number];
@@ -72,6 +73,7 @@ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityErrorCode, string>> = {
72
73
  'a sealed column is read by a predicate, an order or a rule the database evaluates',
73
74
  X_ENTITY_SEALED_IN_VIEW: 'a sealed column is named in a view',
74
75
  X_ENTITY_APPEND_ONLY: 'an append-only entity was asked to rewrite or remove a row',
76
+ X_ENTITY_OPERATOR_UNKNOWN: 'a predicate names an operator outside the vocabulary',
75
77
  };
76
78
 
77
79
  // Registered at module load, unconditionally, in one call. Without this the registry humanises the
@@ -82,8 +84,8 @@ registerErrorCodes(
82
84
  );
83
85
 
84
86
  /**
85
- * How the codes here that are worth classifying are retried. The two sealed-column refusals and
86
- * the append-only refusal: each is the app's own declaration or call, so attempt five answers what attempt one did — and
87
+ * How the codes here that are worth classifying are retried. The two sealed-column refusals, the
88
+ * append-only refusal and the unknown operator: each is the app's own declaration or call, so attempt five answers what attempt one did — and
87
89
  * left unclassified, `classifyThrown` reads them as undecided and a job spends its whole retry
88
90
  * policy re-proving it. Listed although `terminal` is the default, for the reason core's own table
89
91
  * gives for `X_NOT_IMPLEMENTED`. The rest of this package's codes are unclassified, as they were.
@@ -92,6 +94,7 @@ export const ENTITY_ERROR_RETRY = {
92
94
  X_ENTITY_SEALED_PREDICATE: 'terminal',
93
95
  X_ENTITY_SEALED_IN_VIEW: 'terminal',
94
96
  X_ENTITY_APPEND_ONLY: 'terminal',
97
+ X_ENTITY_OPERATOR_UNKNOWN: 'terminal',
95
98
  } as const satisfies Readonly<Partial<Record<EntityErrorCode, ErrorRetry>>>;
96
99
 
97
100
  registerErrorRetry(ENTITY_ERROR_RETRY);
package/src/index.ts CHANGED
@@ -87,6 +87,7 @@ export { compareByKind, sameValueOfKind } from './memory-match';
87
87
  export { memoryRepo, memoryTransactor } from './memory-repo';
88
88
  export type { StatementLoop } from './n-plus-one';
89
89
  export { N_PLUS_ONE_THRESHOLD, nPlusOne, preloadsFor } from './n-plus-one';
90
+ export { isOperator, OPERATORS } from './operator';
90
91
  export { persistedRecordTypes } from './persisted-types';
91
92
  // For a change feed holding a table name and raw columns: `entityForTable` + `decodeRow` is the row
92
93
  // the app declared, money and all. Exported for `@ultimat3/realtime` (plan 101, 06 i).
@@ -14,6 +14,7 @@ import { instantMicros } from './instant';
14
14
  import { isNullish as isNull } from './is-null';
15
15
  import { likeMatches } from './like';
16
16
  import { DECIMAL_TEXT, numericOrder } from './numeric-compare';
17
+ import { operatorUnknown } from './operator';
17
18
  import type { Predicate } from './tenancy';
18
19
  import type { ColumnKind } from './types';
19
20
 
@@ -188,6 +189,11 @@ export const matchesPredicate = <Row>(
188
189
  );
189
190
  case 'has-key':
190
191
  return jsonHasKey(actual, predicate.value);
192
+ // Unreachable for a typed call — and that is the case it exists for: an operator built from a
193
+ // string or cast past the type fell out of this switch as `undefined`, every row failed the
194
+ // filter and the read answered an empty list with no error. `pg-sql.ts` refuses identically.
195
+ default:
196
+ throw operatorUnknown(entity.$name, predicate.column, predicate.op);
191
197
  }
192
198
  };
193
199
 
@@ -0,0 +1,121 @@
1
+ // The predicate vocabulary as ONE runtime list, the `Operator` type derived from it, and the one
2
+ // refusal for anything outside it. Both drivers and the chain's `andWhere` raise the same error, so
3
+ // an operator the type would have rejected cannot become "no rows" in one driver and a misleading
4
+ // SQL refusal in the other just because nothing typechecked the call.
5
+
6
+ import { EntityError } from './entity-error';
7
+ import type { Predicate } from './tenancy';
8
+
9
+ /**
10
+ * The predicate vocabulary, closed. The four after `is-not-null` are the CONTAINMENT half, added
11
+ * 2026-08-24: a `json()` or `arrayOf()` column was declared, written and then unfilterable — the ten
12
+ * operators before them could compare a column to a scalar and nothing else — so an app storing
13
+ * either had to leave the query language for hand-written SQL, which is the one read path in this
14
+ * framework with no tenancy guard on it. Their meaning is Postgres', written once in
15
+ * `containment.ts` and read by both drivers.
16
+ *
17
+ * There is deliberately no jsonpath EXPRESSION operator beside them: `contains` already matches
18
+ * nested structure (`data @> '{"a":{"b":1}}'`), and a path language inside the query language
19
+ * would be a second way to ask one question.
20
+ *
21
+ * `matches` is the FULL-TEXT half, added 2026-08-24. Its `column` is not a column: it is
22
+ * `SEARCH_PROPERTY`, and both drivers branch on the OPERATOR and read the entity's own `$search` —
23
+ * a `tsvector` is a physical column no row carries. The operand is a search TERM, always bound.
24
+ */
25
+ export const OPERATORS = [
26
+ 'eq',
27
+ 'neq',
28
+ 'in',
29
+ 'gt',
30
+ 'gte',
31
+ 'lt',
32
+ 'lte',
33
+ 'like',
34
+ 'is-null',
35
+ 'is-not-null',
36
+ 'contains',
37
+ 'contained-by',
38
+ 'overlaps',
39
+ 'has-key',
40
+ 'matches',
41
+ ] as const;
42
+
43
+ export type Operator = (typeof OPERATORS)[number];
44
+
45
+ const KNOWN: ReadonlySet<unknown> = new Set<unknown>(OPERATORS);
46
+
47
+ export const isOperator = (op: unknown): op is Operator => KNOWN.has(op);
48
+
49
+ /** The SQL spellings an author reaches for first, each to the operator it means here. */
50
+ const SQL_SYMBOLS: ReadonlyMap<string, Operator> = new Map<string, Operator>([
51
+ ['>', 'gt'],
52
+ ['>=', 'gte'],
53
+ ['<', 'lt'],
54
+ ['<=', 'lte'],
55
+ ['=', 'eq'],
56
+ ['==', 'eq'],
57
+ ['===', 'eq'],
58
+ ['!=', 'neq'],
59
+ ['!==', 'neq'],
60
+ ['<>', 'neq'],
61
+ ]);
62
+
63
+ /**
64
+ * The operator a near miss meant: a SQL symbol, or a known word in another case or with `_` or a
65
+ * space for `-` (`'GT'`, `'is_null'`, `'is not null'`). `undefined` when nothing is close enough
66
+ * to name — a guess there would be a fix that changes the question.
67
+ */
68
+ export const operatorMeant = (op: unknown): Operator | undefined => {
69
+ if (typeof op !== 'string') return undefined;
70
+ const trimmed = op.trim();
71
+ const symbol = SQL_SYMBOLS.get(trimmed);
72
+ if (symbol !== undefined) return symbol;
73
+ const word = trimmed.toLowerCase().replaceAll(/[\s_]+/g, '-');
74
+ return isOperator(word) ? word : undefined;
75
+ };
76
+
77
+ /** A value from an app, rendered so building the message can never throw instead of refusing. */
78
+ const shown = (op: unknown): string =>
79
+ typeof op === 'string' ? `'${op}'` : typeof op === 'symbol' ? String(op) : `a ${typeof op}`;
80
+
81
+ /** A column name as the single-quoted literal the call is written with. */
82
+ const quoted = (text: string): string =>
83
+ `'${text.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`;
84
+
85
+ /**
86
+ * An operator outside `OPERATORS`. The fix OPENS with the call to paste — the operator meant
87
+ * substituted when there is one, a placeholder when nothing is close — and carries the whole list
88
+ * after it; the sentence about what went wrong is the cause's. The call is written as `andWhere(…)`,
89
+ * never `<entity>.andWhere(…)`: the entity name is not the variable the author's chain hangs off
90
+ * (`describeCommand`'s rule).
91
+ */
92
+ export const operatorUnknown = (entityName: string, column: string, op: unknown): EntityError => {
93
+ const meant = operatorMeant(op);
94
+ const list = OPERATORS.join(', ');
95
+ return new EntityError({
96
+ code: 'X_ENTITY_OPERATOR_UNKNOWN',
97
+ cause:
98
+ meant === undefined
99
+ ? `${entityName}: the predicate on ${quoted(column)} names ${shown(op)}, which is not an operator — read as "no row matches" it would answer an empty list with no error`
100
+ : `${entityName}: the predicate on ${quoted(column)} names ${shown(op)}, which is not an operator — the operator for it is '${meant}'. Read as "no row matches" it would answer an empty list with no error`,
101
+ fix:
102
+ meant === undefined
103
+ ? `andWhere(${quoted(column)}, '<operator>', value) # one of: ${list}`
104
+ : `andWhere(${quoted(column)}, '${meant}', value) # operators: ${list}`,
105
+ });
106
+ };
107
+
108
+ /**
109
+ * The predicate `andWhere` appends, refused on the line the author wrote and before any driver sees
110
+ * the plan: an operator that is not one (`'>'` for `'gt'`) reaches it whenever the call is not
111
+ * typechecked. The drivers refuse it too, identically, for a plan built by hand.
112
+ */
113
+ export const checkedPredicate = (
114
+ entityName: string,
115
+ column: string,
116
+ op: unknown,
117
+ value: unknown,
118
+ ): Predicate => {
119
+ if (!isOperator(op)) throw operatorUnknown(entityName, column, op);
120
+ return { column, op, value };
121
+ };
package/src/pg-sql.ts CHANGED
@@ -10,6 +10,7 @@ import type { EntityCore } from './entity';
10
10
  import { SOFT_DELETE_COLUMN } from './entity';
11
11
  import { searchUndeclared } from './feature-errors';
12
12
  import { microsToIso, seekAlias } from './instant';
13
+ import { operatorUnknown } from './operator';
13
14
  import { allColumns, arrayLiteral, columnsOf, physicalName } from './pg-row';
14
15
  import type { Predicate, QueryPlan, SortKey } from './tenancy';
15
16
  import type { ColumnKind } from './types';
@@ -129,6 +130,11 @@ const predicateSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFr
129
130
  // answered rows the memory driver never did.
130
131
  case 'has-key':
131
132
  return typeof value === 'string' ? sql`${column} operator(pg_catalog.?) ${value}` : NEVER;
133
+ // An operator outside the vocabulary, cast past the type: the statement used to be built around
134
+ // an `undefined` fragment and refused as `X_SQL_UNSAFE`, which names the wrong mistake. The
135
+ // memory driver's refusal, word for word (`memory-match.ts`).
136
+ default:
137
+ throw operatorUnknown(entity.$name, predicate.column, predicate.op);
132
138
  }
133
139
  };
134
140
 
package/src/query.ts CHANGED
@@ -6,18 +6,18 @@
6
6
 
7
7
  import type { Page } from '@ultimat3/core';
8
8
  import { pageOf } from '@ultimat3/core';
9
- import type { BatchIterator } from './batch';
10
- import { assertBatchable, batchIterator } from './batch';
9
+ import { assertBatchable, type BatchIterator, batchIterator } from './batch';
11
10
  import { entityNow } from './clock';
12
11
  import type { EntityCore } from './entity';
13
12
  import { searchUndeclared } from './feature-errors';
13
+ import { checkedPredicate, type Operator } from './operator';
14
14
  import { assertFinitePageSize, DEFAULT_PAGE_SIZE, namedColumns } from './plan';
15
15
  import { preloaded, type RelatedTables } from './preload';
16
16
  import { type PreloadOptions, type PreloadRelation, preloadsWith } from './preload-ceiling';
17
17
  import type { Repo, RepoOptions, UpsertArgs } from './repo';
18
18
  import { copyRow, pickRow } from './sealed';
19
19
  import { SEARCH_PROPERTY } from './search';
20
- import type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
20
+ import type { Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
21
21
  import { type Move, transitionRow } from './transition';
22
22
  import type {
23
23
  ColumnMap,
@@ -320,7 +320,7 @@ const builder = <Source, Row>(
320
320
  }),
321
321
 
322
322
  andWhere: (column: string, op: Operator, value?: unknown) =>
323
- next({ where: [...state.where, { column, op, value }] }),
323
+ next({ where: [...state.where, checkedPredicate(entity.$name, column, op, value)] }),
324
324
 
325
325
  // Refused HERE as well as at the statement, because this is the line the author wrote: a chain
326
326
  // over an entity that declares nothing searchable can never produce a match, and the repair is
package/src/tenancy.ts CHANGED
@@ -11,40 +11,11 @@ import {
11
11
  tenancyRowMismatch,
12
12
  tenancyUnscoped,
13
13
  } from './errors';
14
+ import type { Operator } from './operator';
14
15
  import type { ColumnMap } from './types';
15
16
 
16
- /**
17
- * The predicate vocabulary, closed. The last four are the CONTAINMENT half, added 2026-08-24: a
18
- * `json()` or `arrayOf()` column was declared, written and then unfilterable — the ten operators
19
- * before them could compare a column to a scalar and nothing else — so an app storing either had
20
- * to leave the query language for hand-written SQL, which is the one read path in this framework
21
- * with no tenancy guard on it. Their meaning is Postgres', written once in `containment.ts` and
22
- * read by both drivers.
23
- *
24
- * There is deliberately no jsonpath EXPRESSION operator beside them: `contains` already matches
25
- * nested structure (`data @> '{"a":{"b":1}}'`), and a path language inside the query language
26
- * would be a second way to ask one question.
27
- */
28
- export type Operator =
29
- | 'eq'
30
- | 'neq'
31
- | 'in'
32
- | 'gt'
33
- | 'gte'
34
- | 'lt'
35
- | 'lte'
36
- | 'like'
37
- | 'is-null'
38
- | 'is-not-null'
39
- | 'contains'
40
- | 'contained-by'
41
- | 'overlaps'
42
- | 'has-key'
43
- // The FULL-TEXT half, added 2026-08-24. `column` is not a column: it is `SEARCH_PROPERTY`, and
44
- // both drivers branch on the OPERATOR and read the entity's own `$search` — a `tsvector` is a
45
- // physical column no row carries, so resolving it as a property would be a lie in two places.
46
- // The operand is a search TERM, always bound, never parsed as tsquery syntax.
47
- | 'matches';
17
+ /** The vocabulary is `OPERATORS` (`operator.ts`) — one list, and this type is derived from it. */
18
+ export type { Operator } from './operator';
48
19
 
49
20
  export interface Predicate {
50
21
  readonly column: string;