@ultimat3/entity 20.2.1 → 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 -1135
- package/README.md +56 -1
- package/package.json +7 -6
- 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-error.ts +126 -0
- package/src/entity.ts +30 -24
- package/src/errors.ts +13 -120
- package/src/index.ts +15 -9
- package/src/jit-preload.ts +19 -1
- package/src/memory-repo.ts +74 -7
- package/src/memory-unique.ts +51 -0
- package/src/persisted-types.ts +21 -0
- package/src/pg-driver.ts +9 -25
- 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/record-key.ts +59 -0
- package/src/record-projection.ts +88 -0
- package/src/record-table.ts +46 -0
- package/src/record.ts +10 -0
- package/src/registry.ts +19 -1
- package/src/repo.ts +5 -0
- package/src/row-observer.ts +51 -12
- package/src/row-schema.ts +63 -0
- package/src/rows-of.ts +132 -0
- package/src/seed.ts +7 -1
- package/src/transition.ts +6 -1
- package/src/write-tag.ts +75 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// A row's record key: its primary-key columns rendered to one string, in DECLARED order, so the
|
|
2
|
+
// client store keys one record once whatever order a row's own properties arrived in. Runs in the
|
|
3
|
+
// browser too (the store keys adopted rows with it), so it imports nothing a page cannot carry.
|
|
4
|
+
|
|
5
|
+
import { isFixShellSafe, type Row, renderFixShellArg } from '@ultimat3/core';
|
|
6
|
+
import { describeValue } from '@ultimat3/schema';
|
|
7
|
+
import { EntityError } from './entity-error';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A row reached a record key without one of its primary-key columns — a handler built the row by
|
|
11
|
+
* hand, or a partial row was handed where a whole one was declared. Refused, never keyed as
|
|
12
|
+
* `undefined`: two keyless rows would be ONE record in the store and overwrite each other.
|
|
13
|
+
*
|
|
14
|
+
* The value is described by SHAPE, never echoed: a key is the caller's data, and this cause
|
|
15
|
+
* reaches the log line.
|
|
16
|
+
*/
|
|
17
|
+
export const recordKeyMissing = (type: string, column: string, value: unknown): EntityError =>
|
|
18
|
+
new EntityError({
|
|
19
|
+
code: 'X_RECORD_KEY_MISSING',
|
|
20
|
+
cause: `a ${type} row reached its record key with primary-key column "${column}" holding ${describeValue(value)}, not a string, number, bigint, boolean or Date`,
|
|
21
|
+
// Screened, never spliced: an entity NAME is only checked as an identifier when it is also the
|
|
22
|
+
// table, so a declared name carrying shell syntax degrades to prose rather than a command. The
|
|
23
|
+
// `renderFixShellArg` inside the safe branch is verbatim there; it is the call
|
|
24
|
+
// `bun run fix-shell-arg` recognises as the screen.
|
|
25
|
+
fix: isFixShellSafe(type)
|
|
26
|
+
? `x entities describe ${renderFixShellArg(type, 'ENTITY')} --json # lists the primary key; return the whole row (every primary-key column) from the handler that built this one`
|
|
27
|
+
: 'x entities list --json # find this entity, then return the whole row (every primary-key column) from the handler that built this one',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** One part of a key. `Object.hasOwn`, never `row[column]` alone: an inherited member is no key. */
|
|
31
|
+
const partOf = (type: string, row: Row, column: string): string => {
|
|
32
|
+
const value = Object.hasOwn(row, column) ? row[column] : undefined;
|
|
33
|
+
switch (typeof value) {
|
|
34
|
+
case 'string':
|
|
35
|
+
return value;
|
|
36
|
+
case 'number':
|
|
37
|
+
if (Number.isFinite(value)) return String(value);
|
|
38
|
+
break;
|
|
39
|
+
case 'bigint':
|
|
40
|
+
case 'boolean':
|
|
41
|
+
return String(value);
|
|
42
|
+
default:
|
|
43
|
+
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();
|
|
44
|
+
}
|
|
45
|
+
throw recordKeyMissing(type, column, value);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A single key is the value itself, so it is the same string `$tagFor(id)` and every realtime
|
|
50
|
+
* topic already carry. A composite key percent-encodes each part before joining on `:`, so a part
|
|
51
|
+
* holding the separator cannot make two different rows one key.
|
|
52
|
+
*/
|
|
53
|
+
export const recordKeyOf =
|
|
54
|
+
(type: string, primaryKey: readonly string[]) =>
|
|
55
|
+
(row: Row): string => {
|
|
56
|
+
const [only, ...rest] = primaryKey;
|
|
57
|
+
if (only !== undefined && rest.length === 0) return partOf(type, row, only);
|
|
58
|
+
return primaryKey.map((column) => encodeURIComponent(partOf(type, row, column))).join(':');
|
|
59
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// An entity's client projection — record type, record key, row node, persist — and the brand that
|
|
2
|
+
// carries it on the entity's row schema. Value-light on purpose: the browser store imports this,
|
|
3
|
+
// and nothing here may reach the Postgres driver.
|
|
4
|
+
|
|
5
|
+
import type { Row } from '@ultimat3/core';
|
|
6
|
+
import { isSchemaNode, type SchemaNode } from '@ultimat3/schema';
|
|
7
|
+
import { invariantViolated } from './entity-error';
|
|
8
|
+
import { recordKeyOf } from './record-key';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `Symbol.for`, not `Symbol()`: islands are separate bundles, so a row schema declared in one and
|
|
12
|
+
* walked in another holds a brand minted by a different copy of this module. Only the registry key
|
|
13
|
+
* is the same symbol in both.
|
|
14
|
+
*/
|
|
15
|
+
export const ENTITY_BRAND: unique symbol = Symbol.for('ultimate.entity');
|
|
16
|
+
|
|
17
|
+
export interface RecordProjection {
|
|
18
|
+
/** The wire name — the entity's name, the framework's key for it everywhere else too. */
|
|
19
|
+
readonly type: string;
|
|
20
|
+
/**
|
|
21
|
+
* The physical relation. A changefeed and a snapshot speak TABLES; `recordTypeForTable` is how
|
|
22
|
+
* they reach `type`, and this is the same fact read from the other end.
|
|
23
|
+
*/
|
|
24
|
+
readonly table: string;
|
|
25
|
+
/** Primary key → string, in declared order. Throws `X_RECORD_KEY_MISSING`. */
|
|
26
|
+
readonly key: (row: Row) => string;
|
|
27
|
+
/** The row's schema IR — JSON, so a store can put it on disk beside the rows. */
|
|
28
|
+
readonly schema: SchemaNode;
|
|
29
|
+
/**
|
|
30
|
+
* Whether the client keeps this entity's records on disk — `entity(name, { persist: true })`,
|
|
31
|
+
* default `false`. Realtime's persister reads it here, never off the declaration.
|
|
32
|
+
*/
|
|
33
|
+
readonly persist: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What `entity()` hands the projection: everything but the row node, which the schema builds. */
|
|
37
|
+
export interface ProjectionIdentity {
|
|
38
|
+
readonly name: string;
|
|
39
|
+
readonly table: string;
|
|
40
|
+
readonly primaryKey: readonly string[];
|
|
41
|
+
readonly persist: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Built once per `entity()`, frozen: the brand, `recordProjection` and `rowsOf` share one object. */
|
|
45
|
+
export const createProjection = (
|
|
46
|
+
{ name, table, primaryKey, persist }: ProjectionIdentity,
|
|
47
|
+
schema: SchemaNode,
|
|
48
|
+
): RecordProjection =>
|
|
49
|
+
Object.freeze({ type: name, table, key: recordKeyOf(name, primaryKey), schema, persist });
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The projection a schema node is branded with, or `undefined`. Own and non-enumerable: an
|
|
53
|
+
* enumerable symbol would ride every spread and `toEqual` of the IR, and an inherited one would
|
|
54
|
+
* brand every node built from a branded prototype.
|
|
55
|
+
*/
|
|
56
|
+
export const projectionOf = (node: unknown): RecordProjection | undefined => {
|
|
57
|
+
if (!isSchemaNode(node) || !Object.hasOwn(node, ENTITY_BRAND)) return undefined;
|
|
58
|
+
return (node as { readonly [ENTITY_BRAND]?: RecordProjection })[ENTITY_BRAND];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Brands `node` in place. Called by the row schema on its own node and on each wrapper's. */
|
|
62
|
+
export const brandNode = (node: SchemaNode, projection: RecordProjection): void => {
|
|
63
|
+
Object.defineProperty(node, ENTITY_BRAND, { value: projection, enumerable: false });
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** The structural slice of an entity this reads — so it never imports `entity.ts`. */
|
|
67
|
+
export interface ProjectedEntity {
|
|
68
|
+
readonly $name: string;
|
|
69
|
+
readonly $schema: { readonly node: SchemaNode };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `recordProjection(posts)` — the one answer to "what is this entity on the client". Read off the
|
|
74
|
+
* brand rather than rebuilt, so the store, the envelope and `rowsOf` hold the same object.
|
|
75
|
+
*/
|
|
76
|
+
export const recordProjection = (entity: ProjectedEntity): RecordProjection => {
|
|
77
|
+
const projection = projectionOf(entity.$schema.node);
|
|
78
|
+
// Every `entity()` brands its row schema, so absence means a hand-built lookalike: the type is
|
|
79
|
+
// `EntityCore`, and the only way here without a brand is a cast.
|
|
80
|
+
if (projection === undefined) {
|
|
81
|
+
throw invariantViolated(
|
|
82
|
+
entity.$name,
|
|
83
|
+
'$schema',
|
|
84
|
+
'carries no record brand; declare it with entity()',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return projection;
|
|
88
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Table → record type, over the registered entities. A changefeed and a snapshot name the
|
|
2
|
+
// physical relation; the client store keys records by entity name, and this is the one bridge —
|
|
3
|
+
// memoised against the registry's generation, so a per-change lookup is one `Map` read.
|
|
4
|
+
|
|
5
|
+
import { invariantViolated } from './entity-error';
|
|
6
|
+
import type { RecordProjection } from './record-projection';
|
|
7
|
+
import type { RegistryEntry } from './registry';
|
|
8
|
+
import { registeredEntities, registryGeneration } from './registry';
|
|
9
|
+
|
|
10
|
+
let cached: {
|
|
11
|
+
readonly generation: number;
|
|
12
|
+
readonly byTable: ReadonlyMap<string, RegistryEntry>;
|
|
13
|
+
} | null = null;
|
|
14
|
+
|
|
15
|
+
const tableIndex = (): ReadonlyMap<string, RegistryEntry> => {
|
|
16
|
+
const generation = registryGeneration();
|
|
17
|
+
if (cached !== null && cached.generation === generation) return cached.byTable;
|
|
18
|
+
const byTable = new Map<string, RegistryEntry>();
|
|
19
|
+
for (const entry of registeredEntities()) {
|
|
20
|
+
const other = byTable.get(entry.tableName);
|
|
21
|
+
// Two entities over one relation (`table:` lets an app adopt a table twice) leave a changed
|
|
22
|
+
// row with no single record type; refused rather than guessed, since the wrong guess writes
|
|
23
|
+
// one entity's row into another's records.
|
|
24
|
+
if (other !== undefined) {
|
|
25
|
+
throw invariantViolated(
|
|
26
|
+
entry.name,
|
|
27
|
+
'table',
|
|
28
|
+
`shares table "${entry.tableName}" with entity "${other.name}", so a change on it has no one record type — give one of them its own table, or stop streaming it`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
byTable.set(entry.tableName, entry);
|
|
32
|
+
}
|
|
33
|
+
cached = { generation, byTable };
|
|
34
|
+
return byTable;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The record type (entity name) a table's rows belong to, or `undefined` for an unknown table. */
|
|
38
|
+
export const recordTypeForTable = (table: string): string | undefined =>
|
|
39
|
+
tableIndex().get(table)?.name;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The whole client projection a table's rows belong to — type, key, row schema, `persist` — or
|
|
43
|
+
* `undefined` for an unknown table. Same index as `recordTypeForTable`, so the two never disagree.
|
|
44
|
+
*/
|
|
45
|
+
export const recordProjectionForTable = (table: string): RecordProjection | undefined =>
|
|
46
|
+
tableIndex().get(table)?.projection;
|
package/src/record.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// `@ultimat3/entity/record` — the entity's client projection, and nothing that reaches a driver.
|
|
2
|
+
// The package barrel re-exports the same bindings for server code; a BROWSER module imports them
|
|
3
|
+
// from here, because the barrel retains ~1 MB of SQL rendering (`pg-sql.ts`, `@ultimat3/db`) a page
|
|
4
|
+
// never runs — measured in `record-bundle.test.ts`, which fails if this entry ever grows it back.
|
|
5
|
+
|
|
6
|
+
export type { ProjectedEntity, RecordProjection } from './record-projection';
|
|
7
|
+
export { ENTITY_BRAND, recordProjection } from './record-projection';
|
|
8
|
+
export { recordProjectionForTable, recordTypeForTable } from './record-table';
|
|
9
|
+
export type { RecordsByKey, RecordsByType } from './rows-of';
|
|
10
|
+
export { hasEntityRows, rowsOf } from './rows-of';
|
package/src/registry.ts
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// than a silent last-one-wins.
|
|
5
5
|
|
|
6
6
|
import type { IndexMethod } from '@ultimat3/db';
|
|
7
|
-
import {
|
|
7
|
+
import type { EntityCore } from './entity';
|
|
8
|
+
import { entityDuplicate } from './entity-error';
|
|
8
9
|
import type { InvariantKind } from './invariants';
|
|
10
|
+
import type { RecordProjection } from './record-projection';
|
|
9
11
|
import type { ColumnDefault, OnDelete } from './types';
|
|
10
12
|
|
|
11
13
|
export interface ColumnDescription {
|
|
@@ -128,6 +130,12 @@ export interface EntityDescription {
|
|
|
128
130
|
export interface RegistryEntry {
|
|
129
131
|
readonly name: string;
|
|
130
132
|
readonly tableName: string;
|
|
133
|
+
/** `entity(name, { persist: true })` — the client keeps this type on disk. Absent = `false`. */
|
|
134
|
+
readonly persist?: boolean;
|
|
135
|
+
/** The entity's client projection — what a changefeed's table maps to on the client. */
|
|
136
|
+
readonly projection?: RecordProjection;
|
|
137
|
+
/** The entity itself, for `entityForTable` — what decodes a raw row of this table. */
|
|
138
|
+
readonly core?: EntityCore<unknown>;
|
|
131
139
|
describe(): EntityDescription;
|
|
132
140
|
/**
|
|
133
141
|
* The foreign keys this entity declares, resolved. This is how a relation reaches query time:
|
|
@@ -152,6 +160,16 @@ export const registerEntity = <E extends RegistryEntry>(entry: E): E => {
|
|
|
152
160
|
|
|
153
161
|
export const getEntity = (name: string): RegistryEntry | undefined => entities.get(name);
|
|
154
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
|
+
|
|
155
173
|
export const entityNames = (): readonly string[] => [...entities.keys()].sort();
|
|
156
174
|
|
|
157
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 {
|