@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.
- package/CLAUDE.md +37 -21
- package/README.md +32 -19
- package/package.json +5 -5
- package/src/aggregate.ts +31 -22
- package/src/bulk-write.ts +4 -13
- package/src/column.ts +36 -1
- package/src/columns-data.ts +24 -3
- package/src/columns.ts +8 -1
- package/src/containment.ts +15 -2
- package/src/database.ts +9 -2
- package/src/entity-error.ts +26 -21
- package/src/entity.ts +3 -6
- package/src/errors.ts +16 -42
- package/src/expr.ts +66 -20
- package/src/index.ts +6 -1
- package/src/invariants.ts +3 -3
- package/src/judged-write.ts +28 -0
- package/src/like.ts +85 -0
- package/src/memory-match.ts +20 -82
- package/src/memory-unique.ts +7 -6
- package/src/numeric-compare.ts +57 -0
- package/src/pg-driver.ts +34 -28
- package/src/pg-sql.ts +5 -1
- package/src/plan.ts +9 -3
- package/src/preload-ceiling.ts +75 -0
- package/src/preload.ts +20 -7
- package/src/query.ts +14 -14
- package/src/sealed-column.ts +7 -3
- package/src/sealed.ts +6 -4
- package/src/seed.ts +97 -73
- package/src/transition.ts +3 -0
- package/src/types.ts +6 -0
- package/src/unique-constraints.ts +44 -0
package/src/entity-error.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
5
|
+
import {
|
|
6
|
+
isFixShellSafe,
|
|
7
|
+
registerErrorCodes,
|
|
8
|
+
registerErrorRetry,
|
|
9
|
+
UltimateError,
|
|
10
|
+
} from '@ultimat3/core';
|
|
7
11
|
|
|
8
|
-
/**
|
|
9
|
-
|
|
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<
|
|
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<
|
|
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 {
|
|
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 =
|
|
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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
7
|
+
export type { EntityErrorCode } from './entity-error';
|
|
9
8
|
export {
|
|
10
|
-
|
|
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
|
|
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
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
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
|
|
181
|
-
* through: `db()` resolves `currentTx()` first, which is
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
452
|
+
columns: C,
|
|
410
453
|
): InvariantColumns<C> => {
|
|
411
|
-
const
|
|
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
|
-
|
|
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:
|
|
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
|
+
};
|