@ultimat3/entity 11.2.0 → 12.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/src/entity.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // this one call.
5
5
 
6
6
  import { renderThrowable } from '@ultimat3/core';
7
+ import type { IndexMethod } from '@ultimat3/db';
7
8
  import { describeValue, type StandardSchemaV1 } from '@ultimat3/schema';
8
9
  import { entityNow } from './clock';
9
10
  import { assertColumnName, bindColumn, columnName, moneyColumns } from './column';
@@ -30,6 +31,21 @@ export interface IndexInit<C extends ColumnMap> {
30
31
  readonly unique?: boolean;
31
32
  /** Partial index predicate, written in the same language as an invariant. */
32
33
  readonly where?: (columns: InvariantColumns<C>) => Expr;
34
+ /**
35
+ * The access method. Omitted is `btree`, which is Postgres' own default and what every index
36
+ * declared before this existed is — so an entity that names none emits the statement it always
37
+ * emitted and nothing regenerates.
38
+ *
39
+ * `'gin'` is the one with a caller, and it is the whole point of the containment operators:
40
+ * measured on Postgres 16 over 20,000 rows, `tags @> …`, `tags <@ …`, `tags && …` and
41
+ * `data @> …` are each a Bitmap Index Scan with one and a Seq Scan without. The set is
42
+ * `@ultimat3/db`'s `INDEX_METHODS`, imported rather than restated — one declaration of one fact.
43
+ *
44
+ * Two Postgres rules ride with it and both are refused HERE, where the author is, rather than at
45
+ * `x db gen` or inside `ROLE=migrate` as the server's own syntax error: a GIN index cannot be
46
+ * unique and cannot order its keys.
47
+ */
48
+ readonly using?: IndexMethod;
33
49
  }
34
50
 
35
51
  export interface EntityInit<C extends ColumnMap> {
@@ -109,8 +125,87 @@ export type Entity<Row, C extends ColumnMap = ColumnMap> = EntityCore<Row, C> &
109
125
 
110
126
  const MONEY_PARTS = new Set(['minor', 'currency']);
111
127
 
112
- const indexName = (table: string, columns: readonly string[], unique: boolean): string =>
113
- `${table}_${columns.join('_')}_${unique ? 'key' : 'idx'}`;
128
+ /**
129
+ * What separates two indexes on the SAME columns: the predicate and the direction. Eight hex
130
+ * characters of sha256 over both — deterministic across processes, so a name is a property of the
131
+ * declaration and never of the run that generated it.
132
+ */
133
+ /**
134
+ * What separates two indexes on the SAME columns: the predicate, the direction and the ACCESS
135
+ * METHOD. Eight hex characters of sha256 over all three — deterministic across processes, so a
136
+ * name is a property of the declaration and never of the run that generated it.
137
+ *
138
+ * The method belongs here for exactly the reason `where` does. A btree on an `arrayOf()` column
139
+ * answers `=` and an ordering; a GIN on the same column answers `@>` / `<@` / `&&`. They are two
140
+ * distinct indexes, and without the method in the name both are `<table>_<cols>_idx` — where the
141
+ * dedup below drops one in silence (the defect this discriminator was added for) or, since that
142
+ * dedup is now on the whole definition, two `create index` statements share one name and the
143
+ * migration is `42P07`.
144
+ */
145
+ const indexDiscriminator = (
146
+ order: string | undefined,
147
+ where: string | null,
148
+ using: string | undefined,
149
+ ): string =>
150
+ new Bun.CryptoHasher('sha256')
151
+ // The method is APPENDED only when one was declared, never as an empty field: every name this
152
+ // function has ever minted for a partial or ordered index is therefore unchanged by the method
153
+ // existing, and an index that declares no method is byte-identical to the one it was.
154
+ .update(`${order ?? ''}|${where ?? ''}${using === undefined ? '' : `|${using}`}`)
155
+ .digest('hex')
156
+ .slice(0, 8);
157
+
158
+ /**
159
+ * `<table>_<columns>_idx`, plus a discriminator when — and only when — the index carries a
160
+ * predicate, a direction or a non-default access method.
161
+ *
162
+ * Only then, because the plain name is load-bearing in two places: `unique()` on a column is an
163
+ * inline column clause and Postgres names the index it creates exactly `<table>_<column>_key`, so
164
+ * a discriminator there would make the generator emit a second `create unique index` for an index
165
+ * that already exists (`42P07`); and a foreign key's own index is deduped against a hand-declared
166
+ * one by this name.
167
+ *
168
+ * Without it, two DIFFERENT partial indexes on one column were one name — `posts_author_id_idx`
169
+ * for both `where status = 'published'` and `where status = 'draft'` — and the dedup below dropped
170
+ * the second with no error, no warning and no drift finding, since a declared index is matched by
171
+ * name.
172
+ */
173
+ /**
174
+ * `NAMEDATALEN - 1`. Postgres truncates a longer identifier and says NOTHING, so two index names
175
+ * sharing their first 63 bytes become one index on the server — the same silent collapse the
176
+ * discriminator above exists to prevent, one layer down, and invisible to a drift check comparing
177
+ * DECLARED names because those still differ. Bytes and not characters: 63 is what the server
178
+ * counts, and `.length` would stop seeing the truncation the moment a name is not ASCII.
179
+ */
180
+ const MAX_IDENTIFIER_BYTES = 63;
181
+
182
+ const byteLength = (value: string): number => new TextEncoder().encode(value).length;
183
+
184
+ const indexName = (
185
+ entityName: string,
186
+ table: string,
187
+ columns: readonly string[],
188
+ unique: boolean,
189
+ order?: string | undefined,
190
+ where: string | null = null,
191
+ using?: IndexMethod | undefined,
192
+ ): string => {
193
+ const suffix = unique ? 'key' : 'idx';
194
+ const base = `${table}_${columns.join('_')}`;
195
+ const plain = order === undefined && where === null && using === undefined;
196
+ const name = plain
197
+ ? `${base}_${suffix}`
198
+ : `${base}_${indexDiscriminator(order, where, using)}_${suffix}`;
199
+ const bytes = byteLength(name);
200
+ if (bytes <= MAX_IDENTIFIER_BYTES) return name;
201
+ throw invariantViolated(
202
+ entityName,
203
+ 'index',
204
+ `the index on (${columns.join(', ')}) is named "${name}", which is ${bytes} bytes — ` +
205
+ `Postgres truncates an identifier at ${MAX_IDENTIFIER_BYTES} and does not say so, ` +
206
+ 'so two indexes can silently become one',
207
+ );
208
+ };
114
209
 
115
210
  const defaultValue = (meta: ColumnMeta): unknown => {
116
211
  const declared = meta.default;
@@ -127,7 +222,10 @@ export const entity = <const C extends ColumnMap>(
127
222
  const entries: readonly (readonly [string, AnyColumn])[] = Object.entries(init.columns);
128
223
  for (const [property, column] of entries) bindColumn(column, name, property);
129
224
 
130
- const table = init.table === undefined ? name : assertColumnName(init.table);
225
+ // Both branches. The declared table was checked and the fallback — which is every entity that
226
+ // does not rename its table — was not, so an entity NAME closed the identifier in the same way a
227
+ // column name could: `entity('t" (x int); drop table u; --')` emitted that `drop table` verbatim.
228
+ const table = assertColumnName(init.table ?? name);
131
229
  const cacheTag = `entity:${name}`;
132
230
  const softDelete = Object.hasOwn(init.columns, SOFT_DELETE_COLUMN);
133
231
  const tenantColumn = resolveTenantColumn(name, init.columns, init.tenant);
@@ -187,7 +285,11 @@ export const entity = <const C extends ColumnMap>(
187
285
  meta.kind === 'money' ? moneyColumns(property, meta).minor : columnName(property, meta),
188
286
  ];
189
287
  return [
190
- { name: indexName(table, physical, meta.unique), columns: physical, unique: meta.unique },
288
+ {
289
+ name: indexName(name, table, physical, meta.unique),
290
+ columns: physical,
291
+ unique: meta.unique,
292
+ },
191
293
  ];
192
294
  }),
193
295
  ...(init.indexes ?? []).map((index) => {
@@ -206,18 +308,62 @@ export const entity = <const C extends ColumnMap>(
206
308
  'a partial index predicate must be expressible in SQL; a JS predicate cannot be one',
207
309
  );
208
310
  }
311
+ // Two rules Postgres has that a declaration can break, refused where the author wrote it.
312
+ // `@ultimat3/db` refuses both again at `createIndex` — that is not a duplicate, it is the
313
+ // guard for a description nobody built here — but its refusal lands at `x db gen` or, if a
314
+ // migration was already written, inside `ROLE=migrate` as the server's own syntax error with
315
+ // none of the entity's words in it.
316
+ if (index.using !== undefined && index.using !== 'btree') {
317
+ if (unique) {
318
+ throw invariantViolated(
319
+ name,
320
+ 'index',
321
+ `the index on (${columns.join(', ')}) is unique and ${index.using}; ` +
322
+ `Postgres has no unique ${index.using} index — drop unique, or drop using`,
323
+ );
324
+ }
325
+ if (index.order !== undefined) {
326
+ throw invariantViolated(
327
+ name,
328
+ 'index',
329
+ `the index on (${columns.join(', ')}) is ${index.using} and ${index.order}; ` +
330
+ 'only a btree orders its keys — drop order, or drop using',
331
+ );
332
+ }
333
+ }
209
334
  return {
210
- name: indexName(table, columns, unique),
335
+ name: indexName(name, table, columns, unique, index.order, where, index.using),
211
336
  columns,
212
337
  unique,
213
338
  ...(index.order === undefined ? {} : { order: index.order }),
214
339
  ...(where === null ? {} : { where }),
340
+ // Absent stays absent: `btree` written out would be a field every existing snapshot lacks,
341
+ // and `indexMethodOf` reads the two the same way precisely so nothing regenerates.
342
+ ...(index.using === undefined || index.using === 'btree' ? {} : { using: index.using }),
215
343
  };
216
344
  }),
217
345
  ];
218
- // A foreign key already indexes its column; naming it again in `indexes` is not two indexes.
346
+ /**
347
+ * A foreign key already indexes its column; naming it again in `indexes` is not two indexes.
348
+ *
349
+ * On the WHOLE definition and not on the name. With the discriminator above the two rules agree
350
+ * exactly, so this is not a behaviour change on its own — it is which one FAILS LOUDLY if the
351
+ * naming is ever weakened again. Matching on the name drops the second index in silence, which
352
+ * is how two different partial indexes became one for three majors; matching on the definition
353
+ * keeps both, and two `create index` statements sharing a name is `42P07` on the next migration.
354
+ */
355
+ const identity = (index: IndexDef): string =>
356
+ [
357
+ index.name,
358
+ index.columns.join(','),
359
+ index.unique,
360
+ index.order ?? '',
361
+ index.where ?? '',
362
+ index.using ?? '',
363
+ ].join('|');
219
364
  const indexes: readonly IndexDef[] = declared.filter(
220
- (index, position) => declared.findIndex((other) => other.name === index.name) === position,
365
+ (index, position) =>
366
+ declared.findIndex((other) => identity(other) === identity(index)) === position,
221
367
  );
222
368
 
223
369
  const tags = [cacheTag, ...(init.tags ?? [])];
package/src/errors.ts CHANGED
@@ -18,6 +18,9 @@ export const ENTITY_OWNED_ERROR_CODES = [
18
18
  'X_N_PLUS_ONE_QUERY',
19
19
  'X_N_PLUS_ONE_WRITE',
20
20
  'X_REPO_CLIENT_PINNED',
21
+ 'X_AGGREGATE_UNSUPPORTED',
22
+ 'X_AGGREGATE_MIXED_CURRENCY',
23
+ 'X_APPROXIMATE_COUNT_FILTERED',
21
24
  ] as const;
22
25
 
23
26
  /**
@@ -52,6 +55,9 @@ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityOwnedErrorCode, string>>
52
55
  X_N_PLUS_ONE_QUERY: 'a read repeated once per row',
53
56
  X_N_PLUS_ONE_WRITE: 'a write repeated once per row',
54
57
  X_REPO_CLIENT_PINNED: 'a repository pinned to its own client cannot join the open transaction',
58
+ X_AGGREGATE_UNSUPPORTED: 'that column has no aggregate both drivers can answer alike',
59
+ X_AGGREGATE_MIXED_CURRENCY: 'an amount was aggregated across currencies',
60
+ X_APPROXIMATE_COUNT_FILTERED: 'an estimate was asked of a filtered chain',
55
61
  };
56
62
 
57
63
  // Registered at module load, unconditionally, in one call. Without this the registry humanises the
package/src/index.ts CHANGED
@@ -3,6 +3,8 @@
3
3
  /** Re-exported so an `entity` file needs one import, not two. Same object as schema's. */
4
4
  export type { Infer } from '@ultimat3/schema';
5
5
  export { t } from '@ultimat3/schema';
6
+ export type { AggregateFn } from './aggregate';
7
+ export { AVG_SCALE } from './aggregate';
6
8
  export type { BatchIterator } from './batch';
7
9
  export type { MoneyColumns } from './column';
8
10
  export { columnName, moneyColumns, snake } from './column';
@@ -72,6 +74,7 @@ export {
72
74
  MAX_ASSERTED_ROWS,
73
75
  toSql,
74
76
  } from './invariants';
77
+ export { memoryRepo, memoryTransactor } from './memory-repo';
75
78
  export type { StatementLoop } from './n-plus-one';
76
79
  export { N_PLUS_ONE_THRESHOLD, nPlusOne, preloadsFor } from './n-plus-one';
77
80
  export type { PostgresDriverOptions } from './pg-driver';
@@ -111,7 +114,6 @@ export type {
111
114
  Tx,
112
115
  UpsertArgs,
113
116
  } from './repo';
114
- export { memoryRepo, memoryTransactor } from './repo';
115
117
  export type { RowBulkChange, RowChange, RowChangeOp, RowObserver } from './row-observer';
116
118
  export { observedRepo, rowObserver, setRowObserver } from './row-observer';
117
119
  export type {
package/src/instant.ts ADDED
@@ -0,0 +1,93 @@
1
+ // Single responsibility: a `timestamptz` value at the precision the COLUMN stores it, rather than
2
+ // the precision a JS `Date` holds. The column keeps microseconds and a `Date` keeps milliseconds,
3
+ // so a page position minted from a decoded row ranks rows differently from the `order by` that
4
+ // produced them. Microseconds since the epoch is the one form both sides can be exact in, and this
5
+ // file is the only place the two representations meet.
6
+
7
+ /** A `Date` is exactly this much coarser than the column it came out of. */
8
+ const MICROS_PER_MILLI = 1000n;
9
+
10
+ const MICROS_PER_SECOND = 1_000_000n;
11
+
12
+ const FRACTION_DIGITS = 6;
13
+
14
+ /**
15
+ * What `(col at time zone 'UTC')::text` prints: `2026-01-01 00:00:00.123456`, with the fraction
16
+ * omitted entirely when every digit of it is zero and TRUNCATED when the trailing ones are —
17
+ * `.1` is a tenth of a second, not one microsecond, which is why the fraction is padded on the
18
+ * right and never on the left.
19
+ *
20
+ * The year is `\d{4,}` because Postgres prints one wider than four digits unpadded; a `BC` suffix
21
+ * matches nothing here on purpose, and an unmatched text falls back to the decoded `Date`.
22
+ */
23
+ const PG_INSTANT_TEXT = /^(\d{4,})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?$/;
24
+
25
+ /**
26
+ * `Date.UTC` maps years 0–99 into the 1900s, so the epoch is built by assignment instead. Whole
27
+ * seconds only: the fraction is added in the microsecond domain, where it is exact.
28
+ */
29
+ const utcSecondMillis = (parts: readonly number[]): number => {
30
+ const [year = 0, month = 1, day = 1, hour = 0, minute = 0, second = 0] = parts;
31
+ const at = new Date(0);
32
+ at.setUTCFullYear(year, month - 1, day);
33
+ at.setUTCHours(hour, minute, second, 0);
34
+ return at.getTime();
35
+ };
36
+
37
+ /** Floor division: `-1n / 2n` truncates toward zero, which would place a pre-1970 instant late. */
38
+ const floorDiv = (value: bigint, by: bigint): bigint => {
39
+ const remainder = ((value % by) + by) % by;
40
+ return (value - remainder) / by;
41
+ };
42
+
43
+ /**
44
+ * The exact microsecond epoch of a `timestamptz` Postgres rendered as text, or `undefined` when
45
+ * the text is not one — a caller with nothing to read falls back to the decoded `Date`, which is
46
+ * the position it always had.
47
+ */
48
+ export const pgInstantMicros = (text: unknown): bigint | undefined => {
49
+ if (typeof text !== 'string') return undefined;
50
+ const match = PG_INSTANT_TEXT.exec(text);
51
+ if (match === null) return undefined;
52
+ const [, year = '', month = '', day = '', hour = '', minute = '', second = '', fraction] = match;
53
+ const millis = utcSecondMillis([year, month, day, hour, minute, second].map(Number));
54
+ if (!Number.isFinite(millis)) return undefined;
55
+ // The fraction is always forward in time, so it ADDS even when the second boundary is negative.
56
+ return BigInt(millis) * MICROS_PER_MILLI + BigInt((fraction ?? '').padEnd(FRACTION_DIGITS, '0'));
57
+ };
58
+
59
+ /**
60
+ * The microsecond epoch of whatever a sort key is holding: a decoded row's `Date` (milliseconds,
61
+ * so the last three digits are zero), a value already counted in microseconds, or the decimal a
62
+ * cursor carries. `undefined` for anything else, so a caller decides rather than guessing at `0`.
63
+ */
64
+ export const instantMicros = (value: unknown): bigint | undefined => {
65
+ if (typeof value === 'bigint') return value;
66
+ if (value instanceof Date) {
67
+ const millis = value.getTime();
68
+ return Number.isNaN(millis) ? undefined : BigInt(millis) * MICROS_PER_MILLI;
69
+ }
70
+ if (typeof value === 'string' && /^-?\d+$/.test(value)) return BigInt(value);
71
+ return undefined;
72
+ };
73
+
74
+ /**
75
+ * The instant a seek binds, spelled so Postgres parses it back to the same microsecond. ISO 8601
76
+ * in UTC with all six fraction digits — `toISOString()` alone is milliseconds, which is the whole
77
+ * defect this file exists to close, so the fraction is written here rather than read off the
78
+ * `Date`.
79
+ */
80
+ export const microsToIso = (micros: bigint): string => {
81
+ const second = floorDiv(micros, MICROS_PER_SECOND);
82
+ const fraction = micros - second * MICROS_PER_SECOND;
83
+ const whole = new Date(Number(second) * 1000).toISOString();
84
+ return `${whole.slice(0, whole.indexOf('.'))}.${String(fraction).padStart(FRACTION_DIGITS, '0')}Z`;
85
+ };
86
+
87
+ /**
88
+ * The output name the microsecond half of a sort key comes back under. Every physical column name
89
+ * in this framework is lower case — `columnName` is either `snake(property)`, which lower-cases,
90
+ * or a `.column()` name `assertColumnName` refuses unless it matches `[a-z_][a-z0-9_$]*` — so an
91
+ * UPPER-CASE suffix is a name no entity can declare and this alias can never shadow a column.
92
+ */
93
+ export const seekAlias = (physicalColumn: string): string => `${physicalColumn}$US`;
@@ -6,9 +6,11 @@
6
6
 
7
7
  import { compareDecimalText } from '@ultimat3/core';
8
8
  import { keyOf } from './batch-read';
9
+ import { arrayContains, arrayOverlaps, jsonContains, jsonHasKey } from './containment';
9
10
  import { kindOf, valueAt } from './cursor';
10
11
  import type { EntityCore } from './entity';
11
12
  import { EntityError } from './errors';
13
+ import { instantMicros } from './instant';
12
14
  import type { Predicate } from './tenancy';
13
15
  import type { ColumnKind } from './types';
14
16
 
@@ -46,6 +48,25 @@ export const compareByKind = (
46
48
  left: unknown,
47
49
  right: unknown,
48
50
  ): number => {
51
+ // NULL is the LARGEST value, which is what `order by` means in Postgres and what `nulls last`
52
+ // ascending / `nulls first` descending spell out (`pg-sql.ts`'s `orderSql`, `@ultimat3/query`'s
53
+ // `compareValues`). Two absences are equal, so the next sort key decides — without this rule a
54
+ // NULL fell through to `String(left) < String(right)` and sorted as the four characters `null`,
55
+ // somewhere in the middle of the alphabet, which is a different listing from the one the
56
+ // database returns and a page boundary cut where the server cuts none.
57
+ if (isNull(left) || isNull(right)) {
58
+ return isNull(left) && isNull(right) ? 0 : isNull(left) ? 1 : -1;
59
+ }
60
+ // A `timestamptz` is compared in MICROSECONDS, which is what the column holds and what a cursor
61
+ // now carries (`cursor.ts`). The two sides are not the same shape and that is the point: a
62
+ // stored row here is a `Date` and a keyset position is a microsecond count, so a `Date`/`Date`
63
+ // test alone would fall through to `String(left) < String(right)` and order a page by the text
64
+ // of an ISO string against a decimal.
65
+ if (kind === 'timestamptz') {
66
+ const before = instantMicros(left);
67
+ const after = instantMicros(right);
68
+ if (before !== undefined && after !== undefined) return sign(before, after);
69
+ }
49
70
  if (left instanceof Date && right instanceof Date) return sign(left.getTime(), right.getTime());
50
71
  if (kind !== undefined && DECIMAL_TEXT.has(kind)) {
51
72
  // `undefined` when either side is not a plain decimal — that pair is not a numeric comparison,
@@ -199,5 +220,51 @@ export const matchesPredicate = <Row>(
199
220
  return isNull(actual);
200
221
  case 'is-not-null':
201
222
  return !isNull(actual);
223
+ // The containment half, decided by the column's DECLARED kind exactly as everything above it
224
+ // is: `@>` on a `jsonb` is recursive structural containment and `@>` on an array is plain
225
+ // element containment, and those are two different operators that happen to share a symbol.
226
+ // A NULL column value matches nothing, which is what the SQL answers too.
227
+ case 'contains':
228
+ return !isNull(actual) && containsBy(kind, actual, predicate.value);
229
+ case 'contained-by':
230
+ return !isNull(actual) && containsBy(kind, predicate.value, actual);
231
+ // `&&` is arrays only. A `jsonb` column reaching it is refused rather than guessed at: there
232
+ // is no `jsonb && jsonb` in Postgres, so any answer here would be one no statement can make.
233
+ case 'overlaps':
234
+ return (
235
+ !isNull(actual) &&
236
+ arrayOverlaps(
237
+ asArray(entity, predicate, actual),
238
+ asArray(entity, predicate, predicate.value),
239
+ )
240
+ );
241
+ case 'has-key':
242
+ return jsonHasKey(actual, predicate.value);
243
+ }
244
+ };
245
+
246
+ /** `left @> right`, under the rule the LEFT column's kind decides. */
247
+ const containsBy = (kind: ColumnKind | undefined, left: unknown, right: unknown): boolean =>
248
+ kind === 'jsonb'
249
+ ? jsonContains(left, right)
250
+ : arrayContains(Array.isArray(left) ? left : [left], Array.isArray(right) ? right : [right]);
251
+
252
+ /**
253
+ * The operand of an array-only operator. A `jsonb` column here is the caller asking for an
254
+ * operator Postgres does not have on that type, so it is refused where they wrote it rather than
255
+ * answered with something the database never would.
256
+ */
257
+ const asArray = <Row>(
258
+ entity: EntityCore<Row>,
259
+ predicate: Predicate,
260
+ value: unknown,
261
+ ): readonly unknown[] => {
262
+ if (kindOf(entity, predicate.column) === 'jsonb') {
263
+ throw new EntityError({
264
+ code: 'X_INVARIANT_VIOLATED',
265
+ cause: `${entity.$name}.${predicate.column} is jsonb, and Postgres has no && (overlaps) operator for jsonb`,
266
+ fix: `${entity.$name}.andWhere('${predicate.column}', 'contains', <value>) # @> matches nested structure; && is for arrayOf() columns`,
267
+ });
202
268
  }
269
+ return Array.isArray(value) ? value : [value];
203
270
  };