@ultimat3/entity 3.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 +60 -5
- package/README.md +7 -0
- package/package.json +5 -5
- package/src/bulk-write.ts +9 -7
- package/src/clock.ts +18 -0
- package/src/column.ts +15 -0
- package/src/count-by.ts +2 -1
- package/src/cursor.ts +23 -2
- package/src/describe.ts +5 -0
- package/src/entity.ts +2 -2
- package/src/index.ts +3 -0
- package/src/jit-preload.ts +66 -10
- package/src/memory-match.ts +169 -0
- package/src/pg-driver.ts +2 -2
- package/src/pg-row.ts +4 -4
- package/src/pg-sql.ts +19 -6
- 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 +3 -2
- package/src/types.ts +56 -14
|
@@ -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,6 +23,7 @@ import {
|
|
|
24
23
|
namedProperties,
|
|
25
24
|
upsertPlan,
|
|
26
25
|
} from './bulk-write';
|
|
26
|
+
import { entityNow } from './clock';
|
|
27
27
|
import { coalesceFindById } from './coalesce';
|
|
28
28
|
import { countsFrom, groupColumnOf, groupValue, MAX_GROUPS } from './count-by';
|
|
29
29
|
import { cursorFor, seekFrom, valueAt } from './cursor';
|
|
@@ -132,7 +132,7 @@ export const postgresRepo = <Row>(
|
|
|
132
132
|
? updateStatement(
|
|
133
133
|
entity,
|
|
134
134
|
plan,
|
|
135
|
-
new Map([[physicalName(entity, SOFT_DELETE_COLUMN),
|
|
135
|
+
new Map([[physicalName(entity, SOFT_DELETE_COLUMN), entityNow()]]),
|
|
136
136
|
shapeOf({}),
|
|
137
137
|
false,
|
|
138
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 { columnName, moneyColumns } from './column';
|
|
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
|
|
|
@@ -39,7 +39,7 @@ export const columnsOf = (property: string, column: AnyColumn): readonly string[
|
|
|
39
39
|
*/
|
|
40
40
|
export const physicalName = <Row>(entity: EntityCore<Row>, path: string): string => {
|
|
41
41
|
const [property = path, part] = path.split('.');
|
|
42
|
-
const column = entity.$columns
|
|
42
|
+
const column = columnFor(entity.$columns, property);
|
|
43
43
|
if (column === undefined) {
|
|
44
44
|
throw invariantViolated(
|
|
45
45
|
entity.$name,
|
|
@@ -73,7 +73,7 @@ export const allColumns = <Row>(entity: EntityCore<Row>): readonly string[] =>
|
|
|
73
73
|
*/
|
|
74
74
|
export const bindValues = <Row>(
|
|
75
75
|
entity: EntityCore<Row>,
|
|
76
|
-
values:
|
|
76
|
+
values: RowPatch<Row>,
|
|
77
77
|
): ReadonlyMap<string, unknown> => {
|
|
78
78
|
const bound = new Map<string, unknown>();
|
|
79
79
|
// `MoneyInput` lets a writer hand a `bigint`; the row type is `MoneyValue`. `memoryRepo` calls
|
package/src/pg-sql.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// declared. That is the whole reason this file exists instead of a template literal per method.
|
|
5
5
|
|
|
6
6
|
import { identifier, join, raw, type SqlFragment, sql } from '@ultimat3/db';
|
|
7
|
-
import { columnName } from './column';
|
|
7
|
+
import { columnFor, columnName } from './column';
|
|
8
8
|
import type { EntityCore } from './entity';
|
|
9
9
|
import { SOFT_DELETE_COLUMN } from './entity';
|
|
10
10
|
import { allColumns, columnsOf, physicalName } from './pg-row';
|
|
@@ -33,10 +33,23 @@ const predicateSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFr
|
|
|
33
33
|
// `is distinct from` so a null on either side compares as a value, not as unknown.
|
|
34
34
|
return sql`${column} is distinct from ${value}`;
|
|
35
35
|
case 'in': {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
// `in` reads a LIST or nothing. A scalar used to be wrapped into a one-element list, which
|
|
37
|
+
// matched a row here that `memoryRepo`'s `matches` refuses outright — 0 rows in memory, 1 in
|
|
38
|
+
// Postgres, from a call `andWhere(column, op, value: unknown)` compiles. One answer, and it
|
|
39
|
+
// is the one `@ultimat3/query`'s `filterClause` already gives: no rows.
|
|
40
|
+
if (!Array.isArray(value)) return NEVER;
|
|
41
|
+
// A NULL bound as a parameter is `col = null`, which is UNKNOWN and therefore excludes the
|
|
42
|
+
// very row the caller listed — while memory's `sameValue(null, null)` includes it. Postgres
|
|
43
|
+
// has no `in` that compares a null as a value, so the list is partitioned and the nulls are
|
|
44
|
+
// asked for as `is null`: the `(… in (…) or … is null)` pair `eq` and `neq` already emit.
|
|
45
|
+
const present = value.filter((each) => each !== null && each !== undefined);
|
|
46
|
+
const list =
|
|
47
|
+
present.length === 0
|
|
48
|
+
? undefined
|
|
49
|
+
: sql`${column} in (${join(present.map((e) => sql`${e}`))})`;
|
|
50
|
+
const nulls = present.length === value.length ? undefined : sql`${column} is null`;
|
|
51
|
+
if (list === undefined) return nulls ?? NEVER;
|
|
52
|
+
return nulls === undefined ? list : sql`(${list} or ${nulls})`;
|
|
40
53
|
}
|
|
41
54
|
case 'gt':
|
|
42
55
|
return sql`${column} > ${value}`;
|
|
@@ -137,7 +150,7 @@ const projection = <Row>(entity: EntityCore<Row>, plan: QueryPlan): SqlFragment
|
|
|
137
150
|
...plan.orderBy.map((entry) => entry.column.split('.')[0] ?? entry.column),
|
|
138
151
|
]);
|
|
139
152
|
const names = [...wanted].flatMap((property) => {
|
|
140
|
-
const column = entity.$columns
|
|
153
|
+
const column = columnFor(entity.$columns, property);
|
|
141
154
|
return column === undefined ? [] : columnsOf(property, column);
|
|
142
155
|
});
|
|
143
156
|
return join(names.map(identifier));
|
package/src/plan.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { EntityError, invariantViolated, patchEmpty, writeUnfiltered } from './e
|
|
|
8
8
|
import type { FindManyArgs, RepoOptions } from './repo';
|
|
9
9
|
import type { Predicate, QueryPlan, SortKey } from './tenancy';
|
|
10
10
|
import { scopedPlan } from './tenancy';
|
|
11
|
+
import type { RowPatch } from './types';
|
|
11
12
|
|
|
12
13
|
/** A page is bounded by default; an unbounded read is a production incident waiting for traffic. */
|
|
13
14
|
export const DEFAULT_PAGE_SIZE = 50;
|
|
@@ -146,7 +147,7 @@ export const namedColumns = (values: unknown): readonly (readonly [string, unkno
|
|
|
146
147
|
/** The filter a filtered write is allowed to run with: never the empty one. */
|
|
147
148
|
const boundedWhere = <Row>(
|
|
148
149
|
entity: EntityCore<Row>,
|
|
149
|
-
filter:
|
|
150
|
+
filter: RowPatch<Row>,
|
|
150
151
|
operation: string,
|
|
151
152
|
): Predicate[] => {
|
|
152
153
|
const where = namedColumns(filter).map(
|
|
@@ -166,7 +167,7 @@ const boundedWhere = <Row>(
|
|
|
166
167
|
*/
|
|
167
168
|
export const deletePlan = <Row>(
|
|
168
169
|
entity: EntityCore<Row>,
|
|
169
|
-
filter:
|
|
170
|
+
filter: RowPatch<Row>,
|
|
170
171
|
options: RepoOptions | undefined,
|
|
171
172
|
operation: string,
|
|
172
173
|
): QueryPlan =>
|
|
@@ -179,8 +180,8 @@ export const deletePlan = <Row>(
|
|
|
179
180
|
*/
|
|
180
181
|
export const updatePlan = <Row>(
|
|
181
182
|
entity: EntityCore<Row>,
|
|
182
|
-
filter:
|
|
183
|
-
patch:
|
|
183
|
+
filter: RowPatch<Row>,
|
|
184
|
+
patch: RowPatch<Row>,
|
|
184
185
|
options: RepoOptions | undefined,
|
|
185
186
|
operation: string,
|
|
186
187
|
): QueryPlan => {
|
package/src/query.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
// concurrent writes an insert before the offset shifts every later page, so a client silently
|
|
5
5
|
// skips and repeats rows.
|
|
6
6
|
|
|
7
|
-
import { systemClock } from '@ultimat3/core';
|
|
8
7
|
import type { BatchIterator } from './batch';
|
|
9
8
|
import { assertBatchable, batchIterator } from './batch';
|
|
9
|
+
import { entityNow } from './clock';
|
|
10
10
|
import type { EntityCore } from './entity';
|
|
11
11
|
import { assertPageSize, DEFAULT_PAGE_SIZE, namedColumns } from './plan';
|
|
12
12
|
import type { RelatedTables } from './preload';
|
|
@@ -15,7 +15,7 @@ import type { Relation } from './relations';
|
|
|
15
15
|
import { relationNamed } from './relations';
|
|
16
16
|
import type { Page, Repo, RepoOptions, UpsertArgs } from './repo';
|
|
17
17
|
import type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
|
|
18
|
-
import type { ColumnMap, IdOf, Insertable } from './types';
|
|
18
|
+
import type { ColumnMap, IdOf, Insertable, RowPatch } from './types';
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* What a preloaded relation adds to a row. `unknown` because the name is a string resolved at
|
|
@@ -26,7 +26,7 @@ export type Preloaded<Name extends string> = { readonly [K in Name]: unknown };
|
|
|
26
26
|
|
|
27
27
|
export interface ReadBuilder<Row> {
|
|
28
28
|
/** Equality on the columns given. `where({ orgId })` is what satisfies the tenancy guard. */
|
|
29
|
-
where(filter:
|
|
29
|
+
where(filter: RowPatch<Row>): ReadBuilder<Row>;
|
|
30
30
|
andWhere(column: keyof Row & string, op: Operator, value: unknown): ReadBuilder<Row>;
|
|
31
31
|
orderBy(column: keyof Row & string, direction?: SortDirection): ReadBuilder<Row>;
|
|
32
32
|
limit(rows: number): ReadBuilder<Row>;
|
|
@@ -106,21 +106,21 @@ export interface Table<Row, C extends ColumnMap = ColumnMap> extends ReadBuilder
|
|
|
106
106
|
*/
|
|
107
107
|
upsertAll(rows: readonly Insertable<C>[], args: UpsertArgs<Row>): Promise<readonly Row[]>;
|
|
108
108
|
/** `IdOf<Row>`: an entity that declared `uuid<PostId>()` is addressed by a `PostId` only. */
|
|
109
|
-
update(id: IdOf<Row>, patch:
|
|
109
|
+
update(id: IdOf<Row>, patch: RowPatch<Row>, options?: RepoOptions): Promise<Row>;
|
|
110
110
|
delete(id: IdOf<Row>, options?: RepoOptions): Promise<void>;
|
|
111
111
|
/**
|
|
112
112
|
* Delete by equality filter; resolves with the number of rows removed. The only way to remove a
|
|
113
113
|
* row from an entity whose primary key is composite — `likes`, `blocks`, a join table — where
|
|
114
114
|
* one id cannot name it. `deleteWhere({})` is `X_WRITE_UNFILTERED`, never every row.
|
|
115
115
|
*/
|
|
116
|
-
deleteWhere(filter:
|
|
116
|
+
deleteWhere(filter: RowPatch<Row>, options?: RepoOptions): Promise<number>;
|
|
117
117
|
/**
|
|
118
118
|
* Update by equality filter; resolves with the number of rows written. The `update(id, patch)`
|
|
119
119
|
* a composite primary key cannot express — `participants.updateWhere({ conversationId, userId },
|
|
120
120
|
* { lastReadAt })` is the reference case. Empty filter: `X_WRITE_UNFILTERED`. Empty patch:
|
|
121
121
|
* `X_PATCH_EMPTY`. `onUpdateNow()` columns are stamped exactly as `update(id, patch)` stamps them.
|
|
122
122
|
*/
|
|
123
|
-
updateWhere(filter:
|
|
123
|
+
updateWhere(filter: RowPatch<Row>, patch: RowPatch<Row>, options?: RepoOptions): Promise<number>;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
interface State {
|
|
@@ -311,7 +311,7 @@ const touch = <Row, Patch>(entity: EntityCore<Row>, patch: Patch): Patch => {
|
|
|
311
311
|
if (namedColumns(patch).length === 0) return patch;
|
|
312
312
|
const stamped: Record<string, unknown> = {};
|
|
313
313
|
for (const [property, column] of Object.entries(entity.$columns)) {
|
|
314
|
-
if (column.$meta.onUpdate !== undefined) stamped[property] =
|
|
314
|
+
if (column.$meta.onUpdate !== undefined) stamped[property] = entityNow();
|
|
315
315
|
}
|
|
316
316
|
return Object.assign({}, patch, stamped);
|
|
317
317
|
};
|
package/src/registry.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { entityDuplicate } from './errors';
|
|
7
7
|
import type { InvariantKind } from './invariants';
|
|
8
|
+
import type { OnDelete } from './types';
|
|
8
9
|
|
|
9
10
|
export interface ColumnDescription {
|
|
10
11
|
readonly property: string;
|
|
@@ -16,6 +17,13 @@ export interface ColumnDescription {
|
|
|
16
17
|
readonly hasDefault: boolean;
|
|
17
18
|
readonly check: string | null;
|
|
18
19
|
readonly references: string | null;
|
|
20
|
+
/**
|
|
21
|
+
* The `references()` rule, `null` when the key declared none. Beside `references` rather than
|
|
22
|
+
* inside it because that field is a flat `"<table>.<column>"` string with no room for it, and
|
|
23
|
+
* `@ultimat3/db` is tier 1: it cannot import this package, so a rule that is not on this
|
|
24
|
+
* projection reaches no `alter table` at all. It reached none until 3.0.
|
|
25
|
+
*/
|
|
26
|
+
readonly onDelete: OnDelete | null;
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
/**
|
|
@@ -35,6 +43,8 @@ export interface ReferenceDescription {
|
|
|
35
43
|
readonly targetEntity: string;
|
|
36
44
|
readonly targetProperty: string;
|
|
37
45
|
readonly targetColumn: string;
|
|
46
|
+
/** What the database does to this row when the target goes. `null` is Postgres' `no action`. */
|
|
47
|
+
readonly onDelete: OnDelete | null;
|
|
38
48
|
}
|
|
39
49
|
|
|
40
50
|
export interface InvariantDescription {
|
package/src/relations.ts
CHANGED
|
@@ -204,7 +204,10 @@ export const relationsFor = (entityName: string): EntityRelations =>
|
|
|
204
204
|
*/
|
|
205
205
|
export const relationNamed = (entityName: string, name: string): Relation => {
|
|
206
206
|
const relations = relationsFor(entityName);
|
|
207
|
-
|
|
207
|
+
// `relations[name]` walks the prototype: `preload('toString')` used to hand back
|
|
208
|
+
// `Function.prototype.toString` AS a `Relation`, past the refusal, to be read for a `.through`
|
|
209
|
+
// it does not have. A relation map is derived from foreign keys, so a name is caller data here.
|
|
210
|
+
const relation = Object.hasOwn(relations, name) ? relations[name] : undefined;
|
|
208
211
|
if (relation === undefined) {
|
|
209
212
|
throw preloadUnknownRelation(entityName, name, Object.keys(relations));
|
|
210
213
|
}
|