@ultimat3/entity 21.0.0 → 22.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 +226 -1158
- package/README.md +9 -0
- package/package.json +5 -5
- package/src/aggregate-decode.ts +2 -1
- package/src/columns-data.ts +28 -2
- package/src/columns-scalar.ts +79 -0
- package/src/columns.ts +15 -46
- package/src/entity.ts +1 -0
- package/src/index.ts +6 -9
- package/src/jit-preload.ts +19 -1
- package/src/memory-repo.ts +74 -7
- package/src/memory-unique.ts +51 -0
- package/src/pg-driver.ts +3 -20
- package/src/pg-row.ts +5 -1
- package/src/pg-sql-aggregate.ts +132 -0
- package/src/pg-sql.ts +1 -118
- package/src/pg-transactor.ts +23 -0
- package/src/registry.ts +13 -0
- package/src/repo.ts +5 -0
- package/src/row-observer.ts +37 -14
- package/src/seed.ts +7 -1
- package/src/transition.ts +6 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The aggregate reads compiled to SQL: a grouped count, one aggregate over one column, the
|
|
2
|
+
// distinct currencies a money column holds, and the planner's row estimate. Split from `pg-sql.ts`,
|
|
3
|
+
// which compiles the row reads they share their predicates with — `conditions()` is imported, never
|
|
4
|
+
// restated, so an aggregate counts exactly the rows the matching read would return.
|
|
5
|
+
|
|
6
|
+
import { identifier, type SqlFragment, sql } from '@ultimat3/db';
|
|
7
|
+
import type { AggregateFn } from './aggregate';
|
|
8
|
+
import { AVG_SCALE } from './aggregate';
|
|
9
|
+
import { kindOf } from './cursor';
|
|
10
|
+
import type { EntityCore } from './entity';
|
|
11
|
+
import { columnRef, conditions, type ReadShape } from './pg-sql';
|
|
12
|
+
import type { QueryPlan } from './tenancy';
|
|
13
|
+
|
|
14
|
+
/** What a grouped count comes back as. Both names are fixed, so neither can be a column's. */
|
|
15
|
+
export interface GroupRow {
|
|
16
|
+
readonly group_value: unknown;
|
|
17
|
+
readonly group_count: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The grouped count: one row per distinct value of one column, over exactly the rows
|
|
22
|
+
* `countStatement` would have counted — the same predicates, the same soft-delete filter, one
|
|
23
|
+
* `group by` more. `limit` bounds the groups, not the rows, which is what turns a whole-table
|
|
24
|
+
* breakdown into a refusal instead of a result set nobody sized.
|
|
25
|
+
*
|
|
26
|
+
* Both output names are aliases and fixed, so they cannot collide with each other whatever the
|
|
27
|
+
* table declares: an entity is free to have a column called `count`, and the un-aliased form would
|
|
28
|
+
* then return two outputs of one name.
|
|
29
|
+
*/
|
|
30
|
+
export const countByStatement = <Row>(
|
|
31
|
+
entity: EntityCore<Row>,
|
|
32
|
+
plan: QueryPlan,
|
|
33
|
+
shape: ReadShape,
|
|
34
|
+
column: string,
|
|
35
|
+
limit: number,
|
|
36
|
+
): SqlFragment => {
|
|
37
|
+
const grouped = columnRef(entity, column);
|
|
38
|
+
return sql`select ${grouped} as group_value, count(*) as group_count from ${identifier(
|
|
39
|
+
entity.$table,
|
|
40
|
+
)} where ${conditions(entity, plan, shape)} group by ${grouped} limit ${limit}`;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One aggregate over exactly the rows `countStatement` would have counted — the same predicates,
|
|
45
|
+
* the same soft-delete filter, one function more. Four outputs and always the same four names, so
|
|
46
|
+
* neither driver reads a column an entity could also have declared:
|
|
47
|
+
*
|
|
48
|
+
* - `agg_value` — the aggregate itself, as TEXT. `::text` and never a float: `sum(bigint)` is a
|
|
49
|
+
* `numeric` Bun would hand back as a string anyway, and pinning it makes `integer` behave the
|
|
50
|
+
* same. `aggregate.ts` re-parses it by the column's kind.
|
|
51
|
+
* - `agg_count` — how many non-null values went in, which is what tells `null` ("no rows") from a
|
|
52
|
+
* legitimate zero, and what `avg` divides by.
|
|
53
|
+
*
|
|
54
|
+
* `avg` is `round(avg(...), AVG_SCALE)` rather than the server's own scale, because the in-memory
|
|
55
|
+
* driver has to reach the same digits and "whatever numeric division gives you" is not a rule two
|
|
56
|
+
* implementations can share.
|
|
57
|
+
*/
|
|
58
|
+
export const aggregateStatement = <Row>(
|
|
59
|
+
entity: EntityCore<Row>,
|
|
60
|
+
plan: QueryPlan,
|
|
61
|
+
shape: ReadShape,
|
|
62
|
+
fn: AggregateFn,
|
|
63
|
+
column: string,
|
|
64
|
+
): SqlFragment => {
|
|
65
|
+
const target = columnRef(entity, column);
|
|
66
|
+
const extreme = fn === 'min' ? sql`min(${target})` : sql`max(${target})`;
|
|
67
|
+
const value =
|
|
68
|
+
fn === 'sum'
|
|
69
|
+
? sql`sum(${target})`
|
|
70
|
+
: fn === 'avg'
|
|
71
|
+
? sql`round(avg(${target}), ${AVG_SCALE})`
|
|
72
|
+
: // An instant crosses as EPOCH MILLISECONDS, never as the session's own text: `::text`
|
|
73
|
+
// prints in the session's zone, and `new Date` on that answered NaN for an offset with
|
|
74
|
+
// seconds (a pre-1937 LMT) and 1999 for year 0099. Not `at time zone 'UTC'` either —
|
|
75
|
+
// that is an offsetless `timestamp`, which JS reads as LOCAL time.
|
|
76
|
+
kindOf(entity, column) === 'timestamptz'
|
|
77
|
+
? sql`(extract(epoch from ${extreme}) * 1000)`
|
|
78
|
+
: extreme;
|
|
79
|
+
return sql`select ${value}::text as agg_value, count(${target}) as agg_count from ${identifier(
|
|
80
|
+
entity.$table,
|
|
81
|
+
)} where ${conditions(entity, plan, shape)}`;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** What an aggregate comes back as. Both names are fixed, so neither can be a column's. */
|
|
85
|
+
export interface AggregateRow {
|
|
86
|
+
readonly agg_value: unknown;
|
|
87
|
+
readonly agg_count: unknown;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The distinct currencies among the rows an aggregate is about to cover. A separate statement
|
|
92
|
+
* rather than a clever one: `sum(minor)` over two currencies is a number in neither, and the only
|
|
93
|
+
* honest answer is to refuse — which needs the list, not a boolean.
|
|
94
|
+
*
|
|
95
|
+
* Bounded at three, because the refusal names them and a caller with three already knows.
|
|
96
|
+
*/
|
|
97
|
+
export const currenciesStatement = <Row>(
|
|
98
|
+
entity: EntityCore<Row>,
|
|
99
|
+
plan: QueryPlan,
|
|
100
|
+
shape: ReadShape,
|
|
101
|
+
currencyColumn: string,
|
|
102
|
+
scaleColumn: string | null,
|
|
103
|
+
): SqlFragment => {
|
|
104
|
+
const currency = identifier(currencyColumn);
|
|
105
|
+
// The SCALE is half of what makes two amounts incomparable and it is the half with no symptom:
|
|
106
|
+
// `{ minor: 5, currency: 'USD' }` is five cents and the same row at `scale: 6` is five millionths
|
|
107
|
+
// of a dollar. A table with no scale column has one unit per currency by construction.
|
|
108
|
+
const scale = scaleColumn === null ? sql`null` : identifier(scaleColumn);
|
|
109
|
+
return sql`select distinct ${currency} as group_value, ${scale} as group_scale from ${identifier(
|
|
110
|
+
entity.$table,
|
|
111
|
+
)} where ${conditions(entity, plan, shape)} and ${currency} is not null limit 3`;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** One `(currency, scale)` pair the rows an aggregate covers actually use. */
|
|
115
|
+
export interface MoneyUnitRow {
|
|
116
|
+
readonly group_value: unknown;
|
|
117
|
+
readonly group_scale: unknown;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The planner's own row estimate for a table — `reltuples`, which is what `ANALYZE` last wrote and
|
|
122
|
+
* what every query plan in the database is already costed against. `count(*)` walks every visible
|
|
123
|
+
* row (MVCC gives no shortcut), so on a large table it is the read that exceeds a web role's
|
|
124
|
+
* `statement_timeout`, and no index can make it cheaper: the `fix:` on that timeout tells an author
|
|
125
|
+
* to add one, and following it changes nothing.
|
|
126
|
+
*
|
|
127
|
+
* `to_regclass` rather than a name comparison, so a search_path change cannot silently answer for a
|
|
128
|
+
* different schema's table of the same name — and `-1` is what Postgres 14+ stores for a table that
|
|
129
|
+
* has never been analysed, which is an answer, not an estimate.
|
|
130
|
+
*/
|
|
131
|
+
export const estimateStatement = (table: string): SqlFragment =>
|
|
132
|
+
sql`select reltuples::bigint as estimate from pg_class where oid = to_regclass(${table})`;
|
package/src/pg-sql.ts
CHANGED
|
@@ -4,8 +4,6 @@
|
|
|
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 type { AggregateFn } from './aggregate';
|
|
8
|
-
import { AVG_SCALE } from './aggregate';
|
|
9
7
|
import { columnFor } from './column';
|
|
10
8
|
import { isNullableKey, kindOf } from './cursor';
|
|
11
9
|
import type { EntityCore } from './entity';
|
|
@@ -26,7 +24,7 @@ export interface ReadShape {
|
|
|
26
24
|
readonly seek?: readonly unknown[] | undefined;
|
|
27
25
|
}
|
|
28
26
|
|
|
29
|
-
const columnRef = <Row>(entity: EntityCore<Row>, path: string): SqlFragment =>
|
|
27
|
+
export const columnRef = <Row>(entity: EntityCore<Row>, path: string): SqlFragment =>
|
|
30
28
|
identifier(physicalName(entity, path));
|
|
31
29
|
|
|
32
30
|
/**
|
|
@@ -379,118 +377,3 @@ export const countStatement = <Row>(
|
|
|
379
377
|
shape: ReadShape,
|
|
380
378
|
): SqlFragment =>
|
|
381
379
|
sql`select count(*) as count from ${identifier(entity.$table)} where ${conditions(entity, plan, shape)}`;
|
|
382
|
-
|
|
383
|
-
/** What a grouped count comes back as. Both names are fixed, so neither can be a column's. */
|
|
384
|
-
export interface GroupRow {
|
|
385
|
-
readonly group_value: unknown;
|
|
386
|
-
readonly group_count: unknown;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/**
|
|
390
|
-
* The grouped count: one row per distinct value of one column, over exactly the rows
|
|
391
|
-
* `countStatement` would have counted — the same predicates, the same soft-delete filter, one
|
|
392
|
-
* `group by` more. `limit` bounds the groups, not the rows, which is what turns a whole-table
|
|
393
|
-
* breakdown into a refusal instead of a result set nobody sized.
|
|
394
|
-
*
|
|
395
|
-
* Both output names are aliases and fixed, so they cannot collide with each other whatever the
|
|
396
|
-
* table declares: an entity is free to have a column called `count`, and the un-aliased form would
|
|
397
|
-
* then return two outputs of one name.
|
|
398
|
-
*/
|
|
399
|
-
export const countByStatement = <Row>(
|
|
400
|
-
entity: EntityCore<Row>,
|
|
401
|
-
plan: QueryPlan,
|
|
402
|
-
shape: ReadShape,
|
|
403
|
-
column: string,
|
|
404
|
-
limit: number,
|
|
405
|
-
): SqlFragment => {
|
|
406
|
-
const grouped = columnRef(entity, column);
|
|
407
|
-
return sql`select ${grouped} as group_value, count(*) as group_count from ${identifier(
|
|
408
|
-
entity.$table,
|
|
409
|
-
)} where ${conditions(entity, plan, shape)} group by ${grouped} limit ${limit}`;
|
|
410
|
-
};
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* One aggregate over exactly the rows `countStatement` would have counted — the same predicates,
|
|
414
|
-
* the same soft-delete filter, one function more. Four outputs and always the same four names, so
|
|
415
|
-
* neither driver reads a column an entity could also have declared:
|
|
416
|
-
*
|
|
417
|
-
* - `agg_value` — the aggregate itself, as TEXT. `::text` and never a float: `sum(bigint)` is a
|
|
418
|
-
* `numeric` Bun would hand back as a string anyway, and pinning it makes `integer` behave the
|
|
419
|
-
* same. `aggregate.ts` re-parses it by the column's kind.
|
|
420
|
-
* - `agg_count` — how many non-null values went in, which is what tells `null` ("no rows") from a
|
|
421
|
-
* legitimate zero, and what `avg` divides by.
|
|
422
|
-
*
|
|
423
|
-
* `avg` is `round(avg(...), AVG_SCALE)` rather than the server's own scale, because the in-memory
|
|
424
|
-
* driver has to reach the same digits and "whatever numeric division gives you" is not a rule two
|
|
425
|
-
* implementations can share.
|
|
426
|
-
*/
|
|
427
|
-
export const aggregateStatement = <Row>(
|
|
428
|
-
entity: EntityCore<Row>,
|
|
429
|
-
plan: QueryPlan,
|
|
430
|
-
shape: ReadShape,
|
|
431
|
-
fn: AggregateFn,
|
|
432
|
-
column: string,
|
|
433
|
-
): SqlFragment => {
|
|
434
|
-
const target = columnRef(entity, column);
|
|
435
|
-
const value =
|
|
436
|
-
fn === 'sum'
|
|
437
|
-
? sql`sum(${target})`
|
|
438
|
-
: fn === 'avg'
|
|
439
|
-
? sql`round(avg(${target}), ${AVG_SCALE})`
|
|
440
|
-
: fn === 'min'
|
|
441
|
-
? sql`min(${target})`
|
|
442
|
-
: sql`max(${target})`;
|
|
443
|
-
return sql`select ${value}::text as agg_value, count(${target}) as agg_count from ${identifier(
|
|
444
|
-
entity.$table,
|
|
445
|
-
)} where ${conditions(entity, plan, shape)}`;
|
|
446
|
-
};
|
|
447
|
-
|
|
448
|
-
/** What an aggregate comes back as. Both names are fixed, so neither can be a column's. */
|
|
449
|
-
export interface AggregateRow {
|
|
450
|
-
readonly agg_value: unknown;
|
|
451
|
-
readonly agg_count: unknown;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* The distinct currencies among the rows an aggregate is about to cover. A separate statement
|
|
456
|
-
* rather than a clever one: `sum(minor)` over two currencies is a number in neither, and the only
|
|
457
|
-
* honest answer is to refuse — which needs the list, not a boolean.
|
|
458
|
-
*
|
|
459
|
-
* Bounded at three, because the refusal names them and a caller with three already knows.
|
|
460
|
-
*/
|
|
461
|
-
export const currenciesStatement = <Row>(
|
|
462
|
-
entity: EntityCore<Row>,
|
|
463
|
-
plan: QueryPlan,
|
|
464
|
-
shape: ReadShape,
|
|
465
|
-
currencyColumn: string,
|
|
466
|
-
scaleColumn: string | null,
|
|
467
|
-
): SqlFragment => {
|
|
468
|
-
const currency = identifier(currencyColumn);
|
|
469
|
-
// The SCALE is half of what makes two amounts incomparable and it is the half with no symptom:
|
|
470
|
-
// `{ minor: 5, currency: 'USD' }` is five cents and the same row at `scale: 6` is five millionths
|
|
471
|
-
// of a dollar. A table with no scale column has one unit per currency by construction.
|
|
472
|
-
const scale = scaleColumn === null ? sql`null` : identifier(scaleColumn);
|
|
473
|
-
return sql`select distinct ${currency} as group_value, ${scale} as group_scale from ${identifier(
|
|
474
|
-
entity.$table,
|
|
475
|
-
)} where ${conditions(entity, plan, shape)} and ${currency} is not null limit 3`;
|
|
476
|
-
};
|
|
477
|
-
|
|
478
|
-
/** One `(currency, scale)` pair the rows an aggregate covers actually use. */
|
|
479
|
-
export interface MoneyUnitRow {
|
|
480
|
-
readonly group_value: unknown;
|
|
481
|
-
readonly group_scale: unknown;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
/**
|
|
485
|
-
* The planner's own row estimate for a table — `reltuples`, which is what `ANALYZE` last wrote and
|
|
486
|
-
* what every query plan in the database is already costed against. `count(*)` walks every visible
|
|
487
|
-
* row (MVCC gives no shortcut), so on a large table it is the read that exceeds a web role's
|
|
488
|
-
* `statement_timeout`, and no index can make it cheaper: the `fix:` on that timeout tells an author
|
|
489
|
-
* to add one, and following it changes nothing.
|
|
490
|
-
*
|
|
491
|
-
* `to_regclass` rather than a name comparison, so a search_path change cannot silently answer for a
|
|
492
|
-
* different schema's table of the same name — and `-1` is what Postgres 14+ stores for a table that
|
|
493
|
-
* has never been analysed, which is an answer, not an estimate.
|
|
494
|
-
*/
|
|
495
|
-
export const estimateStatement = (table: string): SqlFragment =>
|
|
496
|
-
sql`select reltuples::bigint as estimate from pg_class where oid = to_regclass(${table})`;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The Postgres half of `Transactor`: one real transaction per `run`, the `Tx` a token. Split from
|
|
2
|
+
// `pg-driver.ts`, which is the repositories — the two meet only through `db()`, never an import.
|
|
3
|
+
|
|
4
|
+
import { type TransactionOptions, withTransaction } from '@ultimat3/db';
|
|
5
|
+
import type { Transactor } from './repo';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A real Postgres transaction behind the same `Transactor` the in-memory one implements. The
|
|
9
|
+
* `Tx` handed to the callback is a token: repositories find the transaction through `db()`, so
|
|
10
|
+
* nothing has to thread a connection through the call stack.
|
|
11
|
+
*/
|
|
12
|
+
export const postgresTransactor = (options: TransactionOptions = {}): Transactor => ({
|
|
13
|
+
run: (work) =>
|
|
14
|
+
withTransaction(
|
|
15
|
+
(tx) =>
|
|
16
|
+
work({
|
|
17
|
+
id: tx.id,
|
|
18
|
+
onRollback: (undo: () => void) => tx.onRollback(undo),
|
|
19
|
+
onCommit: (effect: () => void) => tx.onCommit(effect),
|
|
20
|
+
}),
|
|
21
|
+
options,
|
|
22
|
+
),
|
|
23
|
+
});
|
package/src/registry.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// than a silent last-one-wins.
|
|
5
5
|
|
|
6
6
|
import type { IndexMethod } from '@ultimat3/db';
|
|
7
|
+
import type { EntityCore } from './entity';
|
|
7
8
|
import { entityDuplicate } from './entity-error';
|
|
8
9
|
import type { InvariantKind } from './invariants';
|
|
9
10
|
import type { RecordProjection } from './record-projection';
|
|
@@ -133,6 +134,8 @@ export interface RegistryEntry {
|
|
|
133
134
|
readonly persist?: boolean;
|
|
134
135
|
/** The entity's client projection — what a changefeed's table maps to on the client. */
|
|
135
136
|
readonly projection?: RecordProjection;
|
|
137
|
+
/** The entity itself, for `entityForTable` — what decodes a raw row of this table. */
|
|
138
|
+
readonly core?: EntityCore<unknown>;
|
|
136
139
|
describe(): EntityDescription;
|
|
137
140
|
/**
|
|
138
141
|
* The foreign keys this entity declares, resolved. This is how a relation reaches query time:
|
|
@@ -157,6 +160,16 @@ export const registerEntity = <E extends RegistryEntry>(entry: E): E => {
|
|
|
157
160
|
|
|
158
161
|
export const getEntity = (name: string): RegistryEntry | undefined => entities.get(name);
|
|
159
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The entity declared on a PHYSICAL table, or `undefined`. What a change feed has in hand is a
|
|
165
|
+
* table name and raw columns; with this and `decodeRow` it gets the row the app declared, money
|
|
166
|
+
* and all — where `@ultimat3/realtime` had to guess money columns from their names.
|
|
167
|
+
*/
|
|
168
|
+
export const entityForTable = (table: string): EntityCore<unknown> | undefined => {
|
|
169
|
+
for (const entry of entities.values()) if (entry.tableName === table) return entry.core;
|
|
170
|
+
return undefined;
|
|
171
|
+
};
|
|
172
|
+
|
|
160
173
|
export const entityNames = (): readonly string[] => [...entities.keys()].sort();
|
|
161
174
|
|
|
162
175
|
/**
|
package/src/repo.ts
CHANGED
|
@@ -15,6 +15,11 @@ export interface Tx {
|
|
|
15
15
|
readonly id: string;
|
|
16
16
|
/** Registered by drivers so a failed transaction can undo in-memory effects. */
|
|
17
17
|
onRollback(undo: () => void): void;
|
|
18
|
+
/**
|
|
19
|
+
* Run once the transaction COMMITS, never on rollback. Optional so a hand-written `Tx` still
|
|
20
|
+
* satisfies the type; without it, work registered here runs immediately (the pre-22 behaviour).
|
|
21
|
+
*/
|
|
22
|
+
onCommit?(effect: () => void): void;
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
export interface RepoOptions {
|
package/src/row-observer.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// that decision.
|
|
15
15
|
|
|
16
16
|
import { currentWriteOrigin } from '@ultimat3/core';
|
|
17
|
-
import { expectedQueryLoop } from '@ultimat3/db';
|
|
17
|
+
import { currentTx, expectedQueryLoop } from '@ultimat3/db';
|
|
18
18
|
import type { EntityCore } from './entity';
|
|
19
19
|
import { MAX_PAGE_SIZE } from './plan';
|
|
20
20
|
import type { Repo, RepoOptions, UpsertArgs } from './repo';
|
|
@@ -184,20 +184,43 @@ const beforeAllOf = async <Row>(
|
|
|
184
184
|
export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Repo<Row> {
|
|
185
185
|
const name = entity.$name;
|
|
186
186
|
|
|
187
|
-
|
|
188
|
-
|
|
187
|
+
/**
|
|
188
|
+
* Reported at COMMIT. The write may sit in a transaction — the caller's `options.tx`, or the
|
|
189
|
+
* ambient `withTransaction` a Postgres repository joins — and a change reported before COMMIT
|
|
190
|
+
* put a row in a live query that a rollback then erased (plan 101, 06 m). Outside a transaction
|
|
191
|
+
* the write is already durable, so it is reported at once. The write origin is read NOW: it is
|
|
192
|
+
* the request's, and the commit callback may run outside that request's scope.
|
|
193
|
+
*/
|
|
194
|
+
const afterCommit = (options: RepoOptions | undefined, report: () => void): void => {
|
|
195
|
+
const tx: { onCommit?(effect: () => void): void } | undefined = options?.tx ?? currentTx();
|
|
196
|
+
if (tx?.onCommit === undefined) report();
|
|
197
|
+
else tx.onCommit(report);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const emit = (
|
|
201
|
+
op: RowChangeOp,
|
|
202
|
+
before: unknown,
|
|
203
|
+
after: unknown,
|
|
204
|
+
options: RepoOptions | undefined,
|
|
205
|
+
): void => {
|
|
206
|
+
const observer = installed;
|
|
207
|
+
if (observer === null) return;
|
|
189
208
|
const write = currentWriteOrigin();
|
|
190
|
-
|
|
209
|
+
const change: RowChange = {
|
|
191
210
|
entity: name,
|
|
192
211
|
op,
|
|
193
212
|
before: asRecord(before),
|
|
194
213
|
after: asRecord(after),
|
|
195
214
|
...(write === undefined ? {} : { write }),
|
|
196
|
-
}
|
|
215
|
+
};
|
|
216
|
+
afterCommit(options, () => observer.onChange(change));
|
|
197
217
|
};
|
|
198
218
|
|
|
199
|
-
const bulk = (op: 'delete' | 'update', rows: number): void => {
|
|
200
|
-
|
|
219
|
+
const bulk = (op: 'delete' | 'update', rows: number, options: RepoOptions | undefined): void => {
|
|
220
|
+
const observer = installed;
|
|
221
|
+
if (rows > 0 && observer?.onBulk !== undefined) {
|
|
222
|
+
afterCommit(options, () => observer.onBulk?.({ entity: name, op, rows }));
|
|
223
|
+
}
|
|
201
224
|
};
|
|
202
225
|
|
|
203
226
|
// Spread first, exactly as `examples/dummy`'s own capturing driver does: a repository may carry
|
|
@@ -208,13 +231,13 @@ export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Rep
|
|
|
208
231
|
|
|
209
232
|
insert: async (values: Row, options?: RepoOptions): Promise<Row> => {
|
|
210
233
|
const stored = await repo.insert(values, options);
|
|
211
|
-
emit('insert', null, stored);
|
|
234
|
+
emit('insert', null, stored, options);
|
|
212
235
|
return stored;
|
|
213
236
|
},
|
|
214
237
|
|
|
215
238
|
insertAll: async (rows: readonly Row[], options?: RepoOptions): Promise<readonly Row[]> => {
|
|
216
239
|
const stored = await repo.insertAll(rows, options);
|
|
217
|
-
for (const row of stored) emit('insert', null, row);
|
|
240
|
+
for (const row of stored) emit('insert', null, row, options);
|
|
218
241
|
return stored;
|
|
219
242
|
},
|
|
220
243
|
|
|
@@ -233,7 +256,7 @@ export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Rep
|
|
|
233
256
|
for (const row of stored) {
|
|
234
257
|
const id = idOf(row);
|
|
235
258
|
const previous = id === undefined ? null : (before.get(id) ?? null);
|
|
236
|
-
emit(previous === null ? 'insert' : 'update', previous, row);
|
|
259
|
+
emit(previous === null ? 'insert' : 'update', previous, row, args);
|
|
237
260
|
}
|
|
238
261
|
return stored;
|
|
239
262
|
},
|
|
@@ -242,7 +265,7 @@ export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Rep
|
|
|
242
265
|
if (installed === null) return await repo.update(id, patch, options);
|
|
243
266
|
const before = await beforeOf(entity, repo, id);
|
|
244
267
|
const after = await repo.update(id, patch, options);
|
|
245
|
-
emit('update', before, after);
|
|
268
|
+
emit('update', before, after, options);
|
|
246
269
|
return after;
|
|
247
270
|
},
|
|
248
271
|
|
|
@@ -250,12 +273,12 @@ export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Rep
|
|
|
250
273
|
if (installed === null) return await repo.delete(id, options);
|
|
251
274
|
const before = await beforeOf(entity, repo, id);
|
|
252
275
|
await repo.delete(id, options);
|
|
253
|
-
emit('delete', before, null);
|
|
276
|
+
emit('delete', before, null, options);
|
|
254
277
|
},
|
|
255
278
|
|
|
256
279
|
deleteWhere: async (filter: RowPatch<Row>, options?: RepoOptions): Promise<number> => {
|
|
257
280
|
const rows = await repo.deleteWhere(filter, options);
|
|
258
|
-
bulk('delete', rows);
|
|
281
|
+
bulk('delete', rows, options);
|
|
259
282
|
return rows;
|
|
260
283
|
},
|
|
261
284
|
|
|
@@ -265,7 +288,7 @@ export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Rep
|
|
|
265
288
|
options?: RepoOptions,
|
|
266
289
|
): Promise<number> => {
|
|
267
290
|
const rows = await repo.updateWhere(filter, patch, options);
|
|
268
|
-
bulk('update', rows);
|
|
291
|
+
bulk('update', rows, options);
|
|
269
292
|
return rows;
|
|
270
293
|
},
|
|
271
294
|
};
|
package/src/seed.ts
CHANGED
|
@@ -291,8 +291,14 @@ export const defineSeed = (
|
|
|
291
291
|
const found = await repo.findMany({ where: equalityPredicates(where), limit: 1 });
|
|
292
292
|
const stored = found.rows[0];
|
|
293
293
|
const preserve: readonly string[] = key.preserve ?? [CREATED_AT_COLUMN];
|
|
294
|
+
// Only what the CALLER named, and never a key the table generates: `$parse` fills a
|
|
295
|
+
// fresh uuid and a `defaultNow()` into `row`, which no stored row can equal — so a
|
|
296
|
+
// re-run reported every row `'updated'` and never once `'skipped'`.
|
|
294
297
|
const compared = Object.keys(row as Record<string, unknown>).filter(
|
|
295
|
-
(property) =>
|
|
298
|
+
(property) =>
|
|
299
|
+
!preserve.includes(property) &&
|
|
300
|
+
Object.hasOwn(values, property) &&
|
|
301
|
+
(!entity.$primaryKey.includes(property) || key.by.includes(property as never)),
|
|
296
302
|
);
|
|
297
303
|
if (
|
|
298
304
|
stored !== undefined &&
|
package/src/transition.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { EntityCore } from './entity';
|
|
|
8
8
|
import { notFound } from './errors';
|
|
9
9
|
import type { IllegalTransition } from './feature-errors';
|
|
10
10
|
import { stateConflict, stateTransitionIllegal, stateUndeclared } from './feature-errors';
|
|
11
|
+
import { singleKeyOf } from './plan';
|
|
11
12
|
import type { Repo, RepoOptions } from './repo';
|
|
12
13
|
import { canMove, isState, isTerminal, movesFrom, type StateMachine } from './state-machine';
|
|
13
14
|
import type { ColumnMap, IdOf, RowPatch } from './types';
|
|
@@ -109,7 +110,11 @@ export const transitionRow = async <Row, C extends ColumnMap>(
|
|
|
109
110
|
// an UNRESOLVED `Row`, so it never reduces and no object literal is ever assignable to it — the
|
|
110
111
|
// same reason `expr.ts` and `@ultimat3/query`'s `paginate` spell theirs the same way. The column
|
|
111
112
|
// name came from `machineFor`, which resolved it against the entity, so the shape is a real one.
|
|
112
|
-
|
|
113
|
+
// Keyed on the entity's OWN primary key, never the literal `id`: a `code`-keyed entity has no
|
|
114
|
+
// `id` column, so the filter matched nothing in memory (a false `X_STATE_CONFLICT`) and named a
|
|
115
|
+
// column Postgres does not have (`X_INVARIANT_VIOLATED`).
|
|
116
|
+
const key = singleKeyOf(entity, 'transition');
|
|
117
|
+
const filter = { [key]: id, [property]: move.from } as unknown as RowPatch<Row>;
|
|
113
118
|
const values = { [property]: move.to } as unknown as RowPatch<Row>;
|
|
114
119
|
const written = await repo.updateWhere(filter, patch(values), options);
|
|
115
120
|
if (written === 0) throw await diagnose(entity, repo, property, id, move, options);
|