@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.
@@ -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 { entityDuplicate } from './errors';
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 {