@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,35 @@
1
+ // Single responsibility: turn the TEXT an aggregate statement returns into the value the column's
2
+ // kind holds — the Postgres driver's half of `aggregate.ts`, opposite `aggregate-fold.ts`.
3
+ //
4
+ // The statement casts every aggregate to `::text` on purpose. `sum(bigint)` is a `numeric` the
5
+ // client hands back as a string anyway, `min(timestamptz)` would arrive as a millisecond `Date`,
6
+ // and pinning all of them to text means exactly one place decides what the value becomes.
7
+
8
+ import type { AggregateFn } from './aggregate';
9
+ import type { ColumnKind } from './types';
10
+
11
+ /**
12
+ * `sum` and `avg` stay decimal TEXT whatever the column was, and that is the point: the sum of a
13
+ * million `integer` rows is not an `integer`, `Number()` on it loses digits past 2^53, and a
14
+ * binary float loses cents on a `numeric`. A caller who wants a JS number writes the `Number()`
15
+ * themselves, where the loss is a decision somebody made.
16
+ *
17
+ * `min` and `max` answer the ROW's own type, because the answer is one of the values that went in:
18
+ * a `timestamptz` back to a `Date` (the row property's type), everything else to the text it
19
+ * already is — `integer` becomes a `number` because that is what the row holds, and a minimum
20
+ * cannot exceed a value that already fitted in one.
21
+ */
22
+ export const decodeAggregate = (fn: AggregateFn, kind: ColumnKind, text: string): unknown => {
23
+ // Decided by the FUNCTION first: `min('likeCount')` is one of the rows' own values and fits in
24
+ // whatever they fit in, while `sum('likeCount')` over a million of them does not.
25
+ if (fn === 'sum' || fn === 'avg') return text;
26
+ if (kind === 'timestamptz') {
27
+ const at = new Date(text);
28
+ return Number.isNaN(at.getTime()) ? null : at;
29
+ }
30
+ if (kind === 'integer') {
31
+ const value = Number(text);
32
+ return Number.isFinite(value) ? value : text;
33
+ }
34
+ return text;
35
+ };
@@ -0,0 +1,91 @@
1
+ // Single responsibility: compute an aggregate from ROWS ALREADY IN HAND — the in-memory driver's
2
+ // half of `aggregate.ts`, split out because that file is the shared RULES (which kinds, which
3
+ // refusals, the decimal arithmetic) and this one is one driver's execution of them.
4
+ //
5
+ // Every path here is exact. A `sum` goes through `sumDecimalText`, not `+`: the rows of a
6
+ // `bigint()` or `decimal()` column are decimal STRINGS, and `Number()` on one loses digits past
7
+ // 2^53 and cents below it — which is the whole reason those columns hand back text.
8
+
9
+ import type { AggregateFn, MoneyUnit } from './aggregate';
10
+ import { aggregateMinor, assertOneUnit, averageDecimalText, sumDecimalText } from './aggregate';
11
+ import { valueAt } from './cursor';
12
+ import type { EntityCore } from './entity';
13
+ import { compareByKind } from './memory-match';
14
+ import type { ColumnKind, MoneyValue } from './types';
15
+
16
+ /** A row's value for this aggregate, or `undefined` for the absences SQL does not count. */
17
+ const present = (value: unknown): boolean => value !== null && value !== undefined;
18
+
19
+ const moneyOf = (value: unknown): MoneyValue | undefined => {
20
+ if (typeof value !== 'object' || value === null) return undefined;
21
+ const record = value as Partial<MoneyValue>;
22
+ return typeof record.minor === 'number' && typeof record.currency === 'string'
23
+ ? (record as MoneyValue)
24
+ : undefined;
25
+ };
26
+
27
+ /** The text form a decimal aggregate adds. `integer` rows are numbers; every other kind is text. */
28
+ const decimalText = (value: unknown): string =>
29
+ typeof value === 'bigint' ? value.toString() : String(value);
30
+
31
+ /**
32
+ * `min`/`max` by the column's DECLARED kind, never by the JS type in hand — the rule this package
33
+ * decides every comparison with. `compareByKind` is the same function the sort and the keyset seek
34
+ * read, so a minimum here is the row a `.orderBy(col, 'asc').one()` would have answered with.
35
+ */
36
+ const extreme = (kind: ColumnKind, values: readonly unknown[], fn: 'min' | 'max'): unknown =>
37
+ values.reduce((best, value) => {
38
+ const order = compareByKind(kind, value, best);
39
+ return (fn === 'min' ? order < 0 : order > 0) ? value : best;
40
+ });
41
+
42
+ /**
43
+ * The aggregate, over exactly the rows the caller's predicate matched. `null` for an empty set in
44
+ * every function, because that is what SQL answers — never `0`, which would claim rows were seen.
45
+ */
46
+ export const foldAggregate = <Row>(
47
+ entity: EntityCore<Row>,
48
+ fn: AggregateFn,
49
+ property: string,
50
+ kind: ColumnKind,
51
+ rows: readonly Row[],
52
+ ): unknown => {
53
+ const values = rows.map((row) => valueAt(row, property)).filter(present);
54
+ if (values.length === 0) return null;
55
+ if (kind === 'money') {
56
+ const amounts = values.flatMap((value) => {
57
+ const money = moneyOf(value);
58
+ return money === undefined ? [] : [money];
59
+ });
60
+ if (amounts.length === 0) return null;
61
+ const unit = assertOneUnit(
62
+ entity,
63
+ fn,
64
+ property,
65
+ amounts.map((money): MoneyUnit => ({ currency: money.currency, scale: money.scale ?? null })),
66
+ );
67
+ if (unit === undefined) return null;
68
+ const minor =
69
+ fn === 'sum'
70
+ ? aggregateMinor(
71
+ entity,
72
+ fn,
73
+ property,
74
+ sumDecimalText(amounts.map((money) => String(money.minor))) ?? '0',
75
+ )
76
+ : (extreme(
77
+ 'integer',
78
+ amounts.map((money) => money.minor),
79
+ fn === 'min' ? 'min' : 'max',
80
+ ) as number);
81
+ return {
82
+ minor,
83
+ currency: unit.currency,
84
+ ...(unit.scale === null ? {} : { scale: unit.scale }),
85
+ } satisfies MoneyValue;
86
+ }
87
+ if (fn === 'sum') return sumDecimalText(values.map(decimalText));
88
+ if (fn === 'avg')
89
+ return averageDecimalText(sumDecimalText(values.map(decimalText)), values.length);
90
+ return extreme(kind, values, fn);
91
+ };
@@ -0,0 +1,232 @@
1
+ // Single responsibility: what an aggregate MEANS — which column kinds each function may be applied
2
+ // to, what its answer is shaped like, and the exact arithmetic. Both drivers read it from here, so
3
+ // a `sum` against memory means what a `sum` against Postgres means; a rule added to one driver
4
+ // alone is the drift the two-driver split exists to prevent.
5
+ //
6
+ // Nothing here is a float. `sum` and `avg` answer decimal TEXT, money answers `MoneyValue` — the
7
+ // same reason `bigint()` and `decimal()` hand back strings: a `number` loses digits past 2^53 and
8
+ // a binary float loses cents.
9
+
10
+ import { columnFor } from './column';
11
+ import type { EntityCore } from './entity';
12
+ import { EntityError } from './errors';
13
+ import type { AnyColumn, ColumnKind } from './types';
14
+
15
+ /** The four, closed. A fifth is a new member here and a new case in both drivers, never one. */
16
+ export type AggregateFn = 'sum' | 'avg' | 'min' | 'max';
17
+
18
+ /**
19
+ * Which kinds each function may be applied to. Closed sets rather than "whatever Postgres accepts",
20
+ * because the bar is what BOTH drivers can answer identically.
21
+ *
22
+ * `text` and `char` are deliberately absent from `min`/`max` even though Postgres has them: text
23
+ * ordering there is the database's COLLATION and here it is JS's UTF-16 code-unit order, and the
24
+ * two disagree on ordinary data (`'a' < 'B'` under `en_US`, `'B' < 'a'` by code unit). A comparison
25
+ * this package cannot make agree is refused rather than answered twice differently — the same
26
+ * decision `memory-match.ts` records for decimal text, in the other direction.
27
+ *
28
+ * `boolean`, `uuid`, `jsonb`, `array` and `bytea` are absent everywhere: none of them has an
29
+ * ordering or a sum a caller would mean.
30
+ */
31
+ const NUMERIC: readonly ColumnKind[] = ['integer', 'bigint', 'numeric'];
32
+ const ORDERED: readonly ColumnKind[] = ['integer', 'bigint', 'numeric', 'timestamptz', 'date'];
33
+
34
+ const ALLOWED = new Map<AggregateFn, ReadonlySet<ColumnKind>>([
35
+ ['sum', new Set<ColumnKind>([...NUMERIC, 'money'])],
36
+ ['avg', new Set<ColumnKind>(NUMERIC)],
37
+ ['min', new Set<ColumnKind>([...ORDERED, 'money'])],
38
+ ['max', new Set<ColumnKind>([...ORDERED, 'money'])],
39
+ ]);
40
+
41
+ export const aggregatable = (fn: AggregateFn, kind: ColumnKind): boolean =>
42
+ ALLOWED.get(fn)?.has(kind) === true;
43
+
44
+ /**
45
+ * `avg` over money is refused rather than rounded. The average of 1, 1 and 2 minor units is 4/3 of
46
+ * a unit, and every representable answer is a rounding — which is the defect `MoneyValue.scale`
47
+ * exists to prevent, so inventing one at the aggregate would reopen it one layer up. The caller
48
+ * decides the rounding, out of two exact numbers.
49
+ */
50
+ export const notAggregatable = (
51
+ entityName: string,
52
+ fn: AggregateFn,
53
+ property: string,
54
+ kind: ColumnKind,
55
+ candidates: readonly string[],
56
+ ): EntityError =>
57
+ new EntityError({
58
+ code: 'X_AGGREGATE_UNSUPPORTED',
59
+ cause:
60
+ fn === 'avg' && kind === 'money'
61
+ ? `${entityName}.avg('${property}') — the mean of an integer number of minor units is not one, and every answer would be a silent rounding`
62
+ : `${entityName}.${fn}('${property}') — a ${kind} column has no ${fn} both drivers can answer the same way`,
63
+ fix:
64
+ fn === 'avg' && kind === 'money'
65
+ ? `${entityName}.sum('${property}') and ${entityName}.count() — divide at the call site, where the rounding is a decision somebody made`
66
+ : candidates.length === 0
67
+ ? `x entities describe ${entityName} --json # this entity declares no column ${fn} can be applied to`
68
+ : `${entityName}.${fn}('${candidates[0]}') # ${fn} takes one of: ${candidates.join(', ')}`,
69
+ });
70
+
71
+ /**
72
+ * Money crossing currencies has no sum, no minimum and no maximum: 100 JPY and 100 EUR are not
73
+ * comparable and adding them answers a number in no currency at all. Both drivers count the
74
+ * distinct currencies of the rows they are about to aggregate and refuse past one, rather than
75
+ * silently answering in whichever currency happened to come first.
76
+ */
77
+ export const mixedCurrency = (
78
+ entityName: string,
79
+ fn: AggregateFn,
80
+ property: string,
81
+ currencies: readonly string[],
82
+ ): EntityError =>
83
+ new EntityError({
84
+ code: 'X_AGGREGATE_MIXED_CURRENCY',
85
+ cause: `${entityName}.${fn}('${property}') covers ${currencies.length} currencies (${[...currencies].sort().join(', ')}) — they have no common unit`,
86
+ fix: `${entityName}.andWhere('${property}.currency', 'eq', '${[...currencies].sort()[0]}').${fn}('${property}') # one currency per call, or countBy('${property}.currency') first`,
87
+ });
88
+
89
+ /** Digits only, optionally signed, optionally with a fraction. What `decimal()` hands back. */
90
+ const DECIMAL_TEXT = /^-?\d+(\.\d+)?$/;
91
+
92
+ /** The scale `avg` answers at, in both drivers. Fixed, so the two cannot round to different places. */
93
+ export const AVG_SCALE = 6;
94
+
95
+ interface Decimal {
96
+ readonly units: bigint;
97
+ readonly scale: number;
98
+ }
99
+
100
+ const parseDecimal = (text: string): Decimal | undefined => {
101
+ if (!DECIMAL_TEXT.test(text)) return undefined;
102
+ const [whole = '0', fraction = ''] = text.split('.');
103
+ return { units: BigInt(`${whole}${fraction}`), scale: fraction.length };
104
+ };
105
+
106
+ const rescale = (value: Decimal, scale: number): bigint =>
107
+ value.units * 10n ** BigInt(scale - value.scale);
108
+
109
+ const render = (units: bigint, scale: number): string => {
110
+ if (scale === 0) return units.toString();
111
+ const negative = units < 0n;
112
+ const digits = (negative ? -units : units).toString().padStart(scale + 1, '0');
113
+ const whole = digits.slice(0, digits.length - scale);
114
+ return `${negative ? '-' : ''}${whole}.${digits.slice(digits.length - scale)}`;
115
+ };
116
+
117
+ /**
118
+ * The exact sum of decimal text, at the widest scale any term carries — which is what Postgres'
119
+ * `sum(numeric)` answers, and what no `Number()` can: `0.1 + 0.2` is `0.30000000000000004` in a
120
+ * binary float and `0.3` here, and an `int8` past 2^53 keeps every digit.
121
+ *
122
+ * `null` for an empty set, exactly as `sum` over no rows is NULL in SQL — never `0`, which would
123
+ * claim rows were counted.
124
+ */
125
+ export const sumDecimalText = (values: readonly string[]): string | null => {
126
+ if (values.length === 0) return null;
127
+ const parsed = values.map(parseDecimal);
128
+ if (parsed.some((value) => value === undefined)) return null;
129
+ const decimals = parsed as readonly Decimal[];
130
+ const scale = decimals.reduce((widest, value) => Math.max(widest, value.scale), 0);
131
+ return render(
132
+ decimals.reduce((total, value) => total + rescale(value, scale), 0n),
133
+ scale,
134
+ );
135
+ };
136
+
137
+ /**
138
+ * `sum / count`, rounded half away from zero at `AVG_SCALE` — which is what Postgres' `round()`
139
+ * does to a `numeric`, and the reason the statement asks for `round(avg(...), 6)` rather than the
140
+ * server's own default scale: two drivers rounding at different places answer two numbers.
141
+ */
142
+ export const averageDecimalText = (total: string | null, count: number): string | null => {
143
+ if (total === null || count === 0) return null;
144
+ const parsed = parseDecimal(total);
145
+ if (parsed === undefined) return null;
146
+ // The EXACT rational, rounded once. Dividing first and rounding after would truncate digits the
147
+ // rounding decision depends on — and rescaling to a fixed number of digits rather than to
148
+ // AVG_SCALE is what made this answer 11000.000000 where Postgres said 1.100000: `rescale` takes
149
+ // an absolute target scale, and the first draft handed it a relative one.
150
+ const digits = Math.max(parsed.scale, AVG_SCALE);
151
+ const numerator = rescale(parsed, digits);
152
+ const denominator = BigInt(count) * 10n ** BigInt(digits - AVG_SCALE);
153
+ const negative = numerator < 0n;
154
+ const magnitude = negative ? -numerator : numerator;
155
+ // `(2m + d) / 2d` is `floor(m/d + 1/2)` in integers — half AWAY FROM ZERO once the sign is put
156
+ // back, which is what Postgres' `round(numeric)` does. No float, no intermediate truncation.
157
+ const rounded = (2n * magnitude + denominator) / (2n * denominator);
158
+ return render(negative ? -rounded : rounded, AVG_SCALE);
159
+ };
160
+
161
+ /**
162
+ * The column an aggregate runs over, resolved and judged before either driver builds anything —
163
+ * so `sum('title')` is refused with the same words and the same code whichever driver is attached.
164
+ */
165
+ export const aggregateColumnOf = <Row>(
166
+ entity: EntityCore<Row>,
167
+ fn: AggregateFn,
168
+ property: string,
169
+ ): AnyColumn => {
170
+ const candidates = Object.entries(entity.$columns)
171
+ .filter(([, each]) => aggregatable(fn, each.$meta.kind))
172
+ .map(([name]) => name);
173
+ const column = columnFor(entity.$columns, property);
174
+ if (column === undefined) {
175
+ throw notAggregatable(entity.$name, fn, property, 'text', candidates);
176
+ }
177
+ if (!aggregatable(fn, column.$meta.kind)) {
178
+ throw notAggregatable(entity.$name, fn, property, column.$meta.kind, candidates);
179
+ }
180
+ return column;
181
+ };
182
+
183
+ /** One amount is one currency at one scale. Two of either have no common unit. */
184
+ export interface MoneyUnit {
185
+ readonly currency: string;
186
+ readonly scale: number | null;
187
+ }
188
+
189
+ export const assertOneUnit = <Row>(
190
+ entity: EntityCore<Row>,
191
+ fn: AggregateFn,
192
+ property: string,
193
+ units: readonly MoneyUnit[],
194
+ ): MoneyUnit | undefined => {
195
+ const seen = new Map<string, MoneyUnit>();
196
+ for (const unit of units) seen.set(`${unit.currency}/${unit.scale ?? ''}`, unit);
197
+ if (seen.size > 1) {
198
+ throw mixedCurrency(entity.$name, fn, property, [...seen.values()].map(unitLabel));
199
+ }
200
+ return [...seen.values()][0];
201
+ };
202
+
203
+ /**
204
+ * What the refusal names. The scale rides along because it is half of what makes two amounts
205
+ * incomparable: `{ minor: 5, currency: 'USD' }` is five cents and `{ minor: 5, currency: 'USD',
206
+ * scale: 6 }` is five millionths of a dollar, and adding them is a 10,000x error with no symptom.
207
+ */
208
+ const unitLabel = (unit: MoneyUnit): string =>
209
+ unit.scale === null ? unit.currency : `${unit.currency}@${unit.scale}`;
210
+
211
+ /**
212
+ * The minor unit an aggregate answers with, narrowed exactly where every other reader of that
213
+ * column narrows it. The column is `bigint` and `MoneyValue.minor` is a `number`, so a total past
214
+ * ±2^53 is a REFUSAL and never a rounded amount — the same bound `parseMinor` applies to one row,
215
+ * applied to the sum of many, where it is far easier to reach.
216
+ */
217
+ export const aggregateMinor = <Row>(
218
+ entity: EntityCore<Row>,
219
+ fn: AggregateFn,
220
+ property: string,
221
+ total: string,
222
+ ): number => {
223
+ const units = BigInt(total);
224
+ if (units <= BigInt(Number.MAX_SAFE_INTEGER) && units >= BigInt(-Number.MAX_SAFE_INTEGER)) {
225
+ return Number(units);
226
+ }
227
+ throw new EntityError({
228
+ code: 'X_AGGREGATE_UNSUPPORTED',
229
+ cause: `${entity.$name}.${fn}('${property}') is ${total} minor units, past ±2^53 — no JS number holds it and MoneyValue.minor is one`,
230
+ fix: `${entity.$name}.andWhere(…).${fn}('${property}') # narrow the rows, or read the total as text with a hand-written statement`,
231
+ });
232
+ };
package/src/batch.ts CHANGED
@@ -56,7 +56,8 @@ export const assertBatchable = <Row>(
56
56
  if (chain.limit !== undefined) throw limitedBatches(entity.$name, chain.limit, size);
57
57
  // The order the driver will sort by, primary key included: the cursor between two batches is
58
58
  // minted from it, and an ordering that cannot carry one fails on the batch *after* the first —
59
- // where whatever size the caller happened to pass decides whether anyone ever finds out.
59
+ // where whatever size the caller happened to pass decides whether anyone ever finds out. A
60
+ // nullable column is NOT such an ordering `As of 2026-08-24`; a nullable primary-key column is.
60
61
  assertSeekable(entity, totalOrder(entity, chain.orderBy));
61
62
  };
62
63
 
@@ -0,0 +1,29 @@
1
+ // Two things every column builder needs and neither owns: how a rejected value is DESCRIBED, and
2
+ // the CHECK a closed set of values emits. Here rather than in `columns.ts` so `enum-column.ts` can
3
+ // read them without importing the file that imports it.
4
+
5
+ import { describeValue } from '@ultimat3/schema';
6
+
7
+ /**
8
+ * The rejected value, rendered as its SHAPE and never its content — `@ultimat3/schema`'s
9
+ * `describeValue`, the same renderer every builtin validator fails through, so a column and a
10
+ * schema describe one bad value the same way.
11
+ *
12
+ * WHY it is not `String(value)`: a column rejection is not a private diagnostic. It becomes
13
+ * `X_INVARIANT_VIOLATED`'s `cause` and a `$view` issue, which `@ultimat3/http` returns to the
14
+ * caller AND writes into the log line — and core's logger redacts by KEY, so a value baked into a
15
+ * message has no key left to redact. `text()` on a password field wrote the mistyped password to
16
+ * the central log index in cleartext and into the user's own network tab; a `uuid()` holding an
17
+ * API key surrogate does the same. A column is the worse half of that pair, because the value can
18
+ * arrive from the DATABASE — so the leak is not bounded by what someone just typed.
19
+ *
20
+ * `got` stays `got` and the "expected …" half is untouched: only what follows it changes.
21
+ */
22
+ export const got = (value: unknown): string => `got ${describeValue(value)}`;
23
+
24
+ const quote = (value: string): string => `'${value.replaceAll("'", "''")}'`;
25
+
26
+ export const oneOf =
27
+ (values: readonly string[]) =>
28
+ (name: string): string =>
29
+ `${name} in (${values.map(quote).join(', ')})`;
package/src/column.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { invariantViolated } from './errors';
10
10
  import { refuseColumn } from './refuse';
11
+ import { DEFAULT_SEARCH_WEIGHT, isSearchWeight } from './search';
11
12
  import type {
12
13
  AnyColumn,
13
14
  Column,
@@ -105,7 +106,12 @@ export const bindColumn = (column: AnyColumn, table: string, property: string):
105
106
  'build a new column instead of sharing one between entities',
106
107
  );
107
108
  }
108
- const binding: Binding = { table, property, name: columnName(property, column.$meta) };
109
+ // The DERIVED name too, not only a declared one: `snake(property)` lower-cases and nothing else.
110
+ const binding: Binding = {
111
+ table,
112
+ property,
113
+ name: assertColumnName(columnName(property, column.$meta)),
114
+ };
109
115
  bindings.set(column, binding);
110
116
  return binding;
111
117
  };
@@ -176,6 +182,27 @@ export const makeColumn = <T, Optional extends boolean>(
176
182
 
177
183
  unique: () => makeColumn<T, Optional>({ ...meta, unique: true }, parse, optional),
178
184
 
185
+ searchable: (weight = DEFAULT_SEARCH_WEIGHT) => {
186
+ // Refused where the chain was written, because the alternative is a `to_tsvector` over a cast
187
+ // the DDL cannot express: `to_tsvector` takes text, and a `jsonb` or a `timestamptz` reaching
188
+ // it is a `42883` inside `ROLE=migrate`, with the server's words and none of the column's.
189
+ if (meta.kind !== 'text') {
190
+ refuseColumn(
191
+ 'searchable',
192
+ `a ${meta.kind} column is not searchable — full text search reads text`,
193
+ 'text().searchable() — index a text() column, and store the searchable projection of a structured value in one of its own',
194
+ );
195
+ }
196
+ if (!isSearchWeight(weight)) {
197
+ refuseColumn(
198
+ 'searchable',
199
+ `"${String(weight)}" is not a search weight`,
200
+ "text().searchable('A') — one of A, B, C or D, biggest first; omit it for D",
201
+ );
202
+ }
203
+ return makeColumn<T, Optional>({ ...meta, searchable: weight }, parse, optional);
204
+ },
205
+
179
206
  tenant: () => makeColumn<T, Optional>({ ...meta, tenant: true, index: true }, parse, optional),
180
207
 
181
208
  references: (target, options = {}) =>
@@ -203,6 +230,15 @@ export const makeColumn = <T, Optional extends boolean>(
203
230
  * identifier. `[a-z_][a-z0-9_$]*`, which is what an unquoted Postgres identifier may be, and the
204
231
  * bound is the same 63 bytes the server truncates at — a longer one silently addresses a
205
232
  * different column.
233
+ *
234
+ * **Every physical name, not only a declared one — `As of 2026-08-24`.** `columnName` is
235
+ * `meta.name ?? snake(property)` and for three majors only the first branch reached here, so a
236
+ * PROPERTY name went into the DDL untouched: `snake()` lower-cases and does nothing else, and a
237
+ * column named `n" , "x" text); drop table t; --` produced a `create table` carrying a real
238
+ * `drop table` (measured through `generateMigration`). Quoting is not a defence against a value
239
+ * that can close the quote, which is what the paragraph above already said. `bindColumn` is where
240
+ * the derived name is checked, because that runs once per column at `entity()` rather than on
241
+ * every statement.
206
242
  */
207
243
  export const assertColumnName = (name: string): string => {
208
244
  if (!/^[a-z_][a-z0-9_$]*$/.test(name) || name.length > 63) {
package/src/columns.ts CHANGED
@@ -5,7 +5,6 @@
5
5
  import { uuid as uuidV7 } from '@ultimat3/core';
6
6
  import {
7
7
  CURRENCY_CODE_PATTERN,
8
- describeValue,
9
8
  isCurrencyCode,
10
9
  isMoneyScale,
11
10
  MAX_MONEY_SCALE,
@@ -19,6 +18,7 @@ import {
19
18
  makeColumn,
20
19
  makeTimestamp,
21
20
  } from './column';
21
+ import { got, oneOf } from './column-values';
22
22
  import { refuseColumn } from './refuse';
23
23
  import type {
24
24
  Column,
@@ -31,23 +31,6 @@ import type {
31
31
  UuidColumn,
32
32
  } from './types';
33
33
 
34
- /**
35
- * The rejected value, rendered as its SHAPE and never its content — `@ultimat3/schema`'s
36
- * `describeValue`, the same renderer every builtin validator fails through, so a column and a
37
- * schema describe one bad value the same way.
38
- *
39
- * WHY it is not `String(value)`: a column rejection is not a private diagnostic. It becomes
40
- * `X_INVARIANT_VIOLATED`'s `cause` and a `$view` issue, which `@ultimat3/http` returns to the
41
- * caller AND writes into the log line — and core's logger redacts by KEY, so a value baked into a
42
- * message has no key left to redact. `text()` on a password field wrote the mistyped password to
43
- * the central log index in cleartext and into the user's own network tab; a `uuid()` holding an
44
- * API key surrogate does the same. A column is the worse half of that pair, because the value can
45
- * arrive from the DATABASE — so the leak is not bounded by what someone just typed.
46
- *
47
- * `got` stays `got` and the "expected …" half is untouched: only what follows it changes.
48
- */
49
- const got = (value: unknown): string => `got ${describeValue(value)}`;
50
-
51
34
  /** uuid v7: time-ordered, so a primary key index stays append-friendly. */
52
35
  export const newId = (): string => uuidV7();
53
36
 
@@ -152,34 +135,6 @@ const parseInstant = (value: unknown): Date => {
152
135
  export const timestamp = (): TimestampColumn =>
153
136
  makeTimestamp<false>({ ...BARE, kind: 'timestamptz' }, parseInstant, false);
154
137
 
155
- const quote = (value: string): string => `'${value.replaceAll("'", "''")}'`;
156
-
157
- const oneOf =
158
- (values: readonly string[]) =>
159
- (name: string): string =>
160
- `${name} in (${values.map(quote).join(', ')})`;
161
-
162
- /**
163
- * A closed set of strings, emitted as a CHECK rather than a Postgres `ENUM` type: adding a
164
- * variant is then a one-line migration instead of `ALTER TYPE`, which cannot run inside a
165
- * transaction on older servers.
166
- */
167
- export const enumerated = <const V extends readonly string[]>(values: V): Column<V[number]> => {
168
- const allowed = new Set<string>(values);
169
- return column<V[number]>(
170
- 'text',
171
- (value) =>
172
- typeof value === 'string' && allowed.has(value)
173
- ? value
174
- : refuseColumn(
175
- 'enum',
176
- `expected one of ${values.join(' | ')}, ${got(value)}`,
177
- 'store one of the values enumerated() declares, or add the new variant to that list and run x db gen "extend the enum check" — the values are a CHECK constraint, so the table moves with them',
178
- ),
179
- { values, check: oneOf(values) },
180
- );
181
- };
182
-
183
138
  /**
184
139
  * An absolute http(s) URL, validated on write rather than on render: a bad URL stored once is
185
140
  * served to every reader, and `<img src>` fails silently in the browser.
@@ -454,3 +409,7 @@ export const currencyCheck = (currencyColumn: string): string =>
454
409
  */
455
410
  export const scaleCheck = (scaleColumn: string): string =>
456
411
  `${scaleColumn} is null or (${scaleColumn} >= 0 and ${scaleColumn} <= ${MAX_MONEY_SCALE})`;
412
+
413
+ // `enumerated()` lives in `enum-column.ts` — it is the one builder with a chain of its own, and
414
+ // splitting it is what kept this file under the ceiling. Re-exported so no caller had to move.
415
+ export { enumerated } from './enum-column';
@@ -0,0 +1,94 @@
1
+ // Single responsibility: what `@>`, `<@`, `&&` and a JSON key test MEAN, written once so the
2
+ // in-memory driver answers what Postgres answers. A `jsonb` or an `arrayOf()` column was declared
3
+ // and then unfilterable — the ten-operator vocabulary had nothing that could look inside one — so
4
+ // an app with either had to leave the query language for hand-written SQL, which is the one path
5
+ // in this framework with no tenancy guard on it.
6
+ //
7
+ // Every rule here is Postgres', reproduced rather than approximated. Where the two could not be
8
+ // made to agree the operator is refused instead (`memory-match.ts`), never guessed at.
9
+
10
+ /** Absent and NULL are one thing, exactly as they are to every other predicate. */
11
+ const isNull = (value: unknown): boolean => value === null || value === undefined;
12
+
13
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
14
+ typeof value === 'object' && value !== null && !Array.isArray(value);
15
+
16
+ /** Neither an object nor an array — which is exactly the set the top-level exception below covers. */
17
+ const isScalar = (value: unknown): boolean => !isRecord(value) && !Array.isArray(value);
18
+
19
+ /**
20
+ * Two ELEMENTS, equal. `===` plus the one case it gets wrong here: an `arrayOf(timestamp())` row
21
+ * holds `Date` objects, and two Dates for the same instant are two references — the same trap
22
+ * `sameValueOfKind` closes for a predicate, one operator along.
23
+ *
24
+ * Nothing deeper, and that is a property rather than an omission: `arrayOf()` refuses `jsonb`,
25
+ * `bytea`, `money` and a nested array at declaration, so an element is always a scalar or a Date.
26
+ */
27
+ const sameElement = (left: unknown, right: unknown): boolean => {
28
+ if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime();
29
+ return left === right;
30
+ };
31
+
32
+ /**
33
+ * `left @> right` for `jsonb`, to the letter of Postgres' definition — each clause below measured
34
+ * on Postgres 16 rather than read off a summary, because three of them are easy to state wrongly:
35
+ *
36
+ * - two objects: every key of `right` is present in `left` and its value is contained by the one
37
+ * there — recursively, which is what makes `data @> '{"a":{"b":1}}'` a NESTED match and why this
38
+ * package ships no second path-expression language beside it (axiom 1).
39
+ * - two arrays: every element of `right` is contained by SOME element of `left`, which is why
40
+ * `[1,2,3] @> [3,1]` holds and order never matters.
41
+ * - an array on the left and a PRIMITIVE on the right, **at the top level only**: `'[1,2]' @> '2'`
42
+ * is true. Both halves of that sentence are load-bearing and both were wrong here first —
43
+ * `'{"list":[1,2,3]}' @> '{"list":2}'` is FALSE (the exception does not recurse) and
44
+ * `'[{"a":1}]' @> '{"a":1}'` is FALSE (it does not extend to composites).
45
+ * - anything else: element equality, which for a jsonb scalar is what it sounds like.
46
+ */
47
+ export const jsonContains = (left: unknown, right: unknown): boolean => contains(left, right, true);
48
+
49
+ const contains = (left: unknown, right: unknown, top: boolean): boolean => {
50
+ if (Array.isArray(left)) {
51
+ if (Array.isArray(right)) {
52
+ return right.every((item) => left.some((candidate) => contains(candidate, item, false)));
53
+ }
54
+ return top && isScalar(right) && left.some((candidate) => sameElement(candidate, right));
55
+ }
56
+ if (isRecord(left) && isRecord(right)) {
57
+ return Object.keys(right).every(
58
+ (key) => Object.hasOwn(left, key) && contains(left[key], right[key], false),
59
+ );
60
+ }
61
+ return isScalar(left) && isScalar(right) && sameElement(left, right);
62
+ };
63
+
64
+ /**
65
+ * `left @> right` for a SQL array, which is a different operator with a different rule: element
66
+ * containment is plain equality, never the recursive one above, because an array's elements are
67
+ * scalars of one declared type rather than arbitrary JSON. An empty right-hand side is contained
68
+ * by every array, which is what Postgres answers.
69
+ */
70
+ export const arrayContains = (left: readonly unknown[], right: readonly unknown[]): boolean =>
71
+ right.every((item) => left.some((candidate) => sameElement(candidate, item)));
72
+
73
+ /**
74
+ * `left && right`: they share at least one element. Arrays only — `jsonb` has no `&&` — and the
75
+ * bound is the opposite way round from `@>`: an EMPTY operand overlaps nothing, where it is
76
+ * contained by everything.
77
+ */
78
+ export const arrayOverlaps = (left: readonly unknown[], right: readonly unknown[]): boolean =>
79
+ right.some((item) => left.some((candidate) => sameElement(candidate, item)));
80
+
81
+ /**
82
+ * `jsonb_exists(value, key)` — the function form of the `?` operator, which is what the SQL side
83
+ * emits so a literal `?` can never be read as a parameter placeholder by anything on the way.
84
+ *
85
+ * Three shapes, all of them Postgres': a top-level key of an object, a string ELEMENT of an array,
86
+ * and a string value equal to the key. A number never matches — `jsonb_exists('[1]', '1')` is
87
+ * false there, and a `String(item) === key` here would have made it true.
88
+ */
89
+ export const jsonHasKey = (value: unknown, key: unknown): boolean => {
90
+ if (typeof key !== 'string' || isNull(value)) return false;
91
+ if (Array.isArray(value)) return value.some((item) => item === key);
92
+ if (isRecord(value)) return Object.hasOwn(value, key);
93
+ return value === key;
94
+ };