@ultimat3/entity 3.0.0 → 4.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,169 @@
1
+ // Single responsibility: what a `Predicate` MEANS in the in-memory driver — equality, ordering and
2
+ // LIKE. Every rule here exists so the answer matches the one Postgres gives for the same predicate
3
+ // on the same column, which is why each is decided by the column's DECLARED KIND and never by the
4
+ // JS type of whichever value is in hand: the database decides by the column's type, so a driver
5
+ // deciding by `typeof` is answering a different question.
6
+
7
+ import { compareDecimalText } from '@ultimat3/core';
8
+ import { keyOf } from './batch-read';
9
+ import { kindOf, valueAt } from './cursor';
10
+ import type { EntityCore } from './entity';
11
+ import { EntityError } from './errors';
12
+ import type { Predicate } from './tenancy';
13
+ import type { ColumnKind } from './types';
14
+
15
+ /**
16
+ * The kinds whose ROW VALUE is a decimal string. `bigint()` and `decimal()` both hand back digits
17
+ * as text on purpose (`columns-data.ts`): a JS `bigint` is what `JSON.stringify` throws on and a
18
+ * `number` loses digits past 2^53, exactly where a legacy `int8` key lives.
19
+ *
20
+ * Which makes them the kinds no `typeof` branch can catch. `compare` had a `number`/`number` case
21
+ * and a `bigint`/`bigint` case and neither fired for these, so both fell to
22
+ * `String(left) < String(right)`: memory answered `["10","100","2","9"]` where Postgres answers
23
+ * `["2","9","10","100"]`, and a keyset page boundary was cut where the database never cuts one.
24
+ *
25
+ * This SET is the whole of what this package contributes; the comparison itself is
26
+ * `@ultimat3/core`'s `compareDecimalText`. The split is the point — the text arrives in more than
27
+ * one package and the DECLARED KIND does not, so a caller with no column kinds
28
+ * (`@ultimat3/query`, whose `OrderKey` is a name and a direction) deliberately never asks: a
29
+ * `text` column holding `"10"` and `"9"` is ordered lexically by Postgres, and a comparator
30
+ * guessing "both sides look like decimals" would trade this disagreement for that one.
31
+ */
32
+ const DECIMAL_TEXT: ReadonlySet<ColumnKind> = new Set<ColumnKind>(['bigint', 'numeric']);
33
+
34
+ const sign = <T extends number | bigint | string>(left: T, right: T): number =>
35
+ left < right ? -1 : left > right ? 1 : 0;
36
+
37
+ /**
38
+ * Two values of one column, ordered as Postgres orders that column. `-1`, `0` or `1` — never a
39
+ * difference, so a `bigint` pair needs no subtraction it cannot express in a `number`.
40
+ */
41
+ export const compareByKind = (
42
+ kind: ColumnKind | undefined,
43
+ left: unknown,
44
+ right: unknown,
45
+ ): number => {
46
+ if (left instanceof Date && right instanceof Date) return sign(left.getTime(), right.getTime());
47
+ if (kind !== undefined && DECIMAL_TEXT.has(kind)) {
48
+ // `undefined` when either side is not a plain decimal — that pair is not a numeric comparison,
49
+ // so it falls through to the branches below rather than being guessed at.
50
+ const exact = compareDecimalText(left, right);
51
+ if (exact !== undefined) return exact;
52
+ }
53
+ if (typeof left === 'number' && typeof right === 'number') return sign(left, right);
54
+ if (typeof left === 'bigint' && typeof right === 'bigint') return sign(left, right);
55
+ return sign(String(left), String(right));
56
+ };
57
+
58
+ /**
59
+ * Equality, in the two places `===` is not what the database means. A `Date` compares by identity,
60
+ * so `where({ publishedAt })` would match nothing here and every row there. And Postgres compares a
61
+ * `uuid` as a VALUE — it parses the text and prints it lower-cased — so an id handed in upper case
62
+ * matches the row there and used to miss it here, which is `findById(UPPER)` answering `null` in
63
+ * memory and the row in production. `keyOf` is where that rule already lived, for the batched read.
64
+ */
65
+ export const sameValueOfKind = (
66
+ kind: ColumnKind | undefined,
67
+ left: unknown,
68
+ right: unknown,
69
+ ): boolean => {
70
+ if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime();
71
+ if (kind === 'uuid' && typeof left === 'string' && typeof right === 'string') {
72
+ return keyOf('uuid', left) === keyOf('uuid', right);
73
+ }
74
+ return left === right;
75
+ };
76
+
77
+ const REGEX_SPECIAL = /[.*+?^${}()|[\]\\]/g;
78
+
79
+ const quote = (text: string): string => text.replace(REGEX_SPECIAL, '\\$&');
80
+
81
+ /**
82
+ * Postgres answers a `LIKE` pattern ending in the escape character with `22025 — LIKE pattern must
83
+ * not end with escape character`, so a pattern that means nothing there means nothing here either.
84
+ * The pattern itself is never echoed: a filter value is app data, and this cause is rendered into
85
+ * a log line.
86
+ */
87
+ const danglingEscape = (entityName: string): EntityError =>
88
+ new EntityError({
89
+ code: 'X_INVARIANT_VIOLATED',
90
+ cause: `${entityName}: a like pattern ends with a backslash, which is the escape character — Postgres answers that pattern with 22025 (LIKE pattern must not end with escape character)`,
91
+ fix: "double it — 'a\\\\' is the pattern that matches one literal backslash, and 'a\\%b' matches a literal %",
92
+ });
93
+
94
+ /**
95
+ * A SQL `LIKE` pattern as a regex, with Postgres' DEFAULT escape handling: `%` and `_` are the
96
+ * wildcards, a backslash escapes either (or itself), and everything else is literal.
97
+ *
98
+ * The backslash used to be quoted for the regex BEFORE the wildcards were expanded, so `'a\%b'`
99
+ * matched the literal `a%b` in Postgres and `a\<anything>b` here — one pattern, two meanings, and
100
+ * the driver that disagreed was the one every test runs against.
101
+ *
102
+ * A RUN of `%` is still one `.*`, not one each: `%%%…x` compiled to twenty adjacent `.*` groups,
103
+ * and an anchored regex with twenty of them takes exponential time to fail on a long value — a
104
+ * filter value forwarded from a search box is then a CPU stall in the process. Postgres reads a run
105
+ * of `%` as one wildcard too, so this is the two drivers agreeing rather than a defensive
106
+ * narrowing.
107
+ */
108
+ const likePattern = (entityName: string, pattern: string): RegExp => {
109
+ let source = '';
110
+ let at = 0;
111
+ while (at < pattern.length) {
112
+ const char = pattern[at];
113
+ if (char === '\\') {
114
+ const escaped = pattern[at + 1];
115
+ if (escaped === undefined) throw danglingEscape(entityName);
116
+ source += quote(escaped);
117
+ at += 2;
118
+ continue;
119
+ }
120
+ if (char === '%') {
121
+ while (pattern[at] === '%') at += 1;
122
+ source += '.*';
123
+ continue;
124
+ }
125
+ source += char === '_' ? '.' : quote(char ?? '');
126
+ at += 1;
127
+ }
128
+ return new RegExp(`^${source}$`, 's');
129
+ };
130
+
131
+ /** One predicate against one stored row, in the meaning the Postgres driver compiles it to. */
132
+ export const matchesPredicate = <Row>(
133
+ entity: EntityCore<Row>,
134
+ row: unknown,
135
+ predicate: Predicate,
136
+ ): boolean => {
137
+ // The column's declared kind, resolved once — `price.minor` included, which is the path a money
138
+ // predicate and a money sort key both name.
139
+ const kind = kindOf(entity, predicate.column);
140
+ const actual = valueAt(row, predicate.column);
141
+ const same = (candidate: unknown): boolean => sameValueOfKind(kind, actual, candidate);
142
+ const order = (): number => compareByKind(kind, actual, predicate.value);
143
+ switch (predicate.op) {
144
+ case 'eq':
145
+ return same(predicate.value);
146
+ case 'neq':
147
+ return !same(predicate.value);
148
+ // `in` reads a LIST or nothing: an operand that is not an array matches no row, which is what
149
+ // `predicateSql` now compiles it to and what `@ultimat3/query` answers for the same operand.
150
+ case 'in':
151
+ return Array.isArray(predicate.value) && predicate.value.some(same);
152
+ case 'gt':
153
+ return order() > 0;
154
+ case 'gte':
155
+ return order() >= 0;
156
+ case 'lt':
157
+ return order() < 0;
158
+ case 'lte':
159
+ return order() <= 0;
160
+ // Real LIKE semantics, so `'draft%'` means "starts with" here exactly as it does in Postgres.
161
+ // Treating the pattern as a substring would make the two drivers disagree.
162
+ case 'like':
163
+ return likePattern(entity.$name, String(predicate.value)).test(String(actual));
164
+ case 'is-null':
165
+ return actual === null || actual === undefined;
166
+ case 'is-not-null':
167
+ return actual !== null && actual !== undefined;
168
+ }
169
+ };
package/src/pg-driver.ts CHANGED
@@ -7,7 +7,6 @@
7
7
  // being told — which is how `ctx.jobs.enqueue()` lands its outbox row atomically with the write
8
8
  // that caused it. `RepoOptions.tx` is the in-memory driver's undo hook and is ignored here.
9
9
 
10
- import { systemClock } from '@ultimat3/core';
11
10
  import {
12
11
  currentTx,
13
12
  type DbClient,
@@ -24,6 +23,7 @@ import {
24
23
  namedProperties,
25
24
  upsertPlan,
26
25
  } from './bulk-write';
26
+ import { entityNow } from './clock';
27
27
  import { coalesceFindById } from './coalesce';
28
28
  import { countsFrom, groupColumnOf, groupValue, MAX_GROUPS } from './count-by';
29
29
  import { cursorFor, seekFrom, valueAt } from './cursor';
@@ -132,7 +132,7 @@ export const postgresRepo = <Row>(
132
132
  ? updateStatement(
133
133
  entity,
134
134
  plan,
135
- new Map([[physicalName(entity, SOFT_DELETE_COLUMN), systemClock.now()]]),
135
+ new Map([[physicalName(entity, SOFT_DELETE_COLUMN), entityNow()]]),
136
136
  shapeOf({}),
137
137
  false,
138
138
  )
package/src/pg-row.ts CHANGED
@@ -4,11 +4,11 @@
4
4
  // from the driver is re-parsed by the column that declared it rather than trusted — int8 arrives
5
5
  // as a string, timestamptz may arrive as one, and a silent `NaN` is worse than a loud throw.
6
6
 
7
- import { columnName, moneyColumns } from './column';
7
+ import { columnFor, columnName, moneyColumns } from './column';
8
8
  import { narrowMoney } from './columns';
9
9
  import type { EntityCore } from './entity';
10
10
  import { invariantViolated } from './errors';
11
- import type { AnyColumn, MoneyValue } from './types';
11
+ import type { AnyColumn, MoneyValue, RowPatch } from './types';
12
12
 
13
13
  export type PhysicalRow = Readonly<Record<string, unknown>>;
14
14
 
@@ -39,7 +39,7 @@ export const columnsOf = (property: string, column: AnyColumn): readonly string[
39
39
  */
40
40
  export const physicalName = <Row>(entity: EntityCore<Row>, path: string): string => {
41
41
  const [property = path, part] = path.split('.');
42
- const column = entity.$columns[property];
42
+ const column = columnFor(entity.$columns, property);
43
43
  if (column === undefined) {
44
44
  throw invariantViolated(
45
45
  entity.$name,
@@ -73,7 +73,7 @@ export const allColumns = <Row>(entity: EntityCore<Row>): readonly string[] =>
73
73
  */
74
74
  export const bindValues = <Row>(
75
75
  entity: EntityCore<Row>,
76
- values: Partial<Row>,
76
+ values: RowPatch<Row>,
77
77
  ): ReadonlyMap<string, unknown> => {
78
78
  const bound = new Map<string, unknown>();
79
79
  // `MoneyInput` lets a writer hand a `bigint`; the row type is `MoneyValue`. `memoryRepo` calls
package/src/pg-sql.ts CHANGED
@@ -4,7 +4,7 @@
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 { columnName } from './column';
7
+ import { columnFor, columnName } from './column';
8
8
  import type { EntityCore } from './entity';
9
9
  import { SOFT_DELETE_COLUMN } from './entity';
10
10
  import { allColumns, columnsOf, physicalName } from './pg-row';
@@ -33,10 +33,23 @@ const predicateSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFr
33
33
  // `is distinct from` so a null on either side compares as a value, not as unknown.
34
34
  return sql`${column} is distinct from ${value}`;
35
35
  case 'in': {
36
- const values = Array.isArray(value) ? value : [value];
37
- return values.length === 0
38
- ? NEVER
39
- : sql`${column} in (${join(values.map((each) => sql`${each}`))})`;
36
+ // `in` reads a LIST or nothing. A scalar used to be wrapped into a one-element list, which
37
+ // matched a row here that `memoryRepo`'s `matches` refuses outright — 0 rows in memory, 1 in
38
+ // Postgres, from a call `andWhere(column, op, value: unknown)` compiles. One answer, and it
39
+ // is the one `@ultimat3/query`'s `filterClause` already gives: no rows.
40
+ if (!Array.isArray(value)) return NEVER;
41
+ // A NULL bound as a parameter is `col = null`, which is UNKNOWN and therefore excludes the
42
+ // very row the caller listed — while memory's `sameValue(null, null)` includes it. Postgres
43
+ // has no `in` that compares a null as a value, so the list is partitioned and the nulls are
44
+ // asked for as `is null`: the `(… in (…) or … is null)` pair `eq` and `neq` already emit.
45
+ const present = value.filter((each) => each !== null && each !== undefined);
46
+ const list =
47
+ present.length === 0
48
+ ? undefined
49
+ : sql`${column} in (${join(present.map((e) => sql`${e}`))})`;
50
+ const nulls = present.length === value.length ? undefined : sql`${column} is null`;
51
+ if (list === undefined) return nulls ?? NEVER;
52
+ return nulls === undefined ? list : sql`(${list} or ${nulls})`;
40
53
  }
41
54
  case 'gt':
42
55
  return sql`${column} > ${value}`;
@@ -137,7 +150,7 @@ const projection = <Row>(entity: EntityCore<Row>, plan: QueryPlan): SqlFragment
137
150
  ...plan.orderBy.map((entry) => entry.column.split('.')[0] ?? entry.column),
138
151
  ]);
139
152
  const names = [...wanted].flatMap((property) => {
140
- const column = entity.$columns[property];
153
+ const column = columnFor(entity.$columns, property);
141
154
  return column === undefined ? [] : columnsOf(property, column);
142
155
  });
143
156
  return join(names.map(identifier));
package/src/plan.ts CHANGED
@@ -8,6 +8,7 @@ import { EntityError, invariantViolated, patchEmpty, writeUnfiltered } from './e
8
8
  import type { FindManyArgs, RepoOptions } from './repo';
9
9
  import type { Predicate, QueryPlan, SortKey } from './tenancy';
10
10
  import { scopedPlan } from './tenancy';
11
+ import type { RowPatch } from './types';
11
12
 
12
13
  /** A page is bounded by default; an unbounded read is a production incident waiting for traffic. */
13
14
  export const DEFAULT_PAGE_SIZE = 50;
@@ -146,7 +147,7 @@ export const namedColumns = (values: unknown): readonly (readonly [string, unkno
146
147
  /** The filter a filtered write is allowed to run with: never the empty one. */
147
148
  const boundedWhere = <Row>(
148
149
  entity: EntityCore<Row>,
149
- filter: Partial<Row>,
150
+ filter: RowPatch<Row>,
150
151
  operation: string,
151
152
  ): Predicate[] => {
152
153
  const where = namedColumns(filter).map(
@@ -166,7 +167,7 @@ const boundedWhere = <Row>(
166
167
  */
167
168
  export const deletePlan = <Row>(
168
169
  entity: EntityCore<Row>,
169
- filter: Partial<Row>,
170
+ filter: RowPatch<Row>,
170
171
  options: RepoOptions | undefined,
171
172
  operation: string,
172
173
  ): QueryPlan =>
@@ -179,8 +180,8 @@ export const deletePlan = <Row>(
179
180
  */
180
181
  export const updatePlan = <Row>(
181
182
  entity: EntityCore<Row>,
182
- filter: Partial<Row>,
183
- patch: Partial<Row>,
183
+ filter: RowPatch<Row>,
184
+ patch: RowPatch<Row>,
184
185
  options: RepoOptions | undefined,
185
186
  operation: string,
186
187
  ): QueryPlan => {
package/src/query.ts CHANGED
@@ -4,9 +4,9 @@
4
4
  // concurrent writes an insert before the offset shifts every later page, so a client silently
5
5
  // skips and repeats rows.
6
6
 
7
- import { systemClock } from '@ultimat3/core';
8
7
  import type { BatchIterator } from './batch';
9
8
  import { assertBatchable, batchIterator } from './batch';
9
+ import { entityNow } from './clock';
10
10
  import type { EntityCore } from './entity';
11
11
  import { assertPageSize, DEFAULT_PAGE_SIZE, namedColumns } from './plan';
12
12
  import type { RelatedTables } from './preload';
@@ -15,7 +15,7 @@ import type { Relation } from './relations';
15
15
  import { relationNamed } from './relations';
16
16
  import type { Page, Repo, RepoOptions, UpsertArgs } from './repo';
17
17
  import type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
18
- import type { ColumnMap, IdOf, Insertable } from './types';
18
+ import type { ColumnMap, IdOf, Insertable, RowPatch } from './types';
19
19
 
20
20
  /**
21
21
  * What a preloaded relation adds to a row. `unknown` because the name is a string resolved at
@@ -26,7 +26,7 @@ export type Preloaded<Name extends string> = { readonly [K in Name]: unknown };
26
26
 
27
27
  export interface ReadBuilder<Row> {
28
28
  /** Equality on the columns given. `where({ orgId })` is what satisfies the tenancy guard. */
29
- where(filter: Partial<Row>): ReadBuilder<Row>;
29
+ where(filter: RowPatch<Row>): ReadBuilder<Row>;
30
30
  andWhere(column: keyof Row & string, op: Operator, value: unknown): ReadBuilder<Row>;
31
31
  orderBy(column: keyof Row & string, direction?: SortDirection): ReadBuilder<Row>;
32
32
  limit(rows: number): ReadBuilder<Row>;
@@ -106,21 +106,21 @@ export interface Table<Row, C extends ColumnMap = ColumnMap> extends ReadBuilder
106
106
  */
107
107
  upsertAll(rows: readonly Insertable<C>[], args: UpsertArgs<Row>): Promise<readonly Row[]>;
108
108
  /** `IdOf<Row>`: an entity that declared `uuid<PostId>()` is addressed by a `PostId` only. */
109
- update(id: IdOf<Row>, patch: Partial<Row>, options?: RepoOptions): Promise<Row>;
109
+ update(id: IdOf<Row>, patch: RowPatch<Row>, options?: RepoOptions): Promise<Row>;
110
110
  delete(id: IdOf<Row>, options?: RepoOptions): Promise<void>;
111
111
  /**
112
112
  * Delete by equality filter; resolves with the number of rows removed. The only way to remove a
113
113
  * row from an entity whose primary key is composite — `likes`, `blocks`, a join table — where
114
114
  * one id cannot name it. `deleteWhere({})` is `X_WRITE_UNFILTERED`, never every row.
115
115
  */
116
- deleteWhere(filter: Partial<Row>, options?: RepoOptions): Promise<number>;
116
+ deleteWhere(filter: RowPatch<Row>, options?: RepoOptions): Promise<number>;
117
117
  /**
118
118
  * Update by equality filter; resolves with the number of rows written. The `update(id, patch)`
119
119
  * a composite primary key cannot express — `participants.updateWhere({ conversationId, userId },
120
120
  * { lastReadAt })` is the reference case. Empty filter: `X_WRITE_UNFILTERED`. Empty patch:
121
121
  * `X_PATCH_EMPTY`. `onUpdateNow()` columns are stamped exactly as `update(id, patch)` stamps them.
122
122
  */
123
- updateWhere(filter: Partial<Row>, patch: Partial<Row>, options?: RepoOptions): Promise<number>;
123
+ updateWhere(filter: RowPatch<Row>, patch: RowPatch<Row>, options?: RepoOptions): Promise<number>;
124
124
  }
125
125
 
126
126
  interface State {
@@ -311,7 +311,7 @@ const touch = <Row, Patch>(entity: EntityCore<Row>, patch: Patch): Patch => {
311
311
  if (namedColumns(patch).length === 0) return patch;
312
312
  const stamped: Record<string, unknown> = {};
313
313
  for (const [property, column] of Object.entries(entity.$columns)) {
314
- if (column.$meta.onUpdate !== undefined) stamped[property] = systemClock.now();
314
+ if (column.$meta.onUpdate !== undefined) stamped[property] = entityNow();
315
315
  }
316
316
  return Object.assign({}, patch, stamped);
317
317
  };
package/src/registry.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { entityDuplicate } from './errors';
7
7
  import type { InvariantKind } from './invariants';
8
+ import type { OnDelete } from './types';
8
9
 
9
10
  export interface ColumnDescription {
10
11
  readonly property: string;
@@ -16,6 +17,13 @@ export interface ColumnDescription {
16
17
  readonly hasDefault: boolean;
17
18
  readonly check: string | null;
18
19
  readonly references: string | null;
20
+ /**
21
+ * The `references()` rule, `null` when the key declared none. Beside `references` rather than
22
+ * inside it because that field is a flat `"<table>.<column>"` string with no room for it, and
23
+ * `@ultimat3/db` is tier 1: it cannot import this package, so a rule that is not on this
24
+ * projection reaches no `alter table` at all. It reached none until 3.0.
25
+ */
26
+ readonly onDelete: OnDelete | null;
19
27
  }
20
28
 
21
29
  /**
@@ -35,6 +43,8 @@ export interface ReferenceDescription {
35
43
  readonly targetEntity: string;
36
44
  readonly targetProperty: string;
37
45
  readonly targetColumn: string;
46
+ /** What the database does to this row when the target goes. `null` is Postgres' `no action`. */
47
+ readonly onDelete: OnDelete | null;
38
48
  }
39
49
 
40
50
  export interface InvariantDescription {
package/src/relations.ts CHANGED
@@ -204,7 +204,10 @@ export const relationsFor = (entityName: string): EntityRelations =>
204
204
  */
205
205
  export const relationNamed = (entityName: string, name: string): Relation => {
206
206
  const relations = relationsFor(entityName);
207
- const relation = relations[name];
207
+ // `relations[name]` walks the prototype: `preload('toString')` used to hand back
208
+ // `Function.prototype.toString` AS a `Relation`, past the refusal, to be read for a `.through`
209
+ // it does not have. A relation map is derived from foreign keys, so a name is caller data here.
210
+ const relation = Object.hasOwn(relations, name) ? relations[name] : undefined;
208
211
  if (relation === undefined) {
209
212
  throw preloadUnknownRelation(entityName, name, Object.keys(relations));
210
213
  }