@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.
@@ -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
- /** Required for tenant-scoped entities; the guard throws without it. */
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: string, options?: RepoOptions): Promise<T | null>;
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
- update(id: string, patch: Partial<T>, options?: RepoOptions): Promise<T>;
52
- delete(id: string, options?: RepoOptions): Promise<void>;
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
- /** `%` and `_` are the wildcards; everything else in the pattern is literal, as in SQL. */
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>(entity: EntityCore<Row>, seed: readonly Row[] = []): Repo<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 = (row: Row, options: RepoOptions | undefined): Row => {
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(Object.assign({}, current, { [SOFT_DELETE_COLUMN]: new Date() }), options);
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