@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.
- package/CLAUDE.md +243 -20
- package/README.md +206 -1
- package/package.json +5 -5
- package/src/aggregate-decode.ts +35 -0
- package/src/aggregate-fold.ts +91 -0
- package/src/aggregate.ts +232 -0
- package/src/batch.ts +2 -1
- package/src/column-values.ts +29 -0
- package/src/column.ts +37 -1
- package/src/columns.ts +5 -46
- package/src/containment.ts +94 -0
- package/src/cursor.ts +100 -14
- package/src/database.ts +1 -1
- package/src/describe.ts +38 -3
- package/src/entity.ts +117 -8
- package/src/enum-column.ts +81 -0
- package/src/errors.ts +16 -0
- package/src/feature-errors.ts +121 -0
- package/src/index-name.ts +83 -0
- package/src/index.ts +42 -4
- package/src/instant.ts +93 -0
- package/src/memory-match.ts +72 -0
- package/src/memory-repo.ts +357 -0
- package/src/pg-driver.ts +94 -7
- package/src/pg-row.ts +38 -1
- package/src/pg-sql.ts +311 -133
- package/src/pg-write-sql.ts +129 -0
- package/src/plan.ts +71 -10
- package/src/query.ts +120 -3
- package/src/registry.ts +16 -0
- package/src/repo.ts +16 -328
- package/src/search.ts +153 -0
- package/src/state-machine.ts +132 -0
- package/src/tenancy.ts +23 -2
- package/src/transition.ts +119 -0
- package/src/types.ts +60 -0
|
@@ -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`;
|
package/src/memory-match.ts
CHANGED
|
@@ -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
|
};
|