@ultimat3/entity 1.2.0 → 3.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 +694 -0
- package/README.md +467 -16
- package/package.json +6 -4
- package/src/batch-read.ts +134 -0
- package/src/batch.ts +125 -0
- package/src/bulk-write.ts +285 -0
- package/src/coalesce.ts +189 -0
- package/src/column.ts +91 -2
- package/src/columns-data.ts +205 -0
- package/src/columns.ts +228 -38
- package/src/count-by.ts +148 -0
- package/src/cross-tenant.ts +76 -0
- package/src/cursor.ts +17 -3
- package/src/database.ts +35 -2
- package/src/describe.ts +121 -39
- package/src/entity.ts +65 -20
- package/src/errors.ts +346 -5
- package/src/expr.ts +65 -15
- package/src/index.ts +72 -7
- package/src/invariants.ts +56 -14
- package/src/jit-preload.ts +216 -0
- package/src/n-plus-one.ts +122 -0
- package/src/pg-driver.ts +282 -35
- package/src/pg-row.ts +87 -16
- package/src/pg-sql.ts +156 -13
- package/src/plan.ts +130 -20
- package/src/preload.ts +184 -0
- package/src/query.ts +231 -27
- package/src/registry.ts +63 -5
- package/src/relations.ts +212 -0
- package/src/repo.ts +226 -12
- package/src/seed.ts +288 -19
- package/src/tenancy.ts +194 -12
- package/src/type-pins.ts +311 -0
- package/src/types.ts +114 -12
- package/src/view.ts +8 -2
package/src/relations.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// The relations an entity already declared. A `.references(() => orgs.id)` is a foreign key AND
|
|
2
|
+
// an association; reading it as both is what lets a preload exist with no second declaration
|
|
3
|
+
// syntax to keep in sync with the first — the FK already written IS the relation.
|
|
4
|
+
//
|
|
5
|
+
// Derivation, plus the one place that reads the registry for it: a `hasMany` is a fact about the
|
|
6
|
+
// whole set, and no single entity can see the foreign keys pointing AT it. How an edge is
|
|
7
|
+
// traversed is a separate decision, made by its own caller.
|
|
8
|
+
//
|
|
9
|
+
// The edges come in already resolved, as `RegistryEntry.references()` records. Nothing here
|
|
10
|
+
// parses `describe()`'s `"<table>.<column>"` rendering: that string carries physical names only,
|
|
11
|
+
// and a traversal reads row properties.
|
|
12
|
+
|
|
13
|
+
import { invariantViolated, preloadUnknownRelation } from './errors';
|
|
14
|
+
import type { ReferenceDescription, RegistryEntry } from './registry';
|
|
15
|
+
import { registeredEntities, registryGeneration } from './registry';
|
|
16
|
+
|
|
17
|
+
export type RelationKind = 'belongsTo' | 'hasMany';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One edge, read from one side. `local` is always a property of `from` and `remote` always a
|
|
21
|
+
* property of `to`, whichever way the edge is being read — so a traversal is one sentence in both
|
|
22
|
+
* directions: collect `localKey` off the rows in hand, then ask `to` for the rows whose
|
|
23
|
+
* `remoteColumn` is in that set.
|
|
24
|
+
*/
|
|
25
|
+
export interface Relation {
|
|
26
|
+
readonly kind: RelationKind;
|
|
27
|
+
/** Unique within `from`. This is the name a preload names. */
|
|
28
|
+
readonly name: string;
|
|
29
|
+
/** The entity the relation hangs off — the side holding the rows you already have. */
|
|
30
|
+
readonly from: string;
|
|
31
|
+
/** The entity the related rows come from. May be outside the set the map was built over. */
|
|
32
|
+
readonly to: string;
|
|
33
|
+
readonly localKey: string;
|
|
34
|
+
readonly localColumn: string;
|
|
35
|
+
readonly remoteKey: string;
|
|
36
|
+
readonly remoteColumn: string;
|
|
37
|
+
/** The FK column is nullable, so a `belongsTo` resolving to nothing is data, not a broken FK. */
|
|
38
|
+
readonly nullable: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Every relation reachable from one entity, by name. ONE flat namespace across both kinds. */
|
|
42
|
+
export type EntityRelations = Readonly<Record<string, Relation>>;
|
|
43
|
+
|
|
44
|
+
/** Keyed by entity name, in sorted order — a projection is a build input and must diff cleanly. */
|
|
45
|
+
export type RelationMap = Readonly<Record<string, EntityRelations>>;
|
|
46
|
+
|
|
47
|
+
interface ForeignKey extends ReferenceDescription {
|
|
48
|
+
/** The entity that declared `references()`. Never the target. */
|
|
49
|
+
readonly entity: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface Candidate {
|
|
53
|
+
/** Taken when nothing else in this entity wants it. */
|
|
54
|
+
readonly preferred: string;
|
|
55
|
+
/** Taken by EVERY member of a group whose `preferred` collides — never by one of them. */
|
|
56
|
+
readonly fallback: string;
|
|
57
|
+
readonly relation: Omit<Relation, 'name'>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const ID_SUFFIX = 'Id';
|
|
61
|
+
|
|
62
|
+
/** `authorId` -> `author`: the FK's own name, minus the part that only says "this is a key". */
|
|
63
|
+
const withoutId = (property: string): string =>
|
|
64
|
+
property.length > ID_SUFFIX.length && property.endsWith(ID_SUFFIX)
|
|
65
|
+
? property.slice(0, -ID_SUFFIX.length)
|
|
66
|
+
: property;
|
|
67
|
+
|
|
68
|
+
const capitalize = (value: string): string => value.charAt(0).toUpperCase() + value.slice(1);
|
|
69
|
+
|
|
70
|
+
const foreignKeysOf = (entry: RegistryEntry): readonly ForeignKey[] =>
|
|
71
|
+
entry.references().map((reference) => ({ entity: entry.name, ...reference }));
|
|
72
|
+
|
|
73
|
+
const belongsTo = (fk: ForeignKey): Candidate => ({
|
|
74
|
+
preferred: withoutId(fk.property),
|
|
75
|
+
fallback: fk.property,
|
|
76
|
+
relation: {
|
|
77
|
+
kind: 'belongsTo',
|
|
78
|
+
from: fk.entity,
|
|
79
|
+
to: fk.targetEntity,
|
|
80
|
+
localKey: fk.property,
|
|
81
|
+
localColumn: fk.column,
|
|
82
|
+
remoteKey: fk.targetProperty,
|
|
83
|
+
remoteColumn: fk.targetColumn,
|
|
84
|
+
nullable: fk.nullable,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const hasMany = (fk: ForeignKey): Candidate => ({
|
|
89
|
+
preferred: fk.entity,
|
|
90
|
+
fallback: `${fk.entity}By${capitalize(withoutId(fk.property))}`,
|
|
91
|
+
relation: {
|
|
92
|
+
kind: 'hasMany',
|
|
93
|
+
from: fk.targetEntity,
|
|
94
|
+
to: fk.entity,
|
|
95
|
+
localKey: fk.targetProperty,
|
|
96
|
+
localColumn: fk.targetColumn,
|
|
97
|
+
remoteKey: fk.property,
|
|
98
|
+
remoteColumn: fk.column,
|
|
99
|
+
nullable: fk.nullable,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/** Where the FK is written, whichever side the relation is read from — what a rename must edit. */
|
|
104
|
+
const declaredAt = (relation: Omit<Relation, 'name'>): string =>
|
|
105
|
+
relation.kind === 'belongsTo'
|
|
106
|
+
? `${relation.from}.${relation.localKey}`
|
|
107
|
+
: `${relation.to}.${relation.remoteKey}`;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Two tiers, and the second is taken by the whole colliding group rather than by the newcomer:
|
|
111
|
+
* whether `posts` keeps its name must not depend on which foreign key was declared first.
|
|
112
|
+
*
|
|
113
|
+
* The tiers resolve every realistic schema. Within one group a `belongsTo` falls back to its own
|
|
114
|
+
* property (`author` or `authorId`) and a `hasMany` to `<source>By<Fk>`, which cannot be equal;
|
|
115
|
+
* two `belongsTo` differ by property and two `hasMany` share a source, so they differ by FK. What
|
|
116
|
+
* survives is a name a THIRD group also kept, or two FKs on one entity whose names differ only by
|
|
117
|
+
* an `Id` suffix — both are one rename away, and both are refused rather than silently collapsed
|
|
118
|
+
* into one relation.
|
|
119
|
+
*/
|
|
120
|
+
const named = (entityName: string, candidates: readonly Candidate[]): EntityRelations => {
|
|
121
|
+
const wanted = new Map<string, number>();
|
|
122
|
+
for (const candidate of candidates) {
|
|
123
|
+
wanted.set(candidate.preferred, (wanted.get(candidate.preferred) ?? 0) + 1);
|
|
124
|
+
}
|
|
125
|
+
const relations = new Map<string, Relation>();
|
|
126
|
+
for (const candidate of candidates) {
|
|
127
|
+
const contested = (wanted.get(candidate.preferred) ?? 0) > 1;
|
|
128
|
+
const name = contested ? candidate.fallback : candidate.preferred;
|
|
129
|
+
const taken = relations.get(name);
|
|
130
|
+
if (taken !== undefined) {
|
|
131
|
+
throw invariantViolated(
|
|
132
|
+
entityName,
|
|
133
|
+
'relations',
|
|
134
|
+
`${declaredAt(taken)} and ${declaredAt(candidate.relation)} both resolve to the relation ` +
|
|
135
|
+
`"${name}" — rename one of the two columns`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
relations.set(name, { ...candidate.relation, name });
|
|
139
|
+
}
|
|
140
|
+
return Object.fromEntries([...relations].sort(([a], [b]) => (a < b ? -1 : 1)));
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Every relation among these entities, keyed by entity name.
|
|
145
|
+
*
|
|
146
|
+
* A `belongsTo` is a fact about the entity's own column, so it is recorded even when its target
|
|
147
|
+
* is outside the set — the caller resolves `to` when it traverses. A `hasMany` is a fact about a
|
|
148
|
+
* pair, so only the inbound keys of entities that were passed in can produce one.
|
|
149
|
+
*/
|
|
150
|
+
export const relationsOf = (entries: readonly RegistryEntry[]): RelationMap => {
|
|
151
|
+
// By name: the registry already refuses two entities with one name, so the same entry handed
|
|
152
|
+
// in twice is one entity — not two sets of foreign keys colliding with themselves.
|
|
153
|
+
const unique = new Map(entries.map((entry) => [entry.name, entry]));
|
|
154
|
+
// One pass, filed under both ends as it goes. Rescanning every foreign key once per entity is
|
|
155
|
+
// the whole schema squared, paid again on the first read after every late registration.
|
|
156
|
+
const outbound = new Map<string, Candidate[]>();
|
|
157
|
+
const inbound = new Map<string, Candidate[]>();
|
|
158
|
+
for (const name of unique.keys()) {
|
|
159
|
+
outbound.set(name, []);
|
|
160
|
+
inbound.set(name, []);
|
|
161
|
+
}
|
|
162
|
+
for (const entry of unique.values()) {
|
|
163
|
+
for (const fk of foreignKeysOf(entry)) {
|
|
164
|
+
outbound.get(fk.entity)?.push(belongsTo(fk));
|
|
165
|
+
// A target outside the set contributes no `hasMany`: nothing here holds its inbound keys.
|
|
166
|
+
inbound.get(fk.targetEntity)?.push(hasMany(fk));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const map: Record<string, EntityRelations> = {};
|
|
170
|
+
for (const name of [...unique.keys()].sort()) {
|
|
171
|
+
// Outbound before inbound, so a collision resolves the same way whichever pass found it.
|
|
172
|
+
map[name] = named(name, [...(outbound.get(name) ?? []), ...(inbound.get(name) ?? [])]);
|
|
173
|
+
}
|
|
174
|
+
return map;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** Rebuilt on the first read after any registration, never on a read that changed nothing. */
|
|
178
|
+
let cached: { readonly generation: number; readonly map: RelationMap } | undefined;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The relations of every registered entity — the set query time means by "the relations".
|
|
182
|
+
*
|
|
183
|
+
* Memoised against the registry generation rather than computed once: a schema module imported
|
|
184
|
+
* after the first read registers one more entity, and a `hasMany` that entity contributes would
|
|
185
|
+
* otherwise be missing for the rest of the process. Deriving costs one pass over the foreign keys,
|
|
186
|
+
* so a read that follows a registration simply pays it again.
|
|
187
|
+
*/
|
|
188
|
+
export const relationMap = (): RelationMap => {
|
|
189
|
+
const generation = registryGeneration();
|
|
190
|
+
if (cached !== undefined && cached.generation === generation) return cached.map;
|
|
191
|
+
const map = relationsOf(registeredEntities());
|
|
192
|
+
cached = { generation, map };
|
|
193
|
+
return map;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/** Every relation reachable from one entity. An unregistered name has none — that is not an error. */
|
|
197
|
+
export const relationsFor = (entityName: string): EntityRelations =>
|
|
198
|
+
relationMap()[entityName] ?? {};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* One relation, by the name a caller wrote. A preload names its relation as a *string*, so an
|
|
202
|
+
* unknown one is only actionable if the refusal carries the names that do exist — and they exist
|
|
203
|
+
* nowhere to go and read, being derived from `references()` rather than declared.
|
|
204
|
+
*/
|
|
205
|
+
export const relationNamed = (entityName: string, name: string): Relation => {
|
|
206
|
+
const relations = relationsFor(entityName);
|
|
207
|
+
const relation = relations[name];
|
|
208
|
+
if (relation === undefined) {
|
|
209
|
+
throw preloadUnknownRelation(entityName, name, Object.keys(relations));
|
|
210
|
+
}
|
|
211
|
+
return relation;
|
|
212
|
+
};
|
package/src/repo.ts
CHANGED
|
@@ -7,11 +7,17 @@
|
|
|
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 { systemClock } from '@ultimat3/core';
|
|
11
|
+
import { conflictKeyOf, conflictKeys, upsertPlan } from './bulk-write';
|
|
12
|
+
import { narrowMoney } from './columns';
|
|
13
|
+
import { countsFrom, groupColumnOf } from './count-by';
|
|
10
14
|
import { cursorFor, seekFrom, valueAt } from './cursor';
|
|
11
15
|
import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
12
16
|
import { notFound } from './errors';
|
|
13
|
-
import { idPlan, readPlan, singleKeyOf } from './plan';
|
|
17
|
+
import { deletePlan, idPlan, readPlan, singleKeyOf, updatePlan } from './plan';
|
|
14
18
|
import type { Predicate, QueryPlan, SortKey } from './tenancy';
|
|
19
|
+
import { assertRowTenant } from './tenancy';
|
|
20
|
+
import type { IdOf } from './types';
|
|
15
21
|
|
|
16
22
|
export interface Tx {
|
|
17
23
|
readonly id: string;
|
|
@@ -21,10 +27,31 @@ export interface Tx {
|
|
|
21
27
|
|
|
22
28
|
export interface RepoOptions {
|
|
23
29
|
readonly tx?: Tx;
|
|
24
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* The tenant, and never the authority for it: inside a request the plan is scoped to the acting
|
|
32
|
+
* actor's org whether this is passed or not, and a value that disagrees with the actor is
|
|
33
|
+
* `X_TENANCY_ACTOR_MISMATCH` rather than the tenant the query runs under. It is still required
|
|
34
|
+
* outside every request context — a script has no actor to derive from.
|
|
35
|
+
*/
|
|
25
36
|
readonly orgId?: string;
|
|
26
37
|
}
|
|
27
38
|
|
|
39
|
+
/** What `upsertAll` does with a row that lands on one already stored. */
|
|
40
|
+
export interface UpsertArgs<T = unknown> extends RepoOptions {
|
|
41
|
+
/**
|
|
42
|
+
* The unique constraint a collision is judged against, named as entity properties — never a
|
|
43
|
+
* constraint name, which is a migration artefact this layer cannot resolve. At least one column:
|
|
44
|
+
* "any constraint" is not something a caller can reason about.
|
|
45
|
+
*/
|
|
46
|
+
readonly onConflict: readonly (keyof T & string)[];
|
|
47
|
+
/**
|
|
48
|
+
* `'update'` (the default) overwrites the stored row with the incoming values, except the
|
|
49
|
+
* conflict target and the primary key. `'nothing'` leaves the stored row exactly as it is and
|
|
50
|
+
* omits it from the result, so the result is always "the rows this call wrote".
|
|
51
|
+
*/
|
|
52
|
+
readonly onMatch?: 'update' | 'nothing';
|
|
53
|
+
}
|
|
54
|
+
|
|
28
55
|
export interface FindManyArgs extends RepoOptions {
|
|
29
56
|
readonly where?: readonly Predicate[];
|
|
30
57
|
readonly orderBy?: readonly SortKey[];
|
|
@@ -43,14 +70,69 @@ export interface Page<T> {
|
|
|
43
70
|
/**
|
|
44
71
|
* `T` defaults to `unknown` so a row-agnostic consumer (the generated admin, the manifest
|
|
45
72
|
* emitter) can name the shape without knowing the entity.
|
|
73
|
+
*
|
|
74
|
+
* The id parameters are `IdOf<T>`, not `string`: an entity that declared `uuid<PostId>()` is
|
|
75
|
+
* addressed by a `PostId` and by nothing else. `IdOf<unknown>` and `IdOf<{ id: string }>` are
|
|
76
|
+
* both `string`, so a row-agnostic consumer sees the signature it always saw.
|
|
46
77
|
*/
|
|
47
78
|
export interface Repo<T = unknown> {
|
|
48
|
-
findById(id:
|
|
79
|
+
findById(id: IdOf<T>, options?: RepoOptions): Promise<T | null>;
|
|
49
80
|
findMany(args?: FindManyArgs): Promise<Page<T>>;
|
|
50
81
|
insert(values: T, options?: RepoOptions): Promise<T>;
|
|
51
|
-
|
|
52
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Many rows, one statement — the bulk form a per-row `insert` loop is the N+1 of. Resolves with
|
|
84
|
+
* the rows as stored, defaults included, in the order given; an empty batch writes nothing and
|
|
85
|
+
* resolves with `[]`. Nothing here resolves a collision — `upsertAll` is the call that does.
|
|
86
|
+
* Past Postgres's bind count the batch becomes several statements, so wrap it in
|
|
87
|
+
* `withTransaction` when all-or-nothing matters.
|
|
88
|
+
*/
|
|
89
|
+
insertAll(rows: readonly T[], options?: RepoOptions): Promise<readonly T[]>;
|
|
90
|
+
/**
|
|
91
|
+
* `insertAll` that resolves a collision instead of failing on it. Resolves with the rows this
|
|
92
|
+
* call actually wrote — under `onMatch: 'nothing'` a row already stored is skipped and absent,
|
|
93
|
+
* which is what `returning *` says on the Postgres side.
|
|
94
|
+
*/
|
|
95
|
+
upsertAll(rows: readonly T[], args: UpsertArgs<T>): Promise<readonly T[]>;
|
|
96
|
+
update(id: IdOf<T>, patch: Partial<T>, options?: RepoOptions): Promise<T>;
|
|
97
|
+
delete(id: IdOf<T>, options?: RepoOptions): Promise<void>;
|
|
98
|
+
/**
|
|
99
|
+
* Delete by filter, returning how many rows went. The only way to remove a row from an entity
|
|
100
|
+
* with a composite primary key, where `delete(id)` cannot name one. Never `void`: a caller has
|
|
101
|
+
* to be able to tell "nothing matched" from "it worked", and an empty filter is
|
|
102
|
+
* `X_WRITE_UNFILTERED` rather than every row.
|
|
103
|
+
*/
|
|
104
|
+
deleteWhere(filter: Partial<T>, options?: RepoOptions): Promise<number>;
|
|
105
|
+
/**
|
|
106
|
+
* Update by filter, returning how many rows were written. The `update(id, patch)` a composite
|
|
107
|
+
* primary key cannot express — `participants.lastReadAt` is the reference case. Same two guards
|
|
108
|
+
* as `deleteWhere`, plus `X_PATCH_EMPTY` for a patch that names no columns, and soft-deleted
|
|
109
|
+
* rows are not reachable, exactly as they are not by `update(id, patch)`.
|
|
110
|
+
*/
|
|
111
|
+
updateWhere(filter: Partial<T>, patch: Partial<T>, options?: RepoOptions): Promise<number>;
|
|
53
112
|
count(args?: FindManyArgs): Promise<number>;
|
|
113
|
+
/**
|
|
114
|
+
* The grouped count: one statement, one entry per distinct value of `column`, over exactly the
|
|
115
|
+
* rows `count(args)` counts — the aggregate a `count()` per row is the N+1 of. A value nothing
|
|
116
|
+
* matched is absent rather than `0`: the caller knows which keys they asked about, and a map
|
|
117
|
+
* that invents them cannot say which ones the table has never seen.
|
|
118
|
+
*
|
|
119
|
+
* `column` is a property name, spelled as `select` spells one — the typed form is
|
|
120
|
+
* `ReadBuilder.countBy`, which knows the row. Ordered by count, biggest group first.
|
|
121
|
+
*/
|
|
122
|
+
countBy(column: string, args?: FindManyArgs): Promise<ReadonlyMap<unknown, number>>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What `memoryRepo()` returns: a `Repo`, plus the one member a database-backed repository has no
|
|
127
|
+
* business having. TEST SEAM — nothing on the framework's own request path calls `reset()`.
|
|
128
|
+
*/
|
|
129
|
+
export interface MemoryRepo<Row> extends Repo<Row> {
|
|
130
|
+
/**
|
|
131
|
+
* Drops every stored row, in place. In place is the whole point: `database()` resolves each
|
|
132
|
+
* table's repository once, so a test harness that replaced the driver's repositories would be
|
|
133
|
+
* emptying objects the app under test no longer reads.
|
|
134
|
+
*/
|
|
135
|
+
reset(): void;
|
|
54
136
|
}
|
|
55
137
|
|
|
56
138
|
export interface Transactor {
|
|
@@ -101,12 +183,20 @@ const matches = (row: unknown, predicate: Predicate): boolean => {
|
|
|
101
183
|
}
|
|
102
184
|
};
|
|
103
185
|
|
|
104
|
-
/**
|
|
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
|
+
*/
|
|
105
195
|
const likePattern = (pattern: string): RegExp =>
|
|
106
196
|
new RegExp(
|
|
107
197
|
`^${pattern
|
|
108
198
|
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
109
|
-
.replaceAll(
|
|
199
|
+
.replaceAll(/%+/g, '.*')
|
|
110
200
|
.replaceAll('_', '.')}$`,
|
|
111
201
|
's',
|
|
112
202
|
);
|
|
@@ -151,7 +241,10 @@ const afterCursor = <Row>(
|
|
|
151
241
|
* migration and tests use it everywhere. Postgres is the production driver and implements
|
|
152
242
|
* this same interface.
|
|
153
243
|
*/
|
|
154
|
-
export const memoryRepo = <Row>(
|
|
244
|
+
export const memoryRepo = <Row>(
|
|
245
|
+
entity: EntityCore<Row>,
|
|
246
|
+
seed: readonly Row[] = [],
|
|
247
|
+
): MemoryRepo<Row> => {
|
|
155
248
|
const keyOf = (row: unknown): string =>
|
|
156
249
|
entity.$primaryKey.map((property) => String(field(row, property))).join('');
|
|
157
250
|
const rows = new Map<string, Row>(seed.map((row) => [keyOf(row), row]));
|
|
@@ -179,7 +272,16 @@ export const memoryRepo = <Row>(entity: EntityCore<Row>, seed: readonly Row[] =
|
|
|
179
272
|
return { plan, found: rowsOf(plan, args) };
|
|
180
273
|
};
|
|
181
274
|
|
|
182
|
-
const write = (
|
|
275
|
+
const write = (given: Row, options: RepoOptions | undefined, operation: string): Row => {
|
|
276
|
+
// `MoneyInput` lets a writer hand a `bigint`; a stored row holds the value type. The Postgres
|
|
277
|
+
// driver narrows in `bindValues` and reads its answer back through `returning *`, so without
|
|
278
|
+
// this an in-memory row would be the one row in the framework `JSON.stringify` refuses.
|
|
279
|
+
const row = narrowMoney(entity.$columns, given);
|
|
280
|
+
// Beside `$assert`, and before the row lands: a write is judged by the tenant it names as well
|
|
281
|
+
// as by the invariants it declares, and the Postgres driver runs the same pair in `writeRows`.
|
|
282
|
+
// `update` reaches here with the STORED row merged under its patch, so a patch that moves a row
|
|
283
|
+
// out of this tenant is refused by the same call that refuses an insert into another one.
|
|
284
|
+
assertRowTenant(entity.$name, entity.$tenantColumn, operation, row);
|
|
183
285
|
entity.$assert(row);
|
|
184
286
|
const key = keyOf(row);
|
|
185
287
|
const previous = rows.get(key);
|
|
@@ -237,18 +339,79 @@ export const memoryRepo = <Row>(entity: EntityCore<Row>, seed: readonly Row[] =
|
|
|
237
339
|
},
|
|
238
340
|
|
|
239
341
|
async insert(values, options) {
|
|
240
|
-
return write(values, options);
|
|
342
|
+
return write(values, options, 'insert');
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
async insertAll(batch, options) {
|
|
346
|
+
// The whole batch is judged before any of it lands: Postgres refuses the statement as one,
|
|
347
|
+
// so a row an invariant rejects — or one naming a tenant this actor may not write — must not
|
|
348
|
+
// leave the rows before it stored here either. `write` re-checks both per row; this loop is
|
|
349
|
+
// what makes the batch all-or-nothing, which is the half a per-row check cannot give.
|
|
350
|
+
for (const row of batch) {
|
|
351
|
+
assertRowTenant(entity.$name, entity.$tenantColumn, 'insertAll', row);
|
|
352
|
+
entity.$assert(row);
|
|
353
|
+
}
|
|
354
|
+
return batch.map((row) => write(row, options, 'insertAll'));
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
async upsertAll(batch, args) {
|
|
358
|
+
// The INCOMING rows, judged before any of them is matched: under `onMatch: 'nothing'` a
|
|
359
|
+
// colliding row is skipped and never reaches `write()`, so checking only what lands would
|
|
360
|
+
// let a row naming another tenant through whenever it happened to collide.
|
|
361
|
+
for (const row of batch) {
|
|
362
|
+
assertRowTenant(entity.$name, entity.$tenantColumn, 'upsertAll', row);
|
|
363
|
+
entity.$assert(row);
|
|
364
|
+
}
|
|
365
|
+
const plan = upsertPlan(entity, batch, args.onConflict, args.onMatch ?? 'update');
|
|
366
|
+
const keys = conflictKeys(entity, plan, batch);
|
|
367
|
+
// The stored rows under the same key, so "does this collide" is the question the unique
|
|
368
|
+
// index answers in Postgres and not a scan per row. A soft-deleted row still occupies its
|
|
369
|
+
// key here, because the index it would collide with there is not partial either — and a row
|
|
370
|
+
// whose target holds a null occupies none, because the index is `NULLS DISTINCT`.
|
|
371
|
+
const stored = new Map<string, Row>();
|
|
372
|
+
for (const row of rows.values()) {
|
|
373
|
+
const key = conflictKeyOf(entity, plan.on, row);
|
|
374
|
+
if (key !== undefined) stored.set(key, row);
|
|
375
|
+
}
|
|
376
|
+
const written: Row[] = [];
|
|
377
|
+
for (const [position, row] of batch.entries()) {
|
|
378
|
+
const key = keys[position];
|
|
379
|
+
const existing = key === undefined ? undefined : stored.get(key);
|
|
380
|
+
// `do nothing` writes no row, and `returning *` therefore names none: a skipped row is
|
|
381
|
+
// absent from the result rather than present and unchanged.
|
|
382
|
+
if (existing !== undefined && plan.set.length === 0) continue;
|
|
383
|
+
const merged =
|
|
384
|
+
existing === undefined
|
|
385
|
+
? row
|
|
386
|
+
: Object.assign(
|
|
387
|
+
{},
|
|
388
|
+
existing,
|
|
389
|
+
Object.fromEntries(plan.set.map((property) => [property, field(row, property)])),
|
|
390
|
+
);
|
|
391
|
+
// `UpsertArgs extends RepoOptions`, so the args ARE the options — one bag, and a `tx`
|
|
392
|
+
// passed to an upsert registers its undo exactly as it does for every other write here.
|
|
393
|
+
const result = write(merged, args, 'upsertAll');
|
|
394
|
+
// Filed as it lands, so a later row of the same batch collides with an earlier one exactly
|
|
395
|
+
// as it would with a row the request stored a moment before it.
|
|
396
|
+
if (key !== undefined) stored.set(key, result);
|
|
397
|
+
written.push(result);
|
|
398
|
+
}
|
|
399
|
+
return written;
|
|
241
400
|
},
|
|
242
401
|
|
|
243
402
|
async update(id, patch, options) {
|
|
244
|
-
return write(Object.assign({}, addressed(id, options, 'update'), patch), options);
|
|
403
|
+
return write(Object.assign({}, addressed(id, options, 'update'), patch), options, 'update');
|
|
245
404
|
},
|
|
246
405
|
|
|
247
406
|
async delete(id, options) {
|
|
248
407
|
const current = addressed(id, options, 'delete');
|
|
249
408
|
// Soft delete hides the row without losing it; the column's presence is the switch.
|
|
250
409
|
if (entity.$softDelete) {
|
|
251
|
-
write(
|
|
410
|
+
write(
|
|
411
|
+
Object.assign({}, current, { [SOFT_DELETE_COLUMN]: systemClock.now() }),
|
|
412
|
+
options,
|
|
413
|
+
'delete',
|
|
414
|
+
);
|
|
252
415
|
return;
|
|
253
416
|
}
|
|
254
417
|
const key = keyOf(current);
|
|
@@ -256,9 +419,60 @@ export const memoryRepo = <Row>(entity: EntityCore<Row>, seed: readonly Row[] =
|
|
|
256
419
|
rows.delete(key);
|
|
257
420
|
},
|
|
258
421
|
|
|
422
|
+
async deleteWhere(filter, options) {
|
|
423
|
+
// `rowsOf` is the read path: the same predicates, the same tenant scoping, and the same
|
|
424
|
+
// soft-delete visibility. A row already stamped is not matched, so a second call cannot
|
|
425
|
+
// move `deletedAt` forward — which is what the Postgres driver's `deleted_at is null`
|
|
426
|
+
// clause says there.
|
|
427
|
+
const doomed = rowsOf(deletePlan(entity, filter, options, 'deleteWhere'), {});
|
|
428
|
+
for (const row of doomed) {
|
|
429
|
+
if (entity.$softDelete) {
|
|
430
|
+
write(
|
|
431
|
+
Object.assign({}, row, { [SOFT_DELETE_COLUMN]: systemClock.now() }),
|
|
432
|
+
options,
|
|
433
|
+
'deleteWhere',
|
|
434
|
+
);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const key = keyOf(row);
|
|
438
|
+
options?.tx?.onRollback(() => rows.set(key, row));
|
|
439
|
+
rows.delete(key);
|
|
440
|
+
}
|
|
441
|
+
return doomed.length;
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
async updateWhere(filter, patch, options) {
|
|
445
|
+
// `rowsOf` again, so a soft-deleted row is as unreachable here as it is through
|
|
446
|
+
// `addressed()` — patching a row the app has already deleted is not an update, it is a
|
|
447
|
+
// resurrection nobody asked for. `write` re-asserts the invariants on each result.
|
|
448
|
+
const found = rowsOf(updatePlan(entity, filter, patch, options, 'updateWhere'), {});
|
|
449
|
+
for (const row of found) write(Object.assign({}, row, patch), options, 'updateWhere');
|
|
450
|
+
return found.length;
|
|
451
|
+
},
|
|
452
|
+
|
|
259
453
|
async count(args = {}) {
|
|
260
454
|
return select(args, 'count').found.length;
|
|
261
455
|
},
|
|
456
|
+
|
|
457
|
+
async countBy(column, args = {}) {
|
|
458
|
+
// Refused before a row is read, and by the same function the Postgres driver calls: a column
|
|
459
|
+
// a map cannot be keyed by is that mistake in both drivers or in neither.
|
|
460
|
+
groupColumnOf(entity, column, 'countBy');
|
|
461
|
+
const { found } = select(args, 'countBy');
|
|
462
|
+
const groups = new Map<unknown, number>();
|
|
463
|
+
for (const row of found) {
|
|
464
|
+
// `?? null`, so a property this row never carried lands in the same group Postgres puts a
|
|
465
|
+
// NULL row in — and `0`, `''` and `false` stay the values they are.
|
|
466
|
+
const value = field(row, column) ?? null;
|
|
467
|
+
groups.set(value, (groups.get(value) ?? 0) + 1);
|
|
468
|
+
}
|
|
469
|
+
return countsFrom(entity, column, 'countBy', [...groups]);
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
reset() {
|
|
473
|
+
rows.clear();
|
|
474
|
+
for (const row of seed) rows.set(keyOf(row), row);
|
|
475
|
+
},
|
|
262
476
|
};
|
|
263
477
|
};
|
|
264
478
|
|