@ultimat3/entity 23.0.0 → 24.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.
@@ -1,12 +1,19 @@
1
1
  // The entity layer's code registry, its error class, and the two refusals the declaration path
2
2
  // raises — split from `errors.ts` so a module the BROWSER loads (the record key, the projection,
3
- // the registry) can raise one without importing `@ultimat3/db`, which `errors.ts` needs for
4
- // `dbDrift`'s shell-inert fix line.
3
+ // the registry) can raise one without importing `@ultimat3/db`.
5
4
  import type { ErrorRetry } from '@ultimat3/core';
6
- import { registerErrorCodes, registerErrorRetry, UltimateError } from '@ultimat3/core';
5
+ import {
6
+ isFixShellSafe,
7
+ registerErrorCodes,
8
+ registerErrorRetry,
9
+ UltimateError,
10
+ } from '@ultimat3/core';
7
11
 
8
- /** Codes this package declares and owns. */
9
- export const ENTITY_OWNED_ERROR_CODES = [
12
+ /**
13
+ * Every code this package throws, and it owns each one. `X_DB_DRIFT` is `@ultimat3/db`'s and is
14
+ * raised there — drift is a fact about migrations.
15
+ */
16
+ export const ENTITY_ERROR_CODES = [
10
17
  'X_ENTITY_DUPLICATE',
11
18
  'X_INVARIANT_VIOLATED',
12
19
  'X_TENANCY_UNSCOPED',
@@ -33,23 +40,9 @@ export const ENTITY_OWNED_ERROR_CODES = [
33
40
  'X_ENTITY_SEALED_IN_VIEW',
34
41
  ] as const;
35
42
 
36
- /**
37
- * `X_DB_DRIFT` is `@ultimat3/db`'s — drift is a fact about migrations, and this package imports db
38
- * rather than the other way round. `dbDrift()` below throws it; nothing here titles it, because a
39
- * second copy of the title is what lets the two packages disagree about what the code means.
40
- */
41
- export const ENTITY_BORROWED_ERROR_CODES = ['X_DB_DRIFT'] as const;
42
-
43
- /** Every code entity can throw: the ones it owns plus the one it borrows. */
44
- export const ENTITY_ERROR_CODES = [
45
- ...ENTITY_OWNED_ERROR_CODES,
46
- ...ENTITY_BORROWED_ERROR_CODES,
47
- ] as const;
48
-
49
- export type EntityOwnedErrorCode = (typeof ENTITY_OWNED_ERROR_CODES)[number];
50
43
  export type EntityErrorCode = (typeof ENTITY_ERROR_CODES)[number];
51
44
 
52
- export const ENTITY_ERROR_TITLES: Readonly<Record<EntityOwnedErrorCode, string>> = {
45
+ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityErrorCode, string>> = {
53
46
  X_ENTITY_DUPLICATE: 'two entities claim the same name',
54
47
  X_INVARIANT_VIOLATED: 'a domain invariant rejected this row',
55
48
  X_TENANCY_UNSCOPED: 'a tenant-scoped query has no org predicate',
@@ -96,7 +89,7 @@ registerErrorCodes(
96
89
  export const ENTITY_ERROR_RETRY = {
97
90
  X_ENTITY_SEALED_PREDICATE: 'terminal',
98
91
  X_ENTITY_SEALED_IN_VIEW: 'terminal',
99
- } as const satisfies Readonly<Partial<Record<EntityOwnedErrorCode, ErrorRetry>>>;
92
+ } as const satisfies Readonly<Partial<Record<EntityErrorCode, ErrorRetry>>>;
100
93
 
101
94
  registerErrorRetry(ENTITY_ERROR_RETRY);
102
95
 
@@ -120,6 +113,18 @@ export class EntityError extends UltimateError {
120
113
  }
121
114
  }
122
115
 
116
+ /**
117
+ * The one command a BOUND refusal hands back — a page size, a preload ceiling, a judged write: it
118
+ * runs, and it names the real entity. The edit itself is code on a chain whose variable no error
119
+ * can know (`db.members`, not `preload_test_members`), so it is spelled in the `cause` as the call
120
+ * to write, never in the `fix` as though the entity name were that variable. A name a shell would
121
+ * read is not spliced.
122
+ */
123
+ export const describeCommand = (entityName: string): string =>
124
+ isFixShellSafe(entityName)
125
+ ? `x entities describe ${entityName} --json`
126
+ : 'x entities list --json';
127
+
123
128
  /**
124
129
  * The entity name is a VALUE, never a literal — `entity.$name`, `table`, the `name` `entity()` was
125
130
  * given. A literal is an entity that does not exist, and this fix then hands the reader
package/src/entity.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  import type { IndexMethod } from '@ultimat3/db';
7
7
  import { describeValue, type Schema } from '@ultimat3/schema';
8
8
  import { entityNow } from './clock';
9
- import { assertColumnName, bindColumn, columnName, moneyColumns } from './column';
9
+ import { assertTableName, bindColumn, columnName, moneyColumns } from './column';
10
10
  import { newId } from './columns';
11
11
  import { describeEntity, describeReferences } from './describe';
12
12
  import { invariantViolated } from './errors';
@@ -186,7 +186,7 @@ export const entity = <const C extends ColumnMap>(
186
186
  // Both branches. The declared table was checked and the fallback — which is every entity that
187
187
  // does not rename its table — was not, so an entity NAME closed the identifier in the same way a
188
188
  // column name could: `entity('t" (x int); drop table u; --')` emitted that `drop table` verbatim.
189
- const table = assertColumnName(init.table ?? name);
189
+ const table = assertTableName(name, init.table ?? name);
190
190
  const cacheTag = `entity:${name}`;
191
191
  const softDelete = Object.hasOwn(init.columns, SOFT_DELETE_COLUMN);
192
192
  const tenantColumn = resolveTenantColumn(name, init.columns, init.tenant);
@@ -242,10 +242,7 @@ export const entity = <const C extends ColumnMap>(
242
242
  return part === 'minor' ? parts.minor : parts.currency;
243
243
  };
244
244
 
245
- const columnsExpr = invariantColumns<C>(
246
- name,
247
- entries.map(([property]) => property),
248
- );
245
+ const columnsExpr = invariantColumns<C>(name, init.columns);
249
246
  // Through the one resolver, so a soft-delete column the table spells differently is still the
250
247
  // column every partial index excludes rows by.
251
248
  const partialWhere = softDelete ? `${resolve([SOFT_DELETE_COLUMN])} is null` : undefined;
package/src/errors.ts CHANGED
@@ -1,16 +1,14 @@
1
- // The entity layer's stable error codes. Each factory produces the exact command
2
- // that fixes the situation — `X_DB_DRIFT` is the flagship: it names the table, the
3
- // column and the generator invocation. The code registry, the class, `invariantViolated` and
4
- // `entityDuplicate` live in `entity-error.ts`, which imports no `@ultimat3/db`; re-exported here.
5
- import { shellInertIdentifier } from '@ultimat3/db';
1
+ // The entity layer's stable error codes. Each factory produces the exact command that fixes the
2
+ // situation. The code registry, the class, `invariantViolated` and `entityDuplicate` live in
3
+ // `entity-error.ts`, which imports no `@ultimat3/db`; re-exported here. `X_DB_DRIFT` is not
4
+ // raised here at all: drift is `@ultimat3/db`'s, factory included.
6
5
  import { EntityError } from './entity-error';
7
6
 
8
- export type { EntityErrorCode, EntityOwnedErrorCode } from './entity-error';
7
+ export type { EntityErrorCode } from './entity-error';
9
8
  export {
10
- ENTITY_BORROWED_ERROR_CODES,
9
+ describeCommand,
11
10
  ENTITY_ERROR_CODES,
12
11
  ENTITY_ERROR_TITLES,
13
- ENTITY_OWNED_ERROR_CODES,
14
12
  EntityError,
15
13
  entityDuplicate,
16
14
  invariantViolated,
@@ -165,20 +163,18 @@ export const crossTenantDenied = (init: {
165
163
  });
166
164
 
167
165
  /**
168
- * A repository built with an explicit `client`, used while a transaction is open.
166
+ * A repository built with an explicit `client`, used inside a transaction that was opened on some
167
+ * OTHER client.
169
168
  *
170
- * Refused rather than resolved, because neither answer is available. `withTransaction` reserves
171
- * ONE connection and runs `BEGIN` on it — the ambient pool's, or a reservation of its own
172
- * `client:` option — while a repository pinned through `postgresDriver({ client })` sends every
173
- * statement straight to that client, which takes a different connection out of the pool. So the
174
- * write commits immediately and survives the rollback, and the read cannot see the rows the
175
- * transaction has already written; both are silent. Joining the transaction instead would be the
176
- * worse half of the same guess: a `DbTx` does not name the client it was opened on, so "is this
177
- * even the same database" is not a question this layer can ask, and on a sharded app the answer
178
- * is no.
169
+ * Refused rather than resolved. `withTransaction` reserves one connection and runs `BEGIN` on it,
170
+ * while a pinned repository sends to its own client — another connection, and on a sharded app
171
+ * another database — so the write would commit immediately and survive the rollback, and the read
172
+ * would miss rows the transaction has written; both silent. A transaction opened ON the pinned
173
+ * client (`tx.origin`) is the same database and the same reservation, and the repository joins it:
174
+ * that case never reaches here.
179
175
  *
180
- * The fix names the ambient seam because that is the one path a repository joins a transaction
181
- * through: `db()` resolves `currentTx()` first, which is exactly what a pinned client skips.
176
+ * The fix names the ambient seam because that is the path a repository joins the AMBIENT
177
+ * transaction through: `db()` resolves `currentTx()` first, which is what a pinned client skips.
182
178
  */
183
179
  export const repoClientPinned = (entityName: string): EntityError =>
184
180
  new EntityError({
@@ -187,28 +183,6 @@ export const repoClientPinned = (entityName: string): EntityError =>
187
183
  fix: `setDbClient(client) at boot and build the repository with no client: — postgresDriver() then resolves the open transaction through db() — or run this call outside withTransaction()`,
188
184
  });
189
185
 
190
- /**
191
- * The contract's pinned wording. Mirror of `@ultimat3/db`'s `dbDrift()` (`drift-errors.ts`) —
192
- * keep in sync; both screen through the same `shellInertIdentifier`, so the two lines are one
193
- * text on both sides of the tier seam, and `errors.test.ts` asserts it rather than asking.
194
- *
195
- * The column is the CATALOG's, so it is data, and `x db gen "add C"` puts it inside SHELL DOUBLE
196
- * QUOTES where `$(…)` and a backtick substitute before `x` is reached at all. The argument is a
197
- * migration DESCRIPTION, not an identifier, so no quoted form makes a hostile name safe to pass —
198
- * a refused one is left OUT of the command rather than escaped into it, and read off `cause`.
199
- */
200
- export const dbDrift = (tableName: string, columnName: string): EntityError =>
201
- new EntityError({
202
- code: 'X_DB_DRIFT',
203
- cause: `table "${tableName}" has column "${columnName}" not present in any migration`,
204
- fix:
205
- shellInertIdentifier(columnName) === null
206
- ? 'x db gen "add the column named in this error" # its name carries a backtick, a ' +
207
- 'dollar sign, a quote, a backslash or whitespace, so it is in the cause and not in ' +
208
- 'this command'
209
- : `x db gen "add ${columnName}"`,
210
- });
211
-
212
186
  export const notFound = (entityName: string, id: string): EntityError =>
213
187
  new EntityError({
214
188
  code: 'X_NOT_FOUND',
package/src/expr.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  import { literal as sqlLiteral } from '@ultimat3/db';
16
16
  import { invariantViolated } from './errors';
17
17
  import { isNullish } from './is-null';
18
+ import { DECIMAL_TEXT, numericOrder } from './numeric-compare';
18
19
  import { unportableConstruct } from './pattern-portability';
19
20
  import { refuseInvariant } from './refuse';
20
21
  import type { ColumnMap } from './types';
@@ -49,12 +50,14 @@ export interface Expr {
49
50
  interface Term {
50
51
  readonly path: readonly string[];
51
52
  readonly label: string;
53
+ /** The column's row value is decimal TEXT (`bigint()`, `decimal()`), by its declared kind. */
54
+ readonly decimal: boolean;
52
55
  sql(resolve: Resolve): string;
53
56
  read(row: Row): unknown;
54
57
  }
55
58
 
56
59
  export interface ColumnExpr {
57
- /** `btrim(...)` in SQL, `.trim()` in the app — the same rule, both sides. */
60
+ /** `btrim(col)` in SQL and its rule in the app: leading and trailing SPACES (U+0020) only. */
58
61
  trimmed(): ColumnExpr;
59
62
  minLength(length: number): Expr;
60
63
  contains(value: string): Expr;
@@ -178,6 +181,21 @@ const check = (
178
181
  holds: (row: Row) => boolean,
179
182
  ): Expr => ({ kind: 'check', paths, message, toSql: sql, holds });
180
183
 
184
+ /**
185
+ * A numeric operand a CHECK can spell. `NaN` and the infinities render as `col >= NaN` — a column
186
+ * reference Postgres does not have — so they are refused on the line that wrote them.
187
+ */
188
+ const finite = <T>(rule: 'atLeast' | 'eq', operand: T): T => {
189
+ if (typeof operand === 'number' && !Number.isFinite(operand)) {
190
+ refuseInvariant(
191
+ rule,
192
+ `${String(operand)} is not a number a CHECK can compare a column against`,
193
+ `pass a finite number — c.total.${rule}(0) — or a bigint for a bound past 2^53: c.total.${rule}(9007199254740993n)`,
194
+ );
195
+ }
196
+ return operand;
197
+ };
198
+
181
199
  const isColumnExpr = (value: unknown): value is ColumnExpr =>
182
200
  typeof value === 'object' && value !== null && terms.has(value as ColumnExpr);
183
201
 
@@ -192,11 +210,14 @@ const expr = (term: Term): ColumnExpr => {
192
210
  trimmed: () =>
193
211
  expr({
194
212
  path: term.path,
213
+ decimal: term.decimal,
195
214
  label: `trimmed ${term.label}`,
196
215
  sql: (resolve) => `btrim(${term.sql(resolve)})`,
197
216
  read: (row) => {
198
217
  const value = term.read(row);
199
- return typeof value === 'string' ? value.trim() : value;
218
+ // U+0020 only — what one-argument `btrim` strips. `.trim()` also removed tabs, newlines
219
+ // and NBSP, so the app approved a row the CHECK then refused as a raw 23514.
220
+ return typeof value === 'string' ? value.replace(/^ +| +$/g, '') : value;
200
221
  },
201
222
  }),
202
223
 
@@ -233,21 +254,32 @@ const expr = (term: Term): ColumnExpr => {
233
254
  );
234
255
  },
235
256
 
236
- atLeast: (bound) =>
237
- one(
257
+ atLeast: (bound) => {
258
+ finite('atLeast', bound);
259
+ return one(
238
260
  `${term.label} must be at least ${bound}`,
239
261
  (resolve) => `${term.sql(resolve)} >= ${bound}`,
240
- (value) => (typeof value === 'number' || typeof value === 'bigint') && value >= bound,
241
- ),
262
+ // By the column's KIND, never `typeof value`: a `bigint()`/`decimal()` row is decimal text,
263
+ // so a number test failed every row the CHECK beside it accepts.
264
+ (value) => (numericOrder(term.decimal, value, bound) ?? -1) >= 0,
265
+ );
266
+ },
242
267
 
243
- eq: (other) =>
244
- isColumnExpr(other)
245
- ? sameAs(term, other)
246
- : one(
247
- `${term.label} must equal ${literal(other)}`,
248
- (resolve) => `${term.sql(resolve)} = ${literal(other)}`,
249
- (value) => value === other,
250
- ),
268
+ eq: (other) => {
269
+ if (isColumnExpr(other)) return sameAs(term, other);
270
+ finite('eq', other);
271
+ // `col = 1.5` is numeric equality whatever spelled the operand, and on a decimal column so is
272
+ // `col = '1.5'` — Postgres coerces the literal. Everything else is the value itself.
273
+ const numeric = term.decimal || typeof other === 'number' || typeof other === 'bigint';
274
+ return one(
275
+ `${term.label} must equal ${literal(other)}`,
276
+ (resolve) => `${term.sql(resolve)} = ${literal(other)}`,
277
+ (value) =>
278
+ numeric && typeof other !== 'boolean'
279
+ ? numericOrder(term.decimal, value, other) === 0
280
+ : value === other,
281
+ );
282
+ },
251
283
 
252
284
  isTrue: () =>
253
285
  one(
@@ -281,6 +313,8 @@ const expr = (term: Term): ColumnExpr => {
281
313
  const part = (term: Term, key: string): ColumnExpr =>
282
314
  expr({
283
315
  path: [...term.path, key],
316
+ // A money part is a `number` (minor) or text (currency) — never decimal text.
317
+ decimal: false,
284
318
  label: `${term.label}.${key}`,
285
319
  sql: (resolve) => resolve([...term.path, key]),
286
320
  read: (row) => walk(row, [...term.path, key]),
@@ -300,12 +334,18 @@ const sameAs = (left: Term, other: ColumnExpr): Expr => {
300
334
  [left.path, right.path],
301
335
  `${left.label} must equal ${right.label}`,
302
336
  (resolve) => `${left.sql(resolve)} = ${right.sql(resolve)}`,
303
- (row) => left.read(row) === right.read(row),
337
+ // Two numeric columns are equal when their NUMBERS are: `'2' = '2.00'` across a `bigint()` and
338
+ // a `decimal()`, which `===` over the two row strings denied.
339
+ (row) =>
340
+ left.decimal || right.decimal
341
+ ? numericOrder(true, left.read(row), right.read(row)) === 0
342
+ : left.read(row) === right.read(row),
304
343
  );
305
344
  };
306
345
 
307
- const columnTerm = (property: string): Term => ({
346
+ const columnTerm = (property: string, decimal: boolean): Term => ({
308
347
  path: [property],
348
+ decimal,
309
349
  label: property,
310
350
  sql: (resolve) => resolve([property]),
311
351
  read: (row) => row[property],
@@ -403,25 +443,31 @@ export const iff = (left: Expr, right: Expr): Expr => {
403
443
  * catches a typo at compile time: a JS caller, a dynamically built rule and a `satisfies()` column
404
444
  * list all reach it untyped, and the thrown message names the columns that do exist rather than
405
445
  * failing later as `undefined is not a function`.
446
+ *
447
+ * It takes the column MAP, not a name list: a numeric rule is judged by the column's declared kind
448
+ * (`numeric-compare.ts`), and the map is the only place that kind is written.
406
449
  */
407
450
  export const invariantColumns = <C extends ColumnMap>(
408
451
  entity: string,
409
- properties: readonly string[],
452
+ columns: C,
410
453
  ): InvariantColumns<C> => {
411
- const known = new Set(properties);
454
+ const properties = Object.keys(columns);
412
455
  const helpers = { unique, satisfies };
413
456
  return new Proxy(helpers, {
414
457
  get(target, property) {
415
458
  if (property === 'unique' || property === 'satisfies') return target[property];
416
459
  if (typeof property !== 'string') return undefined;
417
- if (!known.has(property)) {
460
+ // `Object.hasOwn`, never `columns[property]`: the name is caller data on the JS path, and a
461
+ // plain read answers an `Object.prototype` member for `constructor`.
462
+ const column = Object.hasOwn(columns, property) ? columns[property] : undefined;
463
+ if (column === undefined) {
418
464
  throw invariantViolated(
419
465
  entity,
420
466
  'invariant',
421
467
  `no column "${property}"; declared columns are ${properties.join(', ')}`,
422
468
  );
423
469
  }
424
- return expr(columnTerm(property));
470
+ return expr(columnTerm(property, DECIMAL_TEXT.has(column.$meta.kind)));
425
471
  },
426
472
  }) as unknown as InvariantColumns<C>;
427
473
  };
package/src/index.ts CHANGED
@@ -45,7 +45,6 @@ export type {
45
45
  // so a third-party driver has to be able to raise the same refusals the two shipped ones do.
46
46
  export {
47
47
  crossTenantDenied,
48
- dbDrift,
49
48
  ENTITY_ERROR_CODES,
50
49
  ENTITY_ERROR_TITLES,
51
50
  EntityError,
@@ -76,6 +75,10 @@ export {
76
75
  } from './feature-errors';
77
76
  export type { Invariant, InvariantDef, InvariantKind } from './invariants';
78
77
  export { assertInvariants, invariant, MAX_ASSERTED_ROWS } from './invariants';
78
+ // How Postgres compares two values of one column, decided by the column's declared KIND. Exported
79
+ // because `@ultimat3/query`'s matcher and in-memory source ask the same question — and a second
80
+ // answer there is how a live window and the database returned one read in two orders.
81
+ export { compareByKind, sameValueOfKind } from './memory-match';
79
82
  export { memoryRepo, memoryTransactor } from './memory-repo';
80
83
  export type { StatementLoop } from './n-plus-one';
81
84
  export { N_PLUS_ONE_THRESHOLD, nPlusOne, preloadsFor } from './n-plus-one';
@@ -90,6 +93,8 @@ export { postgresTransactor } from './pg-transactor';
90
93
  // its own `pageSize` input against a hardcoded 10_000 is a second declaration of one number.
91
94
  export { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from './plan';
92
95
  export type { RelatedTable, RelatedTables } from './preload';
96
+ export type { PreloadOptions } from './preload-ceiling';
97
+ export { MAX_PRELOADED_ROWS } from './preload-ceiling';
93
98
  export type { Preloaded, ReadBuilder, Table } from './query';
94
99
  export { tableFor } from './query';
95
100
  // The client projection of an entity (plan 101): record type, record key, and the brand on
package/src/invariants.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  // callback the typed column proxy once, so a rule is written against property keys (`c.likeCount`)
8
8
  // and `entity()` alone resolves them to physical names. A physical name is never typed twice.
9
9
 
10
- import { EntityError, invariantViolated } from './errors';
10
+ import { describeCommand, EntityError, invariantViolated } from './errors';
11
11
  import type { Expr, Resolve, Row } from './expr';
12
12
 
13
13
  /** `assert` is a rule only the app can run — a JS predicate with no SQL translation. */
@@ -110,8 +110,8 @@ export const assertedRowsTooMany = (
110
110
  ): EntityError =>
111
111
  new EntityError({
112
112
  code: 'X_INVARIANT_VIOLATED',
113
- cause: `${entityName}.${operation}() matches ${matched} rows and ${entityName} declares an invariant only the app can judge, so every one of them would be read back — past ${MAX_ASSERTED_ROWS} that is the whole table in memory`,
114
- fix: `for await (const rows of ${entityName}.where(filter).inBatches(1000)) { … } # one page per statement, judged one page at a time`,
113
+ cause: `${entityName}.${operation}() matches ${matched} rows and ${entityName} declares an invariant only the app can judge, so every one of them would be read back — past ${MAX_ASSERTED_ROWS} that is the whole table in memory. Read the same filter with .where(filter).inBatches(1000) and write each page: one page per statement, judged one page at a time`,
114
+ fix: `${describeCommand(entityName)} # shows the invariant with sql: null that forces the read-back; then sweep the filter with inBatches()`,
115
115
  });
116
116
 
117
117
  /** Runs on every write. Reports every violation at once so one round trip fixes all. */
@@ -0,0 +1,28 @@
1
+ // Single responsibility: a write whose RESULT the app still has to judge. A rule with no CHECK
2
+ // (`kind: 'assert'`) is asserted on the rows a statement returned — after it was sent — so outside
3
+ // a transaction the violating row was committed and the refusal arrived too late to matter.
4
+
5
+ import { type DbClient, withTransaction } from '@ultimat3/db';
6
+ import type { EntityCore } from './entity';
7
+ import { hasJsOnlyInvariant } from './invariants';
8
+
9
+ /**
10
+ * Runs `work` on the connection its statement and its judgement share. With an app-only rule
11
+ * declared that is one transaction — a SAVEPOINT inside an open one — so the throw takes the write
12
+ * with it. With none, a CHECK already refused the statement and this is `direct` and nothing else:
13
+ * no `BEGIN`, no extra round trip.
14
+ *
15
+ * `direct` is resolved by the CALLER first, so a pinned repository inside a transaction opened on
16
+ * some other client is still refused (`X_REPO_CLIENT_PINNED`) before anything opens. `pinned` is
17
+ * that same client when the repository has one: the transaction is opened on it — a SAVEPOINT when
18
+ * one is already open there — never on the ambient pool.
19
+ */
20
+ export const judgedWrite = <Row, T>(
21
+ entity: EntityCore<Row>,
22
+ direct: DbClient,
23
+ pinned: DbClient | undefined,
24
+ work: (send: DbClient) => Promise<T>,
25
+ ): Promise<T> => {
26
+ if (!hasJsOnlyInvariant(entity.$invariants)) return work(direct);
27
+ return withTransaction((tx) => work(tx), pinned === undefined ? {} : { client: pinned });
28
+ };
package/src/like.ts ADDED
@@ -0,0 +1,85 @@
1
+ // Single responsibility: what a SQL `LIKE` pattern matches in the in-memory driver — Postgres'
2
+ // default escape handling, counted in CHARACTERS, in time bounded by pattern × value.
3
+
4
+ import { EntityError } from './errors';
5
+
6
+ /**
7
+ * Postgres answers a `LIKE` pattern ending in the escape character with `22025 — LIKE pattern must
8
+ * not end with escape character`, so a pattern that means nothing there means nothing here either.
9
+ * The pattern itself is never echoed: a filter value is app data, and this cause is rendered into
10
+ * a log line.
11
+ */
12
+ const danglingEscape = (entityName: string): EntityError =>
13
+ new EntityError({
14
+ code: 'X_INVARIANT_VIOLATED',
15
+ 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)`,
16
+ fix: "double it — 'a\\\\' is the pattern that matches one literal backslash, and 'a\\%b' matches a literal %",
17
+ });
18
+
19
+ const ONE = Symbol('_');
20
+ const MANY = Symbol('%');
21
+
22
+ /** One literal character, `_` (exactly one character) or `%` (any run, the empty one included). */
23
+ type Part = string | typeof ONE | typeof MANY;
24
+
25
+ /**
26
+ * The pattern as its parts: `%` and `_` are the wildcards, a backslash escapes either (or itself),
27
+ * everything else is literal. Read by CODE POINT, which is what Postgres counts — `_` over `😀` is
28
+ * one character there and was two UTF-16 units to a regex `.` without the `u` flag.
29
+ *
30
+ * A run of `%` is one `%`, as Postgres reads it.
31
+ */
32
+ const partsOf = (entityName: string, pattern: string): readonly Part[] => {
33
+ const chars = [...pattern];
34
+ const parts: Part[] = [];
35
+ for (let at = 0; at < chars.length; at += 1) {
36
+ const char = chars[at] ?? '';
37
+ if (char === '\\') {
38
+ const escaped = chars[at + 1];
39
+ if (escaped === undefined) throw danglingEscape(entityName);
40
+ parts.push(escaped);
41
+ at += 1;
42
+ } else if (char === '%') {
43
+ if (parts.at(-1) !== MANY) parts.push(MANY);
44
+ } else {
45
+ parts.push(char === '_' ? ONE : char);
46
+ }
47
+ }
48
+ return parts;
49
+ };
50
+
51
+ /**
52
+ * `text like pattern`. NOT a compiled regex: an anchored regex with one `.*` per `%` backtracks
53
+ * combinatorially the moment the wildcards are separated (`%a%a%a%…b`, `%_%_%_…b`) — seconds at
54
+ * seven of them over a 70-character value, and a filter value arrives from a search box. This is
55
+ * the two-pointer walk with ONE backtrack point (the last `%`), so the worst case is pattern
56
+ * length × value length whatever the pattern holds.
57
+ */
58
+ export const likeMatches = (entityName: string, pattern: string, text: string): boolean => {
59
+ const parts = partsOf(entityName, pattern);
60
+ const chars = [...text];
61
+ let part = 0;
62
+ let char = 0;
63
+ let star = -1;
64
+ let resume = 0;
65
+ while (char < chars.length) {
66
+ const current = parts[part];
67
+ if (current === MANY) {
68
+ star = part;
69
+ resume = char;
70
+ part += 1;
71
+ } else if (current !== undefined && (current === ONE || current === chars[char])) {
72
+ part += 1;
73
+ char += 1;
74
+ } else if (star !== -1) {
75
+ // The last `%` takes one more character and the walk resumes after it.
76
+ part = star + 1;
77
+ resume += 1;
78
+ char = resume;
79
+ } else {
80
+ return false;
81
+ }
82
+ }
83
+ while (parts[part] === MANY) part += 1;
84
+ return part === parts.length;
85
+ };