@ultimat3/entity 11.3.0 → 13.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,129 @@
1
+ // Single responsibility: compile a WRITE into parameterised SQL — insert, upsert, update, delete.
2
+ // Split from `pg-sql.ts` when that file passed the 500-line ceiling: "which rows does this read
3
+ // describe" and "what does this write put there" are two jobs, and only the second one needs to
4
+ // know what a conflict target or a jsonb cell is.
5
+ //
6
+ // The same rule holds on both sides of the split and it is the reason either file exists: nothing
7
+ // is interpolated. `sql` binds every scalar and every identifier is resolved through the entity,
8
+ // so a column name can only ever be one the entity declared. `raw()` appears once here, for the
9
+ // `default` cell of a many-row `values` list — a closed set of one word, written in this file and
10
+ // never derived from a value.
11
+
12
+ import { identifier, join, raw, type SqlFragment, sql } from '@ultimat3/db';
13
+ import { columnName } from './column';
14
+ import type { EntityCore } from './entity';
15
+ import { conditions, type ReadShape } from './pg-sql';
16
+ import type { QueryPlan } from './tenancy';
17
+
18
+ /** `on conflict (…) do update set …`, or `do nothing` when there is nothing to overwrite. */
19
+ export interface ConflictTarget {
20
+ /** Physical columns of the unique index a collision is judged against. */
21
+ readonly columns: readonly string[];
22
+ /** Physical columns a colliding row takes from the incoming one. Empty is `do nothing`. */
23
+ readonly set: readonly string[];
24
+ }
25
+
26
+ export interface InsertShape {
27
+ /** Every physical column written — one list, shared by every row of the statement. */
28
+ readonly columns: readonly string[];
29
+ /** How a collision resolves. Absent, it is the caller's error, exactly as it is for one row. */
30
+ readonly conflict?: ConflictTarget | undefined;
31
+ }
32
+
33
+ /**
34
+ * The cell of a row that did not name this column. `default` is the second and last `raw()` in
35
+ * this file and, like `asc|desc` above it, a closed set of one word: it is what makes a row inside
36
+ * a many-row `insert` mean what the same row means on its own, where an unnamed column is simply
37
+ * left out. The seek operator used to be a third — it is chosen in TypeScript now
38
+ * (`seekAfter`/`seekEqual`), because a timestamp seek is not one operator.
39
+ */
40
+ const DEFAULT_CELL = raw('default');
41
+
42
+ const conflictSql = (conflict: ConflictTarget): SqlFragment => {
43
+ const target = join(conflict.columns.map(identifier));
44
+ return conflict.set.length === 0
45
+ ? sql` on conflict (${target}) do nothing`
46
+ : sql` on conflict (${target}) do update set ${join(
47
+ conflict.set.map((column) => sql`${identifier(column)} = excluded.${identifier(column)}`),
48
+ )}`;
49
+ };
50
+
51
+ /**
52
+ * The one column that cannot be bound as itself. A `jsonb` value is a plain object, and the
53
+ * driver seam refuses one as a parameter (`X_SQL_UNSAFE` — `isBoundValue` takes scalars, a `Date`,
54
+ * a `Uint8Array` and arrays of those); so `bindValues` hands over the JSON TEXT and the cell says
55
+ * what to do with it.
56
+ *
57
+ * `::text::jsonb` and not `::jsonb`, and the double cast is load-bearing rather than defensive.
58
+ * Measured against Postgres 17.10 through Bun's `sql`: with `$1::jsonb` the server describes the
59
+ * parameter as `jsonb`, the client JSON-ENCODES the string it was given, and `{"a":1}` is stored
60
+ * as the JSON *string* `"{\"a\":1}"` — `jsonb_typeof` says `string`. Pinning the parameter to
61
+ * `text` first makes the client send the characters and the server parse them, which is the one
62
+ * spelling that stores an object.
63
+ */
64
+ /** Physical names of this entity's `jsonb` columns. Resolved ONCE per statement, never per cell. */
65
+ const jsonColumns = <Row>(entity: EntityCore<Row>): ReadonlySet<string> => {
66
+ const names = new Set<string>();
67
+ for (const [property, column] of Object.entries(entity.$columns)) {
68
+ if (column.$meta.kind === 'jsonb') names.add(columnName(property, column.$meta));
69
+ }
70
+ return names;
71
+ };
72
+
73
+ /**
74
+ * `${value}`, plus the cast that column needs. The `raw()` argument is a literal written here and
75
+ * nowhere else — the audit point that call is stays a two-word constant, never a value.
76
+ */
77
+ const cell = (json: ReadonlySet<string>, column: string, value: unknown): SqlFragment =>
78
+ json.has(column) ? sql`${value}${raw('::text::jsonb')}` : sql`${value}`;
79
+
80
+ /**
81
+ * One statement for any number of rows. A single row compiles to exactly the text it always did,
82
+ * which is the point: `insertAll([row])` and `insert(row)` are one code path, so there is no
83
+ * second insert builder for the two to drift apart in.
84
+ */
85
+ export const insertStatement = <Row>(
86
+ entity: EntityCore<Row>,
87
+ rows: readonly ReadonlyMap<string, unknown>[],
88
+ shape: InsertShape,
89
+ ): SqlFragment => {
90
+ const json = jsonColumns(entity);
91
+ const tuples = rows.map(
92
+ (row) =>
93
+ sql`(${join(
94
+ shape.columns.map((column) =>
95
+ row.has(column) ? cell(json, column, row.get(column)) : DEFAULT_CELL,
96
+ ),
97
+ )})`,
98
+ );
99
+ const conflict = shape.conflict === undefined ? sql`` : conflictSql(shape.conflict);
100
+ return sql`insert into ${identifier(entity.$table)} (${join(
101
+ shape.columns.map(identifier),
102
+ )}) values ${join(tuples)}${conflict} returning *`;
103
+ };
104
+
105
+ /**
106
+ * `returning` is a parameter and has no default, because the three callers want three different
107
+ * answers and the wrong one is not visible in the result: `update(id, patch)` needs the stored row,
108
+ * a soft delete and a filtered write need a count, and `returning *` on a filtered write over a
109
+ * whole tenant streams every matched row into the process for nobody to read. A default would make
110
+ * that the quiet case.
111
+ */
112
+ export const updateStatement = <Row>(
113
+ entity: EntityCore<Row>,
114
+ plan: QueryPlan,
115
+ values: ReadonlyMap<string, unknown>,
116
+ shape: ReadShape,
117
+ returning: boolean,
118
+ ): SqlFragment => {
119
+ const json = jsonColumns(entity);
120
+ return sql`update ${identifier(entity.$table)} set ${join(
121
+ [...values].map(([column, value]) => sql`${identifier(column)} = ${cell(json, column, value)}`),
122
+ )} where ${conditions(entity, plan, shape)}${returning ? sql` returning *` : sql``}`;
123
+ };
124
+
125
+ /** Only reached when the entity has no soft-delete column, so there is no filter to apply. */
126
+ export const deleteStatement = <Row>(entity: EntityCore<Row>, plan: QueryPlan): SqlFragment =>
127
+ sql`delete from ${identifier(entity.$table)} where ${conditions(entity, plan, {
128
+ includeDeleted: true,
129
+ })}`;
package/src/plan.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // which rows are in scope, what the total sort order is, how big a page is. A guard only one
4
4
  // driver applies is worse than none: the test passes and production leaks another tenant's rows.
5
5
 
6
+ import { assertSeekable } from './cursor';
6
7
  import type { EntityCore } from './entity';
7
8
  import { EntityError, invariantViolated, patchEmpty, writeUnfiltered } from './errors';
8
9
  import type { FindManyArgs, RepoOptions } from './repo';
@@ -49,19 +50,32 @@ export const singleKeyOf = <Row>(entity: EntityCore<Row>, operation: string): st
49
50
  * The primary key is always the final key — a cursor needs a total order, or two rows with the same
50
51
  * sort value straddle a page boundary.
51
52
  *
53
+ * The tiebreak takes the LAST DECLARED key's direction rather than an unconditional `asc`, and
54
+ * that is not a preference. `IndexInit.order` is ONE direction for a whole index, so
55
+ * `orderBy('createdAt', 'desc')` used to run `created_at desc, id asc` — an order this framework's
56
+ * own DSL cannot declare an index for, whatever the author wrote. It also decided the seek's
57
+ * shape: a mixed order has no row comparison, so the seek fell to the or-chain, which measured on
58
+ * Postgres 16 as a BitmapOr plus a Sort where `(created_at, id) < ($1, $2)` is an Index Only Scan.
59
+ * A caller who wants the mixed order still writes it — naming the key itself is what turns the
60
+ * append off.
61
+ *
52
62
  * Exported because a chain can be judged before it runs: `inBatches()` refuses an ordering that
53
- * cannot carry a cursor, and it has to be looking at the order the driver will send rather than at
54
- * the one the caller typed.
63
+ * cannot carry a cursor an undeclared column, or a nullable key in the TIEBREAK, never an
64
+ * ordinary nullable one — and it has to be looking at the order the driver will send rather than
65
+ * at the one the caller typed.
55
66
  */
56
67
  export const totalOrder = <Row>(
57
68
  entity: EntityCore<Row>,
58
69
  ordered: readonly SortKey[],
59
- ): readonly SortKey[] => [
60
- ...ordered,
61
- ...entity.$primaryKey
62
- .filter((property) => !ordered.some((entry) => entry.column === property))
63
- .map((property) => ({ column: property, direction: 'asc' as const })),
64
- ];
70
+ ): readonly SortKey[] => {
71
+ const direction = ordered.at(-1)?.direction ?? 'asc';
72
+ return [
73
+ ...ordered,
74
+ ...entity.$primaryKey
75
+ .filter((property) => !ordered.some((entry) => entry.column === property))
76
+ .map((property) => ({ column: property, direction })),
77
+ ];
78
+ };
65
79
 
66
80
  /**
67
81
  * What a page size has to be before a statement carries it: rows, whole, at least one and at most
@@ -83,8 +97,20 @@ export const assertPageSize = (entityName: string, rows: number): void => {
83
97
  });
84
98
  };
85
99
 
100
+ /**
101
+ * Whether an ordering can carry a page position is a property of the ORDER, so it is decided here
102
+ * — where the order the driver will send is first known — and not in `cursorFor`, which runs only
103
+ * when a page found one row past its limit. That is what made the refusal depend on the table:
104
+ * `orderBy('publishedAt', 'desc').limit(20)` over a nullable column was green on fifteen seeded
105
+ * rows for as long as the test suite existed and `X_INVARIANT_VIOLATED` on the first read past
106
+ * twenty in production. `assertBatchable` has always judged `inBatches()` this way.
107
+ *
108
+ * `cursorFor` and `seekFrom` still call it: they are reached from a driver directly.
109
+ */
86
110
  export const planFor = <Row>(entity: EntityCore<Row>, args: FindManyArgs): QueryPlan => {
87
111
  if (args.limit !== undefined) assertPageSize(entity.$name, args.limit);
112
+ const orderBy = totalOrder(entity, args.orderBy ?? []);
113
+ assertSeekable(entity, orderBy);
88
114
  const scoped =
89
115
  args.orgId === undefined || entity.$tenantColumn === null
90
116
  ? []
@@ -92,7 +118,7 @@ export const planFor = <Row>(entity: EntityCore<Row>, args: FindManyArgs): Query
92
118
  return {
93
119
  entity: entity.$name,
94
120
  where: [...(args.where ?? []), ...scoped],
95
- orderBy: totalOrder(entity, args.orderBy ?? []),
121
+ orderBy,
96
122
  limit: args.limit ?? DEFAULT_PAGE_SIZE,
97
123
  ...(args.cursor === undefined || args.cursor === null ? {} : { cursor: args.cursor }),
98
124
  ...(args.select === undefined ? {} : { select: args.select }),
@@ -109,7 +135,42 @@ export const readPlan = <Row>(
109
135
  entity: EntityCore<Row>,
110
136
  args: FindManyArgs,
111
137
  operation: string,
112
- ): QueryPlan => scopedPlan(entity.$name, entity.$tenantColumn, operation, planFor(entity, args));
138
+ ): QueryPlan => {
139
+ const plan = planFor(entity, args);
140
+ // BEFORE tenancy, deliberately. Applied after, a tenant-scoped entity had no reachable call at
141
+ // all: unscoped it was `X_TENANCY_UNSCOPED` and scoped it was the refusal below, so the method
142
+ // was declared and unusable — the defect class this repo keeps re-shipping. One refusal, and it
143
+ // names which of the two situations the caller is in.
144
+ if (operation === 'approximateCount') assertEstimable(entity, plan);
145
+ return scopedPlan(entity.$name, entity.$tenantColumn, operation, plan);
146
+ };
147
+
148
+ /**
149
+ * An estimate is the TABLE's. `reltuples` knows nothing about a predicate, so a filtered chain
150
+ * asking for one would be answered a different question from the one it asked — and a caller
151
+ * reading `posts.where({ orgId }).approximateCount()` as "roughly how many of mine" would be
152
+ * handed every other tenant's rows too, which is the reading that matters.
153
+ *
154
+ * A TENANT-SCOPED entity is therefore refused outright, filters or none: its whole-table estimate
155
+ * is a number about every tenant, and a per-tenant row count is not a thing the planner holds.
156
+ * That refusal is not a limitation of this method, it is what the method means.
157
+ */
158
+ const assertEstimable = <Row>(entity: EntityCore<Row>, plan: QueryPlan): void => {
159
+ if (entity.$tenantColumn !== null) {
160
+ throw new EntityError({
161
+ code: 'X_APPROXIMATE_COUNT_FILTERED',
162
+ cause: `${entity.$name}.approximateCount() is a whole-TABLE estimate and ${entity.$name} is scoped by ${entity.$tenantColumn} — the planner holds one number for every tenant together`,
163
+ fix: `${entity.$name}.count() # the exact answer, scoped to the acting actor's tenant as every other read is`,
164
+ });
165
+ }
166
+ if (plan.where.length === 0) return;
167
+ const named = plan.where.map((each) => each.column).join(', ');
168
+ throw new EntityError({
169
+ code: 'X_APPROXIMATE_COUNT_FILTERED',
170
+ cause: `${entity.$name}.approximateCount() carries ${plan.where.length} predicate(s) (${named}) — the planner estimates the TABLE and knows nothing about them`,
171
+ fix: `${entity.$name}.count() # the exact answer for a filtered chain; approximateCount() answers for the whole table only`,
172
+ });
173
+ };
113
174
 
114
175
  /**
115
176
  * The plan for an id-addressed write. A write is a query too: without the same guard,
package/src/query.ts CHANGED
@@ -8,14 +8,17 @@ import type { BatchIterator } from './batch';
8
8
  import { assertBatchable, batchIterator } from './batch';
9
9
  import { entityNow } from './clock';
10
10
  import type { EntityCore } from './entity';
11
+ import { searchUndeclared } from './feature-errors';
11
12
  import { assertPageSize, DEFAULT_PAGE_SIZE, namedColumns } from './plan';
12
13
  import type { RelatedTables } from './preload';
13
14
  import { preloaded } from './preload';
14
15
  import type { Relation } from './relations';
15
16
  import { relationNamed } from './relations';
16
17
  import type { Page, Repo, RepoOptions, UpsertArgs } from './repo';
18
+ import { SEARCH_PROPERTY } from './search';
17
19
  import type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
18
- import type { ColumnMap, IdOf, Insertable, RowPatch } from './types';
20
+ import { transitionRow } from './transition';
21
+ import type { ColumnMap, IdOf, Insertable, MoneyValue, RowPatch } from './types';
19
22
 
20
23
  /**
21
24
  * What a preloaded relation adds to a row. `unknown` because the name is a string resolved at
@@ -28,6 +31,23 @@ export interface ReadBuilder<Row> {
28
31
  /** Equality on the columns given. `where({ orgId })` is what satisfies the tenancy guard. */
29
32
  where(filter: RowPatch<Row>): ReadBuilder<Row>;
30
33
  andWhere(column: keyof Row & string, op: Operator, value: unknown): ReadBuilder<Row>;
34
+ /**
35
+ * Full-text search over the entity's generated `tsvector` — every `.searchable()` column at
36
+ * once, one GIN index, one predicate. `posts.where({ orgId }).search(input.q).limit(20).page()`.
37
+ *
38
+ * `term` is USER TEXT and is treated as such end to end: it crosses as a bound parameter and is
39
+ * parsed by `websearch_to_tsquery`, so `&`, `|`, `!`, `:*` and an unbalanced paren are characters
40
+ * to be matched, never operators and never a syntax error. There is deliberately no way to hand
41
+ * this layer a tsquery — a query language inside the query language is a second way to ask, and
42
+ * the one caller that would want it is the injection this method exists to make unreachable.
43
+ *
44
+ * It is an ordinary predicate, so the chain's tenancy, soft delete, projection, order, cursor
45
+ * and page size all mean here exactly what they mean without it. RELEVANCE is not an order this
46
+ * chain can serve: `ts_rank` is a computed value and the cursor carries columns, so the order
47
+ * stays the one the caller declared. An entity with no searchable column is `X_SEARCH_UNDECLARED`
48
+ * and the in-memory driver is `X_SEARCH_IN_MEMORY` — never a different answer from the two.
49
+ */
50
+ search(term: string): ReadBuilder<Row>;
31
51
  orderBy(column: keyof Row & string, direction?: SortDirection): ReadBuilder<Row>;
32
52
  limit(rows: number): ReadBuilder<Row>;
33
53
  /** The cursor from the previous page. */
@@ -59,8 +79,12 @@ export interface ReadBuilder<Row> {
59
79
  *
60
80
  * The loop closes it: `break`, `return` and a throw all stop the next statement, and
61
81
  * `await using` does the same for a handle kept in a variable. A chain that cannot carry a
62
- * cursor — a nullable sort column — and a chain that also called `limit()` are refused here,
63
- * not one batch later.
82
+ * cursor and a chain that also called `limit()` are refused here, not one batch later. An
83
+ * ORDINARY nullable sort column is not one of those: it orders `nulls last` ascending and
84
+ * `nulls first` descending, and the cursor carries that position. What is refused is a sort key
85
+ * that leaves the order un-total — an undeclared column, a money property named without its
86
+ * part, or a nullable PRIMARY-KEY column, where `null = null` is unknown and two such rows are
87
+ * one position to the seek (`cursor.ts`'s `assertSeekable`).
64
88
  */
65
89
  inBatches(size: number): BatchIterator<Row>;
66
90
  /** The terminal: one bounded page and the cursor that continues it. */
@@ -83,11 +107,73 @@ export interface ReadBuilder<Row> {
83
107
  * jsonb, money), and a chain matching more distinct values than one statement should answer with.
84
108
  */
85
109
  countBy<K extends keyof Row & string>(column: K): Promise<ReadonlyMap<Row[K], number>>;
110
+ /**
111
+ * The four SQL aggregates, over exactly the rows `count()` counts — the chain's filters, its
112
+ * tenancy and its soft-delete visibility, never its page. "Total spend this month" is
113
+ * `payments.where({ orgId }).andWhere('paidAt', 'gte', from).sum('amount')`, one statement,
114
+ * rather than a page loop or a hand-written query outside every guard this layer applies.
115
+ *
116
+ * **Never a float.** `sum` and `avg` answer decimal TEXT whatever the column was — the sum of a
117
+ * million `integer` rows is not an `integer` and `Number()` on it loses digits past 2^53 — and a
118
+ * money column answers a `MoneyValue` in integer minor units. `min`/`max` answer the row's own
119
+ * type, because the answer is one of the values that went in.
120
+ *
121
+ * `null` for an empty set in every one of them, which is what SQL answers: a `0` would claim
122
+ * rows were seen.
123
+ *
124
+ * Refused rather than answered: a kind with no aggregate the two drivers can agree on (`text`
125
+ * ordering is the database's collation here and JS code-unit order there), `avg` over money
126
+ * (every answer would be a silent rounding of a fraction of a minor unit), and an amount
127
+ * covering more than one currency or scale.
128
+ */
129
+ sum<K extends keyof Row & string>(
130
+ column: K,
131
+ ): Promise<(Row[K] extends MoneyValue | null ? MoneyValue : string) | null>;
132
+ avg<K extends keyof Row & string>(column: K): Promise<string | null>;
133
+ min<K extends keyof Row & string>(column: K): Promise<Row[K] | null>;
134
+ max<K extends keyof Row & string>(column: K): Promise<Row[K] | null>;
135
+ /**
136
+ * The planner's own row estimate for the table — `reltuples`, one row out of `pg_class`, and the
137
+ * only count that stays constant time as the table grows. `count()` walks every visible row
138
+ * because MVCC gives it no shortcut, so past a few million it is the read that trips a web
139
+ * role's `statement_timeout` and no index can make it cheaper.
140
+ *
141
+ * The whole TABLE, never the chain's filters — a filtered chain is `X_APPROXIMATE_COUNT_FILTERED`
142
+ * rather than an estimate that answers a different question than the one asked. `null` when the
143
+ * table has never been analysed, which is the absence of an estimate and not an estimate of zero.
144
+ */
145
+ approximateCount(): Promise<number | null>;
86
146
  /** The plan this chain describes. Safe to log — `describePlan()` elides values. */
87
147
  plan(): QueryPlan;
88
148
  }
89
149
 
90
150
  export interface Table<Row, C extends ColumnMap = ColumnMap> extends ReadBuilder<Row> {
151
+ /**
152
+ * Move one row through the state machine `column` declares, in ONE statement.
153
+ *
154
+ * `from` is the state the caller believes the row is in, and it rides in the statement's own
155
+ * predicate — so the state that was observed and the state that was written are one decision,
156
+ * made under the row's lock. A read-then-check-then-write is the same call with a window in it,
157
+ * and under two concurrent callers the second one writes a transition out of a state the row had
158
+ * already left. Here the second statement matches no row: `X_STATE_CONFLICT`, naming the state
159
+ * the row is really in.
160
+ *
161
+ * A move the machine does not hold is `X_STATE_TRANSITION_ILLEGAL` and never reaches the
162
+ * database — the table is a property of the declaration. A move out of a TERMINAL state is the
163
+ * same code saying so; a terminal state is one whose outgoing list is empty, which is the whole
164
+ * of the concept and the only part of it the framework owns. Which state is terminal, what any
165
+ * of them mean, who may make a move and what happens on arrival are the app's, every one.
166
+ *
167
+ * Tenant-scoped exactly as `updateWhere` is, because it IS one: a row in another org matches no
168
+ * statement and reads back as absent, so the answer is `X_NOT_FOUND` rather than a conflict that
169
+ * would confirm it exists.
170
+ */
171
+ transition<K extends keyof Row & string>(
172
+ column: K,
173
+ id: IdOf<Row>,
174
+ move: { readonly from: Row[K] & string; readonly to: Row[K] & string },
175
+ options?: RepoOptions,
176
+ ): Promise<Row>;
91
177
  insert(values: Insertable<C>, options?: RepoOptions): Promise<Row>;
92
178
  /**
93
179
  * Many rows, one statement — the bulk write a per-row `insert` loop is the N+1 of, and the line
@@ -201,6 +287,16 @@ const builder = <Source, Row>(
201
287
 
202
288
  andWhere: (column, op, value) => next({ where: [...state.where, { column, op, value }] }),
203
289
 
290
+ // Refused HERE as well as at the statement, because this is the line the author wrote: a chain
291
+ // over an entity that declares nothing searchable can never produce a match, and the repair is
292
+ // one edit to the schema rather than anything about this call.
293
+ search: (term) => {
294
+ if (entity.$search === null) throw searchUndeclared(entity.$name);
295
+ return next({
296
+ where: [...state.where, { column: SEARCH_PROPERTY, op: 'matches', value: term }],
297
+ });
298
+ },
299
+
204
300
  orderBy: (column, direction = 'asc') =>
205
301
  next({ orderBy: [...state.orderBy, { column, direction }] }),
206
302
 
@@ -276,6 +372,22 @@ const builder = <Source, Row>(
276
372
 
277
373
  count: () => repo.count(args()),
278
374
 
375
+ // The one cast on each of these, and it is the same seam `countBy` and `select()` have: the
376
+ // driver contract is row-agnostic because a column name is a runtime string there, while the
377
+ // chain knows which property it just named and therefore what comes back.
378
+ sum: async <K extends keyof Row & string>(column: K) =>
379
+ (await repo.aggregate('sum', column, args())) as
380
+ | (Row[K] extends MoneyValue | null ? MoneyValue : string)
381
+ | null,
382
+ avg: async (column: keyof Row & string) =>
383
+ (await repo.aggregate('avg', column, args())) as string | null,
384
+ min: async <K extends keyof Row & string>(column: K) =>
385
+ (await repo.aggregate('min', column, args())) as Row[K] | null,
386
+ max: async <K extends keyof Row & string>(column: K) =>
387
+ (await repo.aggregate('max', column, args())) as Row[K] | null,
388
+
389
+ approximateCount: () => repo.approximateCount(args()),
390
+
279
391
  async countBy<K extends keyof Row & string>(column: K) {
280
392
  // The one cast on this terminal, and it is the same seam `select()` has: the driver contract
281
393
  // is row-agnostic because a column name is a runtime string there, while the chain knows
@@ -345,4 +457,9 @@ export const tableFor = <Row, C extends ColumnMap>(
345
457
  deleteWhere: async (filter, options) => repo.deleteWhere(filter, options),
346
458
  updateWhere: async (filter, patch, options) =>
347
459
  repo.updateWhere(filter, touch(entity, patch), options),
460
+ // `touch` is passed rather than applied here: a transition IS an update, so an `onUpdateNow()`
461
+ // column has to move exactly as `update(id, patch)` moves it — which is also the audit of WHEN
462
+ // the row moved, using the stamp already declared instead of a second one beside it.
463
+ transition: async (column, id, move, options) =>
464
+ transitionRow(entity, repo, column, id, move, (patch) => touch(entity, patch), options),
348
465
  });
package/src/registry.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // whole domain without importing it — and what makes a duplicate name a build error rather
4
4
  // than a silent last-one-wins.
5
5
 
6
+ import type { IndexMethod } from '@ultimat3/db';
6
7
  import { entityDuplicate } from './errors';
7
8
  import type { InvariantKind } from './invariants';
8
9
  import type { OnDelete } from './types';
@@ -24,6 +25,15 @@ export interface ColumnDescription {
24
25
  * projection reaches no `alter table` at all. It reached none until 3.0.
25
26
  */
26
27
  readonly onDelete: OnDelete | null;
28
+ /**
29
+ * The `generated always as (<expr>) stored` body, when the DATABASE computes this column rather
30
+ * than a writer. Absent on every ordinary column, matching `IndexDescription.using`: a
31
+ * description written before this existed reads the same, so nothing regenerates.
32
+ *
33
+ * `@ultimat3/db` is tier 1 and cannot import this package, so — exactly like `onDelete` — a
34
+ * field that is not on this projection reaches no DDL at all.
35
+ */
36
+ readonly generated?: string;
27
37
  }
28
38
 
29
39
  /**
@@ -72,6 +82,12 @@ export interface IndexDescription {
72
82
  readonly where: string | null;
73
83
  /** `null` is Postgres' own default (`asc`), never written out. */
74
84
  readonly order: 'asc' | 'desc' | null;
85
+ /**
86
+ * The access method, `undefined` for the `btree` every index was before this existed. Absent
87
+ * rather than `null`, matching `IndexDescriptionLike.using` in `@ultimat3/db`: a snapshot that
88
+ * predates the field and an index that declares nothing read the same, so nothing regenerates.
89
+ */
90
+ readonly using?: IndexMethod | undefined;
75
91
  }
76
92
 
77
93
  export interface EntityDescription {