@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/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 {
|
|
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}`;
|
|
@@ -111,7 +124,7 @@ const conditions = <Row>(
|
|
|
111
124
|
): SqlFragment => {
|
|
112
125
|
const parts = plan.where.map((predicate) => predicateSql(entity, predicate));
|
|
113
126
|
if (entity.$softDelete && !shape.includeDeleted) {
|
|
114
|
-
parts.push(sql`${identifier(
|
|
127
|
+
parts.push(sql`${identifier(physicalName(entity, SOFT_DELETE_COLUMN))} is null`);
|
|
115
128
|
}
|
|
116
129
|
if (shape.seek !== undefined) parts.push(seekSql(entity, plan.orderBy, shape.seek));
|
|
117
130
|
return parts.length === 0 ? sql`true` : join(parts, ' and ');
|
|
@@ -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));
|
|
@@ -224,6 +237,35 @@ const conflictSql = (conflict: ConflictTarget): SqlFragment => {
|
|
|
224
237
|
)}`;
|
|
225
238
|
};
|
|
226
239
|
|
|
240
|
+
/**
|
|
241
|
+
* The one column that cannot be bound as itself. A `jsonb` value is a plain object, and the
|
|
242
|
+
* driver seam refuses one as a parameter (`X_SQL_UNSAFE` — `isBoundValue` takes scalars, a `Date`,
|
|
243
|
+
* a `Uint8Array` and arrays of those); so `bindValues` hands over the JSON TEXT and the cell says
|
|
244
|
+
* what to do with it.
|
|
245
|
+
*
|
|
246
|
+
* `::text::jsonb` and not `::jsonb`, and the double cast is load-bearing rather than defensive.
|
|
247
|
+
* Measured against Postgres 17.10 through Bun's `sql`: with `$1::jsonb` the server describes the
|
|
248
|
+
* parameter as `jsonb`, the client JSON-ENCODES the string it was given, and `{"a":1}` is stored
|
|
249
|
+
* as the JSON *string* `"{\"a\":1}"` — `jsonb_typeof` says `string`. Pinning the parameter to
|
|
250
|
+
* `text` first makes the client send the characters and the server parse them, which is the one
|
|
251
|
+
* spelling that stores an object.
|
|
252
|
+
*/
|
|
253
|
+
/** Physical names of this entity's `jsonb` columns. Resolved ONCE per statement, never per cell. */
|
|
254
|
+
const jsonColumns = <Row>(entity: EntityCore<Row>): ReadonlySet<string> => {
|
|
255
|
+
const names = new Set<string>();
|
|
256
|
+
for (const [property, column] of Object.entries(entity.$columns)) {
|
|
257
|
+
if (column.$meta.kind === 'jsonb') names.add(columnName(property, column.$meta));
|
|
258
|
+
}
|
|
259
|
+
return names;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* `${value}`, plus the cast that column needs. The `raw()` argument is a literal written here and
|
|
264
|
+
* nowhere else — the audit point that call is stays a two-word constant, never a value.
|
|
265
|
+
*/
|
|
266
|
+
const cell = (json: ReadonlySet<string>, column: string, value: unknown): SqlFragment =>
|
|
267
|
+
json.has(column) ? sql`${value}${raw('::text::jsonb')}` : sql`${value}`;
|
|
268
|
+
|
|
227
269
|
/**
|
|
228
270
|
* One statement for any number of rows. A single row compiles to exactly the text it always did,
|
|
229
271
|
* which is the point: `insertAll([row])` and `insert(row)` are one code path, so there is no
|
|
@@ -234,10 +276,13 @@ export const insertStatement = <Row>(
|
|
|
234
276
|
rows: readonly ReadonlyMap<string, unknown>[],
|
|
235
277
|
shape: InsertShape,
|
|
236
278
|
): SqlFragment => {
|
|
279
|
+
const json = jsonColumns(entity);
|
|
237
280
|
const tuples = rows.map(
|
|
238
281
|
(row) =>
|
|
239
282
|
sql`(${join(
|
|
240
|
-
shape.columns.map((column) =>
|
|
283
|
+
shape.columns.map((column) =>
|
|
284
|
+
row.has(column) ? cell(json, column, row.get(column)) : DEFAULT_CELL,
|
|
285
|
+
),
|
|
241
286
|
)})`,
|
|
242
287
|
);
|
|
243
288
|
const conflict = shape.conflict === undefined ? sql`` : conflictSql(shape.conflict);
|
|
@@ -259,10 +304,12 @@ export const updateStatement = <Row>(
|
|
|
259
304
|
values: ReadonlyMap<string, unknown>,
|
|
260
305
|
shape: ReadShape,
|
|
261
306
|
returning: boolean,
|
|
262
|
-
): SqlFragment =>
|
|
263
|
-
|
|
264
|
-
|
|
307
|
+
): SqlFragment => {
|
|
308
|
+
const json = jsonColumns(entity);
|
|
309
|
+
return sql`update ${identifier(entity.$table)} set ${join(
|
|
310
|
+
[...values].map(([column, value]) => sql`${identifier(column)} = ${cell(json, column, value)}`),
|
|
265
311
|
)} where ${conditions(entity, plan, shape)}${returning ? sql` returning *` : sql``}`;
|
|
312
|
+
};
|
|
266
313
|
|
|
267
314
|
/** Only reached when the entity has no soft-delete column, so there is no filter to apply. */
|
|
268
315
|
export const deleteStatement = <Row>(entity: EntityCore<Row>, plan: QueryPlan): SqlFragment =>
|
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
|
}
|
package/src/repo.ts
CHANGED
|
@@ -7,17 +7,19 @@
|
|
|
7
7
|
// table silently skips and repeats rows. A keyset cursor is stable because it names a
|
|
8
8
|
// position in the sort order, not a row count.
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { keyOf } from './batch-read';
|
|
11
11
|
import { conflictKeyOf, conflictKeys, upsertPlan } from './bulk-write';
|
|
12
|
+
import { entityNow } from './clock';
|
|
12
13
|
import { narrowMoney } from './columns';
|
|
13
14
|
import { countsFrom, groupColumnOf } from './count-by';
|
|
14
|
-
import { cursorFor, seekFrom, valueAt } from './cursor';
|
|
15
|
+
import { cursorFor, kindOf, seekFrom, valueAt } from './cursor';
|
|
15
16
|
import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
16
17
|
import { notFound } from './errors';
|
|
18
|
+
import { compareByKind, matchesPredicate } from './memory-match';
|
|
17
19
|
import { deletePlan, idPlan, readPlan, singleKeyOf, updatePlan } from './plan';
|
|
18
20
|
import type { Predicate, QueryPlan, SortKey } from './tenancy';
|
|
19
21
|
import { assertRowTenant } from './tenancy';
|
|
20
|
-
import type { IdOf } from './types';
|
|
22
|
+
import type { IdOf, RowPatch } from './types';
|
|
21
23
|
|
|
22
24
|
export interface Tx {
|
|
23
25
|
readonly id: string;
|
|
@@ -52,12 +54,26 @@ export interface UpsertArgs<T = unknown> extends RepoOptions {
|
|
|
52
54
|
readonly onMatch?: 'update' | 'nothing';
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
|
|
57
|
+
/**
|
|
58
|
+
* `findById`'s options: `RepoOptions`, plus the one knob a point READ has and a write must not.
|
|
59
|
+
*
|
|
60
|
+
* `includeDeleted` lives here and deliberately NOT on `RepoOptions`. It has always been honoured
|
|
61
|
+
* on this path — `idPlan` spreads the options straight into `FindManyArgs`, and both drivers read
|
|
62
|
+
* `args.includeDeleted === true` — but `RepoOptions` never declared it, so the only documented way
|
|
63
|
+
* to read a soft-deleted row by its id did not typecheck. Putting it on `RepoOptions` instead would
|
|
64
|
+
* have offered it to `update(id, patch)` and `delete(id)`, which reach the same `idPlan`: that is
|
|
65
|
+
* the resurrection those two carry `deleted_at is null` to refuse.
|
|
66
|
+
*/
|
|
67
|
+
export interface FindByIdOptions extends RepoOptions {
|
|
68
|
+
/** Soft-deleted rows are hidden unless the caller asks for them. */
|
|
69
|
+
readonly includeDeleted?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface FindManyArgs extends FindByIdOptions {
|
|
56
73
|
readonly where?: readonly Predicate[];
|
|
57
74
|
readonly orderBy?: readonly SortKey[];
|
|
58
75
|
readonly limit?: number;
|
|
59
76
|
readonly cursor?: string | null;
|
|
60
|
-
readonly includeDeleted?: boolean;
|
|
61
77
|
readonly select?: readonly string[];
|
|
62
78
|
}
|
|
63
79
|
|
|
@@ -76,7 +92,7 @@ export interface Page<T> {
|
|
|
76
92
|
* both `string`, so a row-agnostic consumer sees the signature it always saw.
|
|
77
93
|
*/
|
|
78
94
|
export interface Repo<T = unknown> {
|
|
79
|
-
findById(id: IdOf<T>, options?:
|
|
95
|
+
findById(id: IdOf<T>, options?: FindByIdOptions): Promise<T | null>;
|
|
80
96
|
findMany(args?: FindManyArgs): Promise<Page<T>>;
|
|
81
97
|
insert(values: T, options?: RepoOptions): Promise<T>;
|
|
82
98
|
/**
|
|
@@ -93,7 +109,7 @@ export interface Repo<T = unknown> {
|
|
|
93
109
|
* which is what `returning *` says on the Postgres side.
|
|
94
110
|
*/
|
|
95
111
|
upsertAll(rows: readonly T[], args: UpsertArgs<T>): Promise<readonly T[]>;
|
|
96
|
-
update(id: IdOf<T>, patch:
|
|
112
|
+
update(id: IdOf<T>, patch: RowPatch<T>, options?: RepoOptions): Promise<T>;
|
|
97
113
|
delete(id: IdOf<T>, options?: RepoOptions): Promise<void>;
|
|
98
114
|
/**
|
|
99
115
|
* Delete by filter, returning how many rows went. The only way to remove a row from an entity
|
|
@@ -101,14 +117,14 @@ export interface Repo<T = unknown> {
|
|
|
101
117
|
* to be able to tell "nothing matched" from "it worked", and an empty filter is
|
|
102
118
|
* `X_WRITE_UNFILTERED` rather than every row.
|
|
103
119
|
*/
|
|
104
|
-
deleteWhere(filter:
|
|
120
|
+
deleteWhere(filter: RowPatch<T>, options?: RepoOptions): Promise<number>;
|
|
105
121
|
/**
|
|
106
122
|
* Update by filter, returning how many rows were written. The `update(id, patch)` a composite
|
|
107
123
|
* primary key cannot express — `participants.lastReadAt` is the reference case. Same two guards
|
|
108
124
|
* as `deleteWhere`, plus `X_PATCH_EMPTY` for a patch that names no columns, and soft-deleted
|
|
109
125
|
* rows are not reachable, exactly as they are not by `update(id, patch)`.
|
|
110
126
|
*/
|
|
111
|
-
updateWhere(filter:
|
|
127
|
+
updateWhere(filter: RowPatch<T>, patch: RowPatch<T>, options?: RepoOptions): Promise<number>;
|
|
112
128
|
count(args?: FindManyArgs): Promise<number>;
|
|
113
129
|
/**
|
|
114
130
|
* The grouped count: one statement, one entry per distinct value of `column`, over exactly the
|
|
@@ -142,79 +158,22 @@ export interface Transactor {
|
|
|
142
158
|
const field = (row: unknown, property: string): unknown =>
|
|
143
159
|
typeof row === 'object' && row !== null ? (row as Record<string, unknown>)[property] : undefined;
|
|
144
160
|
|
|
145
|
-
/**
|
|
146
|
-
* `===` on two Dates compares identity, so `where({ publishedAt })` would match nothing here
|
|
147
|
-
* and every row in Postgres. Equality has to mean the same thing in both drivers or the
|
|
148
|
-
* in-memory one stops being a preview of production.
|
|
149
|
-
*/
|
|
150
|
-
const sameValue = (left: unknown, right: unknown): boolean =>
|
|
151
|
-
left instanceof Date && right instanceof Date
|
|
152
|
-
? left.getTime() === right.getTime()
|
|
153
|
-
: left === right;
|
|
154
|
-
|
|
155
|
-
const matches = (row: unknown, predicate: Predicate): boolean => {
|
|
156
|
-
const actual = field(row, predicate.column);
|
|
157
|
-
switch (predicate.op) {
|
|
158
|
-
case 'eq':
|
|
159
|
-
return sameValue(actual, predicate.value);
|
|
160
|
-
case 'neq':
|
|
161
|
-
return !sameValue(actual, predicate.value);
|
|
162
|
-
case 'in':
|
|
163
|
-
return (
|
|
164
|
-
Array.isArray(predicate.value) &&
|
|
165
|
-
predicate.value.some((candidate) => sameValue(candidate, actual))
|
|
166
|
-
);
|
|
167
|
-
case 'gt':
|
|
168
|
-
return compare(actual, predicate.value) > 0;
|
|
169
|
-
case 'gte':
|
|
170
|
-
return compare(actual, predicate.value) >= 0;
|
|
171
|
-
case 'lt':
|
|
172
|
-
return compare(actual, predicate.value) < 0;
|
|
173
|
-
case 'lte':
|
|
174
|
-
return compare(actual, predicate.value) <= 0;
|
|
175
|
-
// Real LIKE semantics, so `'draft%'` means "starts with" here exactly as it does in
|
|
176
|
-
// Postgres. Treating the pattern as a substring would make the two drivers disagree.
|
|
177
|
-
case 'like':
|
|
178
|
-
return likePattern(String(predicate.value)).test(String(actual));
|
|
179
|
-
case 'is-null':
|
|
180
|
-
return actual === null || actual === undefined;
|
|
181
|
-
case 'is-not-null':
|
|
182
|
-
return actual !== null && actual !== undefined;
|
|
183
|
-
}
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* `%` and `_` are the wildcards; everything else in the pattern is literal, as in SQL.
|
|
188
|
-
*
|
|
189
|
-
* A RUN of `%` is one `.*`, not one each: `%%%…x` compiled to twenty adjacent `.*` groups, and an
|
|
190
|
-
* anchored regex with twenty of them takes exponential time to fail on a long value — a filter
|
|
191
|
-
* value an app forwards from a search box is then a CPU stall in the process, on the in-memory
|
|
192
|
-
* driver. Postgres reads a run of `%` as one wildcard too, so this is the two drivers agreeing
|
|
193
|
-
* rather than a defensive narrowing.
|
|
194
|
-
*/
|
|
195
|
-
const likePattern = (pattern: string): RegExp =>
|
|
196
|
-
new RegExp(
|
|
197
|
-
`^${pattern
|
|
198
|
-
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
199
|
-
.replaceAll(/%+/g, '.*')
|
|
200
|
-
.replaceAll('_', '.')}$`,
|
|
201
|
-
's',
|
|
202
|
-
);
|
|
203
|
-
|
|
204
|
-
const compare = (left: unknown, right: unknown): number => {
|
|
205
|
-
if (left instanceof Date && right instanceof Date) return left.getTime() - right.getTime();
|
|
206
|
-
if (typeof left === 'number' && typeof right === 'number') return left - right;
|
|
207
|
-
if (typeof left === 'bigint' && typeof right === 'bigint') {
|
|
208
|
-
return left < right ? -1 : left > right ? 1 : 0;
|
|
209
|
-
}
|
|
210
|
-
const [a, b] = [String(left), String(right)];
|
|
211
|
-
return a < b ? -1 : a > b ? 1 : 0;
|
|
212
|
-
};
|
|
213
|
-
|
|
214
161
|
/** Lexicographic over the sort keys, direction applied. `> 0` means "after the cursor". */
|
|
215
|
-
const compareToSeek = (
|
|
162
|
+
const compareToSeek = <Row>(
|
|
163
|
+
entity: EntityCore<Row>,
|
|
164
|
+
plan: QueryPlan,
|
|
165
|
+
row: unknown,
|
|
166
|
+
seek: readonly unknown[],
|
|
167
|
+
): number => {
|
|
216
168
|
for (const [index, entry] of plan.orderBy.entries()) {
|
|
217
|
-
|
|
169
|
+
// The COLUMN's kind, not the value's: the seek was revived from the same kind (`cursor.ts`),
|
|
170
|
+
// so a `bigint` column compares its stored decimal string against a revived `BigInt` as one
|
|
171
|
+
// number instead of as two pieces of text.
|
|
172
|
+
const order = compareByKind(
|
|
173
|
+
kindOf(entity, entry.column),
|
|
174
|
+
valueAt(row, entry.column),
|
|
175
|
+
seek[index],
|
|
176
|
+
);
|
|
218
177
|
if (order !== 0) return entry.direction === 'desc' ? -order : order;
|
|
219
178
|
}
|
|
220
179
|
return 0;
|
|
@@ -232,7 +191,7 @@ const afterCursor = <Row>(
|
|
|
232
191
|
): number => {
|
|
233
192
|
const seek = seekFrom(entity, plan);
|
|
234
193
|
if (seek === undefined) return 0;
|
|
235
|
-
const start = found.findIndex((row) => compareToSeek(plan, row, seek) > 0);
|
|
194
|
+
const start = found.findIndex((row) => compareToSeek(entity, plan, row, seek) > 0);
|
|
236
195
|
return start === -1 ? found.length : start;
|
|
237
196
|
};
|
|
238
197
|
|
|
@@ -245,9 +204,20 @@ export const memoryRepo = <Row>(
|
|
|
245
204
|
entity: EntityCore<Row>,
|
|
246
205
|
seed: readonly Row[] = [],
|
|
247
206
|
): MemoryRepo<Row> => {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
207
|
+
/**
|
|
208
|
+
* A stored row's key, spelled the way `batch-read.ts` spells an id — because Postgres compares a
|
|
209
|
+
* `uuid` as a VALUE and prints it lower-cased, so `findById(UPPER)` reads the row there while
|
|
210
|
+
* `String(...)` missed it here: `null` from a read and `X_NOT_FOUND` from a write, against a row
|
|
211
|
+
* that exists, reachable from a path parameter, a client-supplied id or a legacy import.
|
|
212
|
+
*/
|
|
213
|
+
const storeKey = (row: unknown): string =>
|
|
214
|
+
entity.$primaryKey
|
|
215
|
+
.map((property) => keyOf(kindOf(entity, property) ?? '', field(row, property)))
|
|
216
|
+
.join('');
|
|
217
|
+
/** The same key, from the id a caller named rather than from a row it has in hand. */
|
|
218
|
+
const idStoreKey = (id: unknown, operation: string): string =>
|
|
219
|
+
keyOf(kindOf(entity, singleKeyOf(entity, operation)) ?? '', id);
|
|
220
|
+
const rows = new Map<string, Row>(seed.map((row) => [storeKey(row), row]));
|
|
251
221
|
|
|
252
222
|
const rowsOf = (plan: QueryPlan, args: FindManyArgs): Row[] => {
|
|
253
223
|
const visible = (row: Row): boolean =>
|
|
@@ -256,11 +226,15 @@ export const memoryRepo = <Row>(
|
|
|
256
226
|
field(row, SOFT_DELETE_COLUMN) === null ||
|
|
257
227
|
field(row, SOFT_DELETE_COLUMN) === undefined;
|
|
258
228
|
return [...rows.values()]
|
|
259
|
-
.filter((row) => plan.where.every((predicate) =>
|
|
229
|
+
.filter((row) => plan.where.every((predicate) => matchesPredicate(entity, row, predicate)))
|
|
260
230
|
.filter(visible)
|
|
261
231
|
.sort((left, right) => {
|
|
262
232
|
for (const entry of plan.orderBy) {
|
|
263
|
-
const order =
|
|
233
|
+
const order = compareByKind(
|
|
234
|
+
kindOf(entity, entry.column),
|
|
235
|
+
valueAt(left, entry.column),
|
|
236
|
+
valueAt(right, entry.column),
|
|
237
|
+
);
|
|
264
238
|
if (order !== 0) return entry.direction === 'desc' ? -order : order;
|
|
265
239
|
}
|
|
266
240
|
return 0;
|
|
@@ -283,7 +257,7 @@ export const memoryRepo = <Row>(
|
|
|
283
257
|
// out of this tenant is refused by the same call that refuses an insert into another one.
|
|
284
258
|
assertRowTenant(entity.$name, entity.$tenantColumn, operation, row);
|
|
285
259
|
entity.$assert(row);
|
|
286
|
-
const key =
|
|
260
|
+
const key = storeKey(row);
|
|
287
261
|
const previous = rows.get(key);
|
|
288
262
|
options?.tx?.onRollback(() => {
|
|
289
263
|
if (previous === undefined) rows.delete(key);
|
|
@@ -297,7 +271,7 @@ export const memoryRepo = <Row>(
|
|
|
297
271
|
// to name a row, so `update`/`delete` resolve through a plan rather than through the map.
|
|
298
272
|
const addressed = (id: string, options: RepoOptions | undefined, operation: string): Row => {
|
|
299
273
|
const plan = idPlan(entity, id, options, operation);
|
|
300
|
-
const current = rows.get(id);
|
|
274
|
+
const current = rows.get(idStoreKey(id, operation));
|
|
301
275
|
// A soft-deleted row is hidden from writes too — `delete` on one is `X_NOT_FOUND`, not a
|
|
302
276
|
// second stamp, which is what the Postgres driver's `deleted_at is null` clause already says.
|
|
303
277
|
const hidden =
|
|
@@ -308,7 +282,7 @@ export const memoryRepo = <Row>(
|
|
|
308
282
|
if (
|
|
309
283
|
current === undefined ||
|
|
310
284
|
hidden ||
|
|
311
|
-
!plan.where.every((predicate) =>
|
|
285
|
+
!plan.where.every((predicate) => matchesPredicate(entity, current, predicate))
|
|
312
286
|
) {
|
|
313
287
|
throw notFound(entity.$name, id);
|
|
314
288
|
}
|
|
@@ -334,7 +308,8 @@ export const memoryRepo = <Row>(
|
|
|
334
308
|
const more = start + page.length < found.length;
|
|
335
309
|
return {
|
|
336
310
|
rows: page,
|
|
337
|
-
nextCursor:
|
|
311
|
+
nextCursor:
|
|
312
|
+
more && last !== undefined ? cursorFor(entity, plan, last, storeKey(last)) : null,
|
|
338
313
|
};
|
|
339
314
|
},
|
|
340
315
|
|
|
@@ -407,14 +382,10 @@ export const memoryRepo = <Row>(
|
|
|
407
382
|
const current = addressed(id, options, 'delete');
|
|
408
383
|
// Soft delete hides the row without losing it; the column's presence is the switch.
|
|
409
384
|
if (entity.$softDelete) {
|
|
410
|
-
write(
|
|
411
|
-
Object.assign({}, current, { [SOFT_DELETE_COLUMN]: systemClock.now() }),
|
|
412
|
-
options,
|
|
413
|
-
'delete',
|
|
414
|
-
);
|
|
385
|
+
write(Object.assign({}, current, { [SOFT_DELETE_COLUMN]: entityNow() }), options, 'delete');
|
|
415
386
|
return;
|
|
416
387
|
}
|
|
417
|
-
const key =
|
|
388
|
+
const key = storeKey(current);
|
|
418
389
|
options?.tx?.onRollback(() => rows.set(key, current));
|
|
419
390
|
rows.delete(key);
|
|
420
391
|
},
|
|
@@ -428,13 +399,13 @@ export const memoryRepo = <Row>(
|
|
|
428
399
|
for (const row of doomed) {
|
|
429
400
|
if (entity.$softDelete) {
|
|
430
401
|
write(
|
|
431
|
-
Object.assign({}, row, { [SOFT_DELETE_COLUMN]:
|
|
402
|
+
Object.assign({}, row, { [SOFT_DELETE_COLUMN]: entityNow() }),
|
|
432
403
|
options,
|
|
433
404
|
'deleteWhere',
|
|
434
405
|
);
|
|
435
406
|
continue;
|
|
436
407
|
}
|
|
437
|
-
const key =
|
|
408
|
+
const key = storeKey(row);
|
|
438
409
|
options?.tx?.onRollback(() => rows.set(key, row));
|
|
439
410
|
rows.delete(key);
|
|
440
411
|
}
|
|
@@ -471,7 +442,7 @@ export const memoryRepo = <Row>(
|
|
|
471
442
|
|
|
472
443
|
reset() {
|
|
473
444
|
rows.clear();
|
|
474
|
-
for (const row of seed) rows.set(
|
|
445
|
+
for (const row of seed) rows.set(storeKey(row), row);
|
|
475
446
|
},
|
|
476
447
|
};
|
|
477
448
|
};
|