@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 +10 -0
- package/package.json +5 -5
- package/src/entity-error.ts +5 -2
- package/src/index.ts +1 -0
- package/src/memory-match.ts +6 -0
- package/src/operator.ts +121 -0
- package/src/pg-sql.ts +6 -0
- package/src/query.ts +4 -4
- package/src/tenancy.ts +3 -32
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.
|
|
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.
|
|
37
|
-
"@ultimat3/db": "27.13.
|
|
38
|
-
"@ultimat3/schema": "27.13.
|
|
39
|
-
"@ultimat3/time": "27.13.
|
|
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
|
}
|
package/src/entity-error.ts
CHANGED
|
@@ -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
|
|
86
|
-
*
|
|
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).
|
package/src/memory-match.ts
CHANGED
|
@@ -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
|
|
package/src/operator.ts
ADDED
|
@@ -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
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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;
|