@ultimat3/entity 2.0.0 → 4.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 +142 -7
- package/README.md +83 -5
- package/package.json +5 -4
- package/src/bulk-write.ts +9 -7
- package/src/clock.ts +18 -0
- package/src/coalesce.ts +30 -16
- package/src/column.ts +82 -2
- package/src/columns-data.ts +205 -0
- package/src/columns.ts +47 -5
- package/src/count-by.ts +2 -1
- package/src/cursor.ts +23 -2
- package/src/describe.ts +58 -16
- package/src/entity.ts +27 -11
- package/src/index.ts +26 -3
- package/src/jit-preload.ts +66 -10
- package/src/memory-match.ts +169 -0
- package/src/pg-driver.ts +3 -4
- package/src/pg-row.ts +66 -20
- package/src/pg-sql.ts +58 -11
- package/src/plan.ts +5 -4
- package/src/query.ts +7 -7
- package/src/registry.ts +10 -0
- package/src/relations.ts +4 -1
- package/src/repo.ts +70 -99
- package/src/seed.ts +289 -19
- package/src/types.ts +103 -11
package/src/index.ts
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
export type { Infer } from '@ultimat3/schema';
|
|
5
5
|
export { t } from '@ultimat3/schema';
|
|
6
6
|
export type { BatchIterator } from './batch';
|
|
7
|
-
export type {
|
|
7
|
+
export type { MoneyColumns } from './column';
|
|
8
|
+
export { columnName, moneyColumns, snake } from './column';
|
|
9
|
+
export type { MoneyOptions, TextOptions } from './columns';
|
|
8
10
|
export {
|
|
9
11
|
boolean,
|
|
10
12
|
enumerated,
|
|
@@ -18,11 +20,18 @@ export {
|
|
|
18
20
|
url,
|
|
19
21
|
uuid,
|
|
20
22
|
} 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
|
+
export type { DecimalOptions } from './columns-data';
|
|
27
|
+
export { arrayOf, bigint, bytes, date, decimal, json } from './columns-data';
|
|
21
28
|
// `crossTenantReason` stays internal: an app that could read the flag would have a second way to
|
|
22
29
|
// reason about tenant scope — branch on it — next to the one way, which is entering the scope.
|
|
23
30
|
export { CROSS_TENANT_SCOPE, crossTenant } from './cross-tenant';
|
|
24
31
|
export type { Database, DatabaseOptions, Driver, EntitySet } from './database';
|
|
25
32
|
export { database, defaultDriver, memoryDriver } from './database';
|
|
33
|
+
export type { DescribeInput } from './describe';
|
|
34
|
+
export { sqlTypeOf } from './describe';
|
|
26
35
|
export type { Entity, EntityCore, EntityInit, IndexInit } from './entity';
|
|
27
36
|
export { entity, SOFT_DELETE_COLUMN } from './entity';
|
|
28
37
|
export type {
|
|
@@ -92,6 +101,7 @@ export {
|
|
|
92
101
|
export type { EntityRelations, Relation, RelationKind, RelationMap } from './relations';
|
|
93
102
|
export { relationMap, relationNamed, relationsFor, relationsOf } from './relations';
|
|
94
103
|
export type {
|
|
104
|
+
FindByIdOptions,
|
|
95
105
|
FindManyArgs,
|
|
96
106
|
MemoryRepo,
|
|
97
107
|
Page,
|
|
@@ -102,8 +112,18 @@ export type {
|
|
|
102
112
|
UpsertArgs,
|
|
103
113
|
} from './repo';
|
|
104
114
|
export { memoryRepo, memoryTransactor } from './repo';
|
|
105
|
-
export type {
|
|
106
|
-
|
|
115
|
+
export type {
|
|
116
|
+
Seed,
|
|
117
|
+
SeedContext,
|
|
118
|
+
SeedInit,
|
|
119
|
+
SeedKey,
|
|
120
|
+
SeedMetrics,
|
|
121
|
+
SeedOptions,
|
|
122
|
+
SeedRun,
|
|
123
|
+
SeedTier,
|
|
124
|
+
SeedWrite,
|
|
125
|
+
} from './seed';
|
|
126
|
+
export { defineSeed, isSeed, SEED_TIERS, seedId, seedTiersFor } from './seed';
|
|
107
127
|
export type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
|
|
108
128
|
export {
|
|
109
129
|
assertRowTenant,
|
|
@@ -127,14 +147,17 @@ export type {
|
|
|
127
147
|
IdOf,
|
|
128
148
|
IndexDef,
|
|
129
149
|
Insertable,
|
|
150
|
+
MoneyColumnNames,
|
|
130
151
|
MoneyInput,
|
|
131
152
|
MoneyValue,
|
|
132
153
|
OnDelete,
|
|
133
154
|
ReferenceOptions,
|
|
134
155
|
RowOf,
|
|
156
|
+
RowPatch,
|
|
135
157
|
TimestampColumn,
|
|
136
158
|
TypeOf,
|
|
137
159
|
UuidColumn,
|
|
138
160
|
} from './types';
|
|
161
|
+
export { COLUMN_KINDS } from './types';
|
|
139
162
|
// `viewFor` stays internal: a view is reached through the entity, as `posts.$view([...])`.
|
|
140
163
|
export type { EntityView } from './view';
|
package/src/jit-preload.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
//
|
|
6
6
|
// The trigger carries an id, not a row, so what a page leaves behind is an index of its foreign
|
|
7
7
|
// key VALUES rather than a map keyed by row identity: an id is a thing that can be looked up in
|
|
8
|
-
// it, and
|
|
8
|
+
// it, and a page therefore costs its keys rather than its rows. That was true PER PAGE and false
|
|
9
|
+
// across them until `MAX_SIBLING_KEYS` — the store outlives every page and dies with the ctx,
|
|
10
|
+
// which for a job is the whole attempt, so both maps here are bounded and evict the oldest page.
|
|
9
11
|
//
|
|
10
12
|
// The scope guard is a security boundary, not a tuning knob. A preloaded row is served only to a
|
|
11
13
|
// lookup with the same scope key, the same client and no write since — anything else reads the
|
|
@@ -14,7 +16,15 @@
|
|
|
14
16
|
import type { Ctx } from '@ultimat3/core';
|
|
15
17
|
import { tryUseContext } from '@ultimat3/core';
|
|
16
18
|
import type { DbClient } from '@ultimat3/db';
|
|
17
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
type Answer,
|
|
21
|
+
keyOf,
|
|
22
|
+
MAX_IDS_PER_STATEMENT,
|
|
23
|
+
type PointRead,
|
|
24
|
+
readByIds,
|
|
25
|
+
statementChunks,
|
|
26
|
+
} from './batch-read';
|
|
27
|
+
import { columnFor } from './column';
|
|
18
28
|
import type { EntityCore } from './entity';
|
|
19
29
|
|
|
20
30
|
/** The rows one page's worth of foreign keys resolved to, under one scope. */
|
|
@@ -53,10 +63,54 @@ const storeFor = (ctx: Ctx): Store => {
|
|
|
53
63
|
return created;
|
|
54
64
|
};
|
|
55
65
|
|
|
66
|
+
/**
|
|
67
|
+
* How many id keys ONE edge may hold, and how many rows one bucket may keep — a few pages' worth,
|
|
68
|
+
* the way `MAX_IDS_PER_STATEMENT` bounds a statement.
|
|
69
|
+
*
|
|
70
|
+
* `MAX_IDS_PER_STATEMENT` bounded the statement and nothing bounded the STORE: every page merged
|
|
71
|
+
* its keys in and the store died only with the ctx, which for a job is the whole attempt. Measured
|
|
72
|
+
* at 1,000 pages x 1,000 rows with distinct foreign keys, rows dropped after each call and
|
|
73
|
+
* `Bun.gc(true)` either side: **159.3 MB retained**, against 2.7 MB with the tagging off — so a
|
|
74
|
+
* 12M-row `backfill()` retains ~2 GB and OOMs the worker on the DEFAULT configuration, since
|
|
75
|
+
* `jitPreload` defaults to true and `backfill()` names no driver option.
|
|
76
|
+
*
|
|
77
|
+
* Four statements' worth. The keys of one page are filed contiguously, so the survivors are the
|
|
78
|
+
* newest pages' and the arrays every evicted key referenced go with them — which is what makes the
|
|
79
|
+
* bound a bound on bytes and not only on entries. Past it a lookup DECLINES, and declining is the
|
|
80
|
+
* old behaviour everywhere else in this file: the caller reads the statement it always read.
|
|
81
|
+
*/
|
|
82
|
+
export const MAX_SIBLING_KEYS = MAX_IDS_PER_STATEMENT * 4;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Newest wins, oldest goes. A `Map` iterates in insertion order, so its first key is the oldest
|
|
86
|
+
* page's — and the page a sequential `for … of` loop is walking is the newest one, which is the
|
|
87
|
+
* only page this store exists to answer for. Re-filed rather than overwritten, so a key a later
|
|
88
|
+
* page carries again moves to the newest end instead of ageing out under it.
|
|
89
|
+
*/
|
|
90
|
+
const remember = <V>(index: Map<string, V>, key: string, value: V, cap: number): void => {
|
|
91
|
+
index.delete(key);
|
|
92
|
+
index.set(key, value);
|
|
93
|
+
while (index.size > cap) {
|
|
94
|
+
const oldest = index.keys().next();
|
|
95
|
+
if (oldest.done === true) return;
|
|
96
|
+
index.delete(oldest.value);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
56
100
|
/** Both ends of the edge: a key pointing at another column of the same entity is another edge. */
|
|
57
101
|
const siblingKey = (targetEntity: string, targetProperty: string): string =>
|
|
58
102
|
JSON.stringify([targetEntity, targetProperty]);
|
|
59
103
|
|
|
104
|
+
/** TEST SEAM: id keys this request is holding, across every edge. A bound nothing can observe is a
|
|
105
|
+
* bound nothing can pin, and `MAX_SIBLING_KEYS` is the number this answers against. */
|
|
106
|
+
export const siblingKeysHeld = (ctx: Ctx): number => {
|
|
107
|
+
const store = requests.get(ctx);
|
|
108
|
+
if (store === undefined) return 0;
|
|
109
|
+
let held = 0;
|
|
110
|
+
for (const index of store.siblings.values()) held += index.size;
|
|
111
|
+
return held;
|
|
112
|
+
};
|
|
113
|
+
|
|
60
114
|
const writesTo = (store: Store, entity: string): number => store.writes.get(entity) ?? 0;
|
|
61
115
|
|
|
62
116
|
/**
|
|
@@ -80,7 +134,7 @@ export const tagSiblings = <Row>(entity: EntityCore<Row>, rows: readonly Row[]):
|
|
|
80
134
|
for (const reference of references) {
|
|
81
135
|
// The declaring column's own kind: a foreign key mirrors the key it points at, and a value is
|
|
82
136
|
// filed here exactly as `findById` will spell it when it comes looking.
|
|
83
|
-
const kind = entity.$columns
|
|
137
|
+
const kind = columnFor(entity.$columns, reference.property)?.$meta.kind;
|
|
84
138
|
if (kind === undefined) continue;
|
|
85
139
|
const ids: unknown[] = [];
|
|
86
140
|
const keys = new Set<string>();
|
|
@@ -96,7 +150,7 @@ export const tagSiblings = <Row>(entity: EntityCore<Row>, rows: readonly Row[]):
|
|
|
96
150
|
if (ids.length === 0) continue;
|
|
97
151
|
const at = siblingKey(reference.targetEntity, reference.targetProperty);
|
|
98
152
|
const index = store.siblings.get(at) ?? new Map<string, readonly unknown[]>();
|
|
99
|
-
for (const key of keys) index
|
|
153
|
+
for (const key of keys) remember(index, key, ids, MAX_SIBLING_KEYS);
|
|
100
154
|
store.siblings.set(at, index);
|
|
101
155
|
}
|
|
102
156
|
};
|
|
@@ -165,12 +219,14 @@ const preload = <Row>(read: PointRead<Row>, bucket: Bucket, ids: readonly unknow
|
|
|
165
219
|
if (bucket.rows.has(at)) continue;
|
|
166
220
|
// The executor runs synchronously, so `settle` is assigned before the promise is stored.
|
|
167
221
|
let settle!: (answer: Answer) => void;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
222
|
+
const answer = new Promise<Answer>((resolve) => {
|
|
223
|
+
settle = resolve;
|
|
224
|
+
});
|
|
225
|
+
// Bounded for the reason the sibling index is: a bucket holds ROWS, so a long request that
|
|
226
|
+
// preloads page after page retains every row it ever resolved. An evicted entry still settles
|
|
227
|
+
// — `fill` holds its own settler — and a lookup that no longer finds one reads its own
|
|
228
|
+
// statement, which is what it would have read had no page indexed the id at all.
|
|
229
|
+
remember(bucket.rows, at, answer, MAX_SIBLING_KEYS);
|
|
174
230
|
settlers.set(at, settle);
|
|
175
231
|
wanted.push(id);
|
|
176
232
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Single responsibility: what a `Predicate` MEANS in the in-memory driver — equality, ordering and
|
|
2
|
+
// LIKE. Every rule here exists so the answer matches the one Postgres gives for the same predicate
|
|
3
|
+
// on the same column, which is why each is decided by the column's DECLARED KIND and never by the
|
|
4
|
+
// JS type of whichever value is in hand: the database decides by the column's type, so a driver
|
|
5
|
+
// deciding by `typeof` is answering a different question.
|
|
6
|
+
|
|
7
|
+
import { compareDecimalText } from '@ultimat3/core';
|
|
8
|
+
import { keyOf } from './batch-read';
|
|
9
|
+
import { kindOf, valueAt } from './cursor';
|
|
10
|
+
import type { EntityCore } from './entity';
|
|
11
|
+
import { EntityError } from './errors';
|
|
12
|
+
import type { Predicate } from './tenancy';
|
|
13
|
+
import type { ColumnKind } from './types';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The kinds whose ROW VALUE is a decimal string. `bigint()` and `decimal()` both hand back digits
|
|
17
|
+
* as text on purpose (`columns-data.ts`): a JS `bigint` is what `JSON.stringify` throws on and a
|
|
18
|
+
* `number` loses digits past 2^53, exactly where a legacy `int8` key lives.
|
|
19
|
+
*
|
|
20
|
+
* Which makes them the kinds no `typeof` branch can catch. `compare` had a `number`/`number` case
|
|
21
|
+
* and a `bigint`/`bigint` case and neither fired for these, so both fell to
|
|
22
|
+
* `String(left) < String(right)`: memory answered `["10","100","2","9"]` where Postgres answers
|
|
23
|
+
* `["2","9","10","100"]`, and a keyset page boundary was cut where the database never cuts one.
|
|
24
|
+
*
|
|
25
|
+
* This SET is the whole of what this package contributes; the comparison itself is
|
|
26
|
+
* `@ultimat3/core`'s `compareDecimalText`. The split is the point — the text arrives in more than
|
|
27
|
+
* one package and the DECLARED KIND does not, so a caller with no column kinds
|
|
28
|
+
* (`@ultimat3/query`, whose `OrderKey` is a name and a direction) deliberately never asks: a
|
|
29
|
+
* `text` column holding `"10"` and `"9"` is ordered lexically by Postgres, and a comparator
|
|
30
|
+
* guessing "both sides look like decimals" would trade this disagreement for that one.
|
|
31
|
+
*/
|
|
32
|
+
const DECIMAL_TEXT: ReadonlySet<ColumnKind> = new Set<ColumnKind>(['bigint', 'numeric']);
|
|
33
|
+
|
|
34
|
+
const sign = <T extends number | bigint | string>(left: T, right: T): number =>
|
|
35
|
+
left < right ? -1 : left > right ? 1 : 0;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Two values of one column, ordered as Postgres orders that column. `-1`, `0` or `1` — never a
|
|
39
|
+
* difference, so a `bigint` pair needs no subtraction it cannot express in a `number`.
|
|
40
|
+
*/
|
|
41
|
+
export const compareByKind = (
|
|
42
|
+
kind: ColumnKind | undefined,
|
|
43
|
+
left: unknown,
|
|
44
|
+
right: unknown,
|
|
45
|
+
): number => {
|
|
46
|
+
if (left instanceof Date && right instanceof Date) return sign(left.getTime(), right.getTime());
|
|
47
|
+
if (kind !== undefined && DECIMAL_TEXT.has(kind)) {
|
|
48
|
+
// `undefined` when either side is not a plain decimal — that pair is not a numeric comparison,
|
|
49
|
+
// so it falls through to the branches below rather than being guessed at.
|
|
50
|
+
const exact = compareDecimalText(left, right);
|
|
51
|
+
if (exact !== undefined) return exact;
|
|
52
|
+
}
|
|
53
|
+
if (typeof left === 'number' && typeof right === 'number') return sign(left, right);
|
|
54
|
+
if (typeof left === 'bigint' && typeof right === 'bigint') return sign(left, right);
|
|
55
|
+
return sign(String(left), String(right));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Equality, in the two places `===` is not what the database means. A `Date` compares by identity,
|
|
60
|
+
* so `where({ publishedAt })` would match nothing here and every row there. And Postgres compares a
|
|
61
|
+
* `uuid` as a VALUE — it parses the text and prints it lower-cased — so an id handed in upper case
|
|
62
|
+
* matches the row there and used to miss it here, which is `findById(UPPER)` answering `null` in
|
|
63
|
+
* memory and the row in production. `keyOf` is where that rule already lived, for the batched read.
|
|
64
|
+
*/
|
|
65
|
+
export const sameValueOfKind = (
|
|
66
|
+
kind: ColumnKind | undefined,
|
|
67
|
+
left: unknown,
|
|
68
|
+
right: unknown,
|
|
69
|
+
): boolean => {
|
|
70
|
+
if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime();
|
|
71
|
+
if (kind === 'uuid' && typeof left === 'string' && typeof right === 'string') {
|
|
72
|
+
return keyOf('uuid', left) === keyOf('uuid', right);
|
|
73
|
+
}
|
|
74
|
+
return left === right;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const REGEX_SPECIAL = /[.*+?^${}()|[\]\\]/g;
|
|
78
|
+
|
|
79
|
+
const quote = (text: string): string => text.replace(REGEX_SPECIAL, '\\$&');
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Postgres answers a `LIKE` pattern ending in the escape character with `22025 — LIKE pattern must
|
|
83
|
+
* not end with escape character`, so a pattern that means nothing there means nothing here either.
|
|
84
|
+
* The pattern itself is never echoed: a filter value is app data, and this cause is rendered into
|
|
85
|
+
* a log line.
|
|
86
|
+
*/
|
|
87
|
+
const danglingEscape = (entityName: string): EntityError =>
|
|
88
|
+
new EntityError({
|
|
89
|
+
code: 'X_INVARIANT_VIOLATED',
|
|
90
|
+
cause: `${entityName}: a like pattern ends with a backslash, which is the escape character — Postgres answers that pattern with 22025 (LIKE pattern must not end with escape character)`,
|
|
91
|
+
fix: "double it — 'a\\\\' is the pattern that matches one literal backslash, and 'a\\%b' matches a literal %",
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A SQL `LIKE` pattern as a regex, with Postgres' DEFAULT escape handling: `%` and `_` are the
|
|
96
|
+
* wildcards, a backslash escapes either (or itself), and everything else is literal.
|
|
97
|
+
*
|
|
98
|
+
* The backslash used to be quoted for the regex BEFORE the wildcards were expanded, so `'a\%b'`
|
|
99
|
+
* matched the literal `a%b` in Postgres and `a\<anything>b` here — one pattern, two meanings, and
|
|
100
|
+
* the driver that disagreed was the one every test runs against.
|
|
101
|
+
*
|
|
102
|
+
* A RUN of `%` is still one `.*`, not one each: `%%%…x` compiled to twenty adjacent `.*` groups,
|
|
103
|
+
* and an anchored regex with twenty of them takes exponential time to fail on a long value — a
|
|
104
|
+
* filter value forwarded from a search box is then a CPU stall in the process. Postgres reads a run
|
|
105
|
+
* of `%` as one wildcard too, so this is the two drivers agreeing rather than a defensive
|
|
106
|
+
* narrowing.
|
|
107
|
+
*/
|
|
108
|
+
const likePattern = (entityName: string, pattern: string): RegExp => {
|
|
109
|
+
let source = '';
|
|
110
|
+
let at = 0;
|
|
111
|
+
while (at < pattern.length) {
|
|
112
|
+
const char = pattern[at];
|
|
113
|
+
if (char === '\\') {
|
|
114
|
+
const escaped = pattern[at + 1];
|
|
115
|
+
if (escaped === undefined) throw danglingEscape(entityName);
|
|
116
|
+
source += quote(escaped);
|
|
117
|
+
at += 2;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (char === '%') {
|
|
121
|
+
while (pattern[at] === '%') at += 1;
|
|
122
|
+
source += '.*';
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
source += char === '_' ? '.' : quote(char ?? '');
|
|
126
|
+
at += 1;
|
|
127
|
+
}
|
|
128
|
+
return new RegExp(`^${source}$`, 's');
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** One predicate against one stored row, in the meaning the Postgres driver compiles it to. */
|
|
132
|
+
export const matchesPredicate = <Row>(
|
|
133
|
+
entity: EntityCore<Row>,
|
|
134
|
+
row: unknown,
|
|
135
|
+
predicate: Predicate,
|
|
136
|
+
): boolean => {
|
|
137
|
+
// The column's declared kind, resolved once — `price.minor` included, which is the path a money
|
|
138
|
+
// predicate and a money sort key both name.
|
|
139
|
+
const kind = kindOf(entity, predicate.column);
|
|
140
|
+
const actual = valueAt(row, predicate.column);
|
|
141
|
+
const same = (candidate: unknown): boolean => sameValueOfKind(kind, actual, candidate);
|
|
142
|
+
const order = (): number => compareByKind(kind, actual, predicate.value);
|
|
143
|
+
switch (predicate.op) {
|
|
144
|
+
case 'eq':
|
|
145
|
+
return same(predicate.value);
|
|
146
|
+
case 'neq':
|
|
147
|
+
return !same(predicate.value);
|
|
148
|
+
// `in` reads a LIST or nothing: an operand that is not an array matches no row, which is what
|
|
149
|
+
// `predicateSql` now compiles it to and what `@ultimat3/query` answers for the same operand.
|
|
150
|
+
case 'in':
|
|
151
|
+
return Array.isArray(predicate.value) && predicate.value.some(same);
|
|
152
|
+
case 'gt':
|
|
153
|
+
return order() > 0;
|
|
154
|
+
case 'gte':
|
|
155
|
+
return order() >= 0;
|
|
156
|
+
case 'lt':
|
|
157
|
+
return order() < 0;
|
|
158
|
+
case 'lte':
|
|
159
|
+
return order() <= 0;
|
|
160
|
+
// Real LIKE semantics, so `'draft%'` means "starts with" here exactly as it does in Postgres.
|
|
161
|
+
// Treating the pattern as a substring would make the two drivers disagree.
|
|
162
|
+
case 'like':
|
|
163
|
+
return likePattern(entity.$name, String(predicate.value)).test(String(actual));
|
|
164
|
+
case 'is-null':
|
|
165
|
+
return actual === null || actual === undefined;
|
|
166
|
+
case 'is-not-null':
|
|
167
|
+
return actual !== null && actual !== undefined;
|
|
168
|
+
}
|
|
169
|
+
};
|
package/src/pg-driver.ts
CHANGED
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
// being told — which is how `ctx.jobs.enqueue()` lands its outbox row atomically with the write
|
|
8
8
|
// that caused it. `RepoOptions.tx` is the in-memory driver's undo hook and is ignored here.
|
|
9
9
|
|
|
10
|
-
import { systemClock } from '@ultimat3/core';
|
|
11
10
|
import {
|
|
12
11
|
currentTx,
|
|
13
12
|
type DbClient,
|
|
@@ -24,8 +23,8 @@ import {
|
|
|
24
23
|
namedProperties,
|
|
25
24
|
upsertPlan,
|
|
26
25
|
} from './bulk-write';
|
|
26
|
+
import { entityNow } from './clock';
|
|
27
27
|
import { coalesceFindById } from './coalesce';
|
|
28
|
-
import { snake } from './column';
|
|
29
28
|
import { countsFrom, groupColumnOf, groupValue, MAX_GROUPS } from './count-by';
|
|
30
29
|
import { cursorFor, seekFrom, valueAt } from './cursor';
|
|
31
30
|
import type { Driver } from './database';
|
|
@@ -33,7 +32,7 @@ import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
|
33
32
|
import { notFound, repoClientPinned } from './errors';
|
|
34
33
|
import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
|
|
35
34
|
import { forgetPreloaded, tagSiblings } from './jit-preload';
|
|
36
|
-
import { bindValues, decodeRow, type PhysicalRow } from './pg-row';
|
|
35
|
+
import { bindValues, decodeRow, type PhysicalRow, physicalName } from './pg-row';
|
|
37
36
|
import {
|
|
38
37
|
type ConflictTarget,
|
|
39
38
|
countByStatement,
|
|
@@ -133,7 +132,7 @@ export const postgresRepo = <Row>(
|
|
|
133
132
|
? updateStatement(
|
|
134
133
|
entity,
|
|
135
134
|
plan,
|
|
136
|
-
new Map([[
|
|
135
|
+
new Map([[physicalName(entity, SOFT_DELETE_COLUMN), entityNow()]]),
|
|
137
136
|
shapeOf({}),
|
|
138
137
|
false,
|
|
139
138
|
)
|
package/src/pg-row.ts
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
// from the driver is re-parsed by the column that declared it rather than trusted — int8 arrives
|
|
5
5
|
// as a string, timestamptz may arrive as one, and a silent `NaN` is worse than a loud throw.
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { columnFor, columnName, moneyColumns } from './column';
|
|
8
8
|
import { narrowMoney } from './columns';
|
|
9
9
|
import type { EntityCore } from './entity';
|
|
10
10
|
import { invariantViolated } from './errors';
|
|
11
|
-
import type { AnyColumn, MoneyValue } from './types';
|
|
11
|
+
import type { AnyColumn, MoneyValue, RowPatch } from './types';
|
|
12
12
|
|
|
13
13
|
export type PhysicalRow = Readonly<Record<string, unknown>>;
|
|
14
14
|
|
|
@@ -23,10 +23,15 @@ const MONEY_PARTS = new Set(['minor', 'currency']);
|
|
|
23
23
|
* addressable as a predicate or a sort key (`MONEY_PARTS` below, and `cursor.ts`'s copy) — a scale
|
|
24
24
|
* says which units `minor` counts, so ordering or filtering by it compares two different questions.
|
|
25
25
|
*/
|
|
26
|
-
export const columnsOf = (property: string, column: AnyColumn): readonly string[] =>
|
|
27
|
-
column.$meta.kind
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
export const columnsOf = (property: string, column: AnyColumn): readonly string[] => {
|
|
27
|
+
if (column.$meta.kind !== 'money') return [columnName(property, column.$meta)];
|
|
28
|
+
const parts = moneyColumns(property, column.$meta);
|
|
29
|
+
// Two columns for an adopted amount that has no scale column: the list IS the projection, so a
|
|
30
|
+
// name here that the table does not have is a `42703` on the first select.
|
|
31
|
+
return parts.scale === null
|
|
32
|
+
? [parts.minor, parts.currency]
|
|
33
|
+
: [parts.minor, parts.currency, parts.scale];
|
|
34
|
+
};
|
|
30
35
|
|
|
31
36
|
/**
|
|
32
37
|
* A predicate or sort key names a property, never a physical column — so `orgId` becomes
|
|
@@ -34,7 +39,7 @@ export const columnsOf = (property: string, column: AnyColumn): readonly string[
|
|
|
34
39
|
*/
|
|
35
40
|
export const physicalName = <Row>(entity: EntityCore<Row>, path: string): string => {
|
|
36
41
|
const [property = path, part] = path.split('.');
|
|
37
|
-
const column = entity.$columns
|
|
42
|
+
const column = columnFor(entity.$columns, property);
|
|
38
43
|
if (column === undefined) {
|
|
39
44
|
throw invariantViolated(
|
|
40
45
|
entity.$name,
|
|
@@ -44,7 +49,7 @@ export const physicalName = <Row>(entity: EntityCore<Row>, path: string): string
|
|
|
44
49
|
}
|
|
45
50
|
const isMoney = column.$meta.kind === 'money';
|
|
46
51
|
if (part === undefined) {
|
|
47
|
-
if (!isMoney) return
|
|
52
|
+
if (!isMoney) return columnName(property, column.$meta);
|
|
48
53
|
throw invariantViolated(
|
|
49
54
|
entity.$name,
|
|
50
55
|
property,
|
|
@@ -54,7 +59,8 @@ export const physicalName = <Row>(entity: EntityCore<Row>, path: string): string
|
|
|
54
59
|
if (!isMoney || !MONEY_PARTS.has(part)) {
|
|
55
60
|
throw invariantViolated(entity.$name, property, `${property} has no part "${part}"`);
|
|
56
61
|
}
|
|
57
|
-
|
|
62
|
+
const parts = moneyColumns(property, column.$meta);
|
|
63
|
+
return part === 'minor' ? parts.minor : parts.currency;
|
|
58
64
|
};
|
|
59
65
|
|
|
60
66
|
/** Every physical column of the entity, in declaration order. */
|
|
@@ -67,7 +73,7 @@ export const allColumns = <Row>(entity: EntityCore<Row>): readonly string[] =>
|
|
|
67
73
|
*/
|
|
68
74
|
export const bindValues = <Row>(
|
|
69
75
|
entity: EntityCore<Row>,
|
|
70
|
-
values:
|
|
76
|
+
values: RowPatch<Row>,
|
|
71
77
|
): ReadonlyMap<string, unknown> => {
|
|
72
78
|
const bound = new Map<string, unknown>();
|
|
73
79
|
// `MoneyInput` lets a writer hand a `bigint`; the row type is `MoneyValue`. `memoryRepo` calls
|
|
@@ -77,26 +83,63 @@ export const bindValues = <Row>(
|
|
|
77
83
|
if (!Object.hasOwn(record, property)) continue;
|
|
78
84
|
const value = record[property];
|
|
79
85
|
if (column.$meta.kind !== 'money') {
|
|
80
|
-
bound.set(
|
|
86
|
+
bound.set(columnName(property, column.$meta), bindable(column, value));
|
|
81
87
|
continue;
|
|
82
88
|
}
|
|
89
|
+
const parts = moneyColumns(property, column.$meta);
|
|
83
90
|
const money = value as MoneyValue | null | undefined;
|
|
84
|
-
bound.set(
|
|
85
|
-
bound.set(
|
|
91
|
+
bound.set(parts.minor, money?.minor ?? null);
|
|
92
|
+
bound.set(parts.currency, money?.currency ?? null);
|
|
86
93
|
// `?? null` and not `!== undefined`: an amount at the currency's own scale carries no key at
|
|
87
94
|
// all, and that absence is what the nullable column stores. A `0` written here for it would
|
|
88
95
|
// claim whole units — a 100x reinterpretation of every ordinary price.
|
|
89
|
-
bound.set(
|
|
96
|
+
if (parts.scale !== null) bound.set(parts.scale, money?.scale ?? null);
|
|
90
97
|
}
|
|
91
98
|
return bound;
|
|
92
99
|
};
|
|
93
100
|
|
|
94
|
-
|
|
101
|
+
/**
|
|
102
|
+
* One array element, as a Postgres array literal spells it. Quoted always: an unquoted element
|
|
103
|
+
* containing a comma, a brace or a backslash is a different array, and an empty string unquoted
|
|
104
|
+
* is nothing at all.
|
|
105
|
+
*/
|
|
106
|
+
const arrayElement = (value: unknown): string => {
|
|
107
|
+
if (value === null || value === undefined) return 'NULL';
|
|
108
|
+
const text =
|
|
109
|
+
value instanceof Date ? value.toISOString() : typeof value === 'object' ? '' : String(value);
|
|
110
|
+
return `"${text.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The value a parameter carries. Every column but one hands its row value straight over — the
|
|
115
|
+
* measured driver behaviour is that an object binds to `jsonb`, a string binds to `numeric`,
|
|
116
|
+
* `int8` and `date`, and a `Uint8Array` binds to `bytea`.
|
|
117
|
+
*
|
|
118
|
+
* An array is the one that cannot: Bun's `sql` serialises a JS array to `x,y`, which Postgres
|
|
119
|
+
* answers with `malformed array literal` (measured). What it accepts is the literal, so this is
|
|
120
|
+
* where a JS array becomes one.
|
|
121
|
+
*/
|
|
122
|
+
const bindable = (column: AnyColumn, value: unknown): unknown => {
|
|
123
|
+
if (value === null || value === undefined) return null;
|
|
124
|
+
// A plain object is not a bindable parameter (`X_SQL_UNSAFE`), so a `jsonb` value crosses as its
|
|
125
|
+
// TEXT and `pg-sql.ts`'s cell casts it back — see `cellCast` for why the cast is `::text::jsonb`.
|
|
126
|
+
if (column.$meta.kind === 'jsonb') return JSON.stringify(value);
|
|
127
|
+
if (column.$meta.kind !== 'array' || !Array.isArray(value)) return value;
|
|
128
|
+
return `{${value.map(arrayElement).join(',')}}`;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const moneyOf = (
|
|
132
|
+
source: PhysicalRow,
|
|
133
|
+
minor: string,
|
|
134
|
+
currency: string,
|
|
135
|
+
scale: string | undefined,
|
|
136
|
+
): unknown => {
|
|
95
137
|
const amount = source[minor];
|
|
96
138
|
if (amount === null || amount === undefined) return null;
|
|
97
139
|
// A column the projection left out is absent, not null — and absent must read as "no scale"
|
|
98
|
-
// exactly as a stored NULL does, so both take the same branch.
|
|
99
|
-
|
|
140
|
+
// exactly as a stored NULL does, so both take the same branch. So does a table that has no
|
|
141
|
+
// scale column at all, which is why the name itself may be `undefined`.
|
|
142
|
+
const declared = scale === undefined ? undefined : source[scale];
|
|
100
143
|
return {
|
|
101
144
|
minor: amount,
|
|
102
145
|
currency: String(source[currency] ?? '').trim(),
|
|
@@ -113,10 +156,13 @@ export const decodeRow = <Row>(entity: EntityCore<Row>, source: PhysicalRow): Ro
|
|
|
113
156
|
for (const [property, column] of Object.entries(entity.$columns)) {
|
|
114
157
|
const [head, currency, scale] = columnsOf(property, column);
|
|
115
158
|
if (head === undefined || !(head in source)) continue;
|
|
159
|
+
// Decided by the column's KIND and never by how many names came back: a money column whose
|
|
160
|
+
// table has no scale column projects two names, and reading that as a non-money column handed
|
|
161
|
+
// the caller a raw minor unit where a `Money` belongs.
|
|
116
162
|
const value =
|
|
117
|
-
|
|
118
|
-
? source
|
|
119
|
-
:
|
|
163
|
+
column.$meta.kind === 'money' && currency !== undefined
|
|
164
|
+
? moneyOf(source, head, currency, scale)
|
|
165
|
+
: source[head];
|
|
120
166
|
if (value !== null && value !== undefined) {
|
|
121
167
|
row[property] = column.$parse(value);
|
|
122
168
|
continue;
|