@ultimat3/entity 30.8.0 → 31.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 +68 -106
- package/README.md +180 -8
- package/package.json +6 -5
- package/src/adapter.ts +65 -0
- package/src/batch-read.ts +12 -0
- package/src/column.ts +12 -0
- package/src/columns.ts +20 -8
- package/src/cross-tenant.ts +11 -14
- package/src/cursor.ts +3 -0
- package/src/database.ts +2 -2
- package/src/declared-index.ts +72 -0
- package/src/define-driver.ts +156 -0
- package/src/describe.ts +5 -0
- package/src/driver-delete.ts +62 -0
- package/src/driver-errors.ts +80 -0
- package/src/driver-kit-types.ts +107 -0
- package/src/driver-registry.ts +26 -0
- package/src/driver-repo-reads.ts +102 -0
- package/src/driver-repo-writes.ts +236 -0
- package/src/driver-rows.ts +108 -0
- package/src/driver-scope.ts +77 -0
- package/src/driver-write-scope.ts +107 -0
- package/src/entity-error.ts +10 -0
- package/src/entity-parse.ts +3 -2
- package/src/entity-patch.ts +3 -1
- package/src/entity.ts +40 -54
- package/src/enum-column.ts +8 -0
- package/src/index.ts +43 -27
- package/src/json-null.ts +13 -0
- package/src/memory-match.ts +51 -17
- package/src/memory-observed.ts +16 -11
- package/src/memory-repo.ts +46 -99
- package/src/memory-seek.ts +12 -25
- package/src/memory-unique.ts +7 -7
- package/src/memory-update.ts +7 -2
- package/src/memory-upsert.ts +102 -0
- package/src/partition.ts +140 -0
- package/src/personal-data-kind.ts +15 -0
- package/src/personal-data.ts +66 -0
- package/src/pg-driver.ts +83 -30
- package/src/pg-row.ts +7 -1
- package/src/pg-write-sql.ts +39 -4
- package/src/plan.ts +2 -0
- package/src/predicate-screen.ts +36 -0
- package/src/query.ts +13 -4
- package/src/registry.ts +3 -0
- package/src/row-key.ts +19 -0
- package/src/row-observer.ts +38 -90
- package/src/search-index-installed.ts +24 -0
- package/src/search-index-memory.ts +132 -0
- package/src/search-index-postgres.ts +107 -0
- package/src/search-index-sync.ts +96 -0
- package/src/search-index-version.ts +93 -0
- package/src/search-index.ts +168 -0
- package/src/search-mirror-batch.ts +133 -0
- package/src/search-mirrored-repo.ts +98 -0
- package/src/search-repo.ts +91 -0
- package/src/search-written-rows.ts +36 -0
- package/src/tenant-index.ts +113 -0
- package/src/types.ts +12 -0
- package/src/view.ts +2 -1
- package/src/write-commit.ts +28 -0
- package/src/write-tag.ts +6 -2
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Single responsibility: the two write-side guards a custom store cannot be trusted with. The rows a
|
|
2
|
+
// store hands back from a write are checked against the tenants the write named — a Mongo-style
|
|
3
|
+
// upsert answering the STORED document of a global unique hands over another tenant's row — and a
|
|
4
|
+
// same-tenant reference is judged by memory's own judge, over targets the kit looked up itself.
|
|
5
|
+
|
|
6
|
+
import { keyOf } from './batch-read';
|
|
7
|
+
import { kindOf, valueAt } from './cursor';
|
|
8
|
+
import { describeReferences } from './describe';
|
|
9
|
+
import { driverRowOutOfScope } from './driver-errors';
|
|
10
|
+
import type { DriverRepo } from './driver-kit-types';
|
|
11
|
+
import type { EntityCore } from './entity';
|
|
12
|
+
import { type MemoryTables, sameTenantJudge } from './memory-same-tenant';
|
|
13
|
+
import { getEntity } from './registry';
|
|
14
|
+
import { assertRowTenant } from './tenancy';
|
|
15
|
+
|
|
16
|
+
/** How the kit reaches another entity's raw store in the same driver. */
|
|
17
|
+
export type RawFor = <R>(entity: EntityCore<R>) => DriverRepo<R>;
|
|
18
|
+
|
|
19
|
+
const cell = (entity: EntityCore, property: string, value: unknown): string =>
|
|
20
|
+
keyOf(kindOf(entity, property) ?? '', value);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Every returned row names a tenant one of the WRITTEN rows named — or, where a written row named
|
|
24
|
+
* none, passes the actor check `assertRowTenant` applies. Refused whole, never filtered.
|
|
25
|
+
*/
|
|
26
|
+
export const assertWrittenInScope = <Row>(
|
|
27
|
+
driverName: string,
|
|
28
|
+
entity: EntityCore<Row>,
|
|
29
|
+
operation: string,
|
|
30
|
+
written: readonly Row[],
|
|
31
|
+
returned: readonly Row[],
|
|
32
|
+
): readonly Row[] => {
|
|
33
|
+
const column = entity.$tenantColumn;
|
|
34
|
+
if (column === null) return returned;
|
|
35
|
+
const own = entity as EntityCore;
|
|
36
|
+
const named = written.map((row) => valueAt(row, column));
|
|
37
|
+
const allowed = new Set(
|
|
38
|
+
named
|
|
39
|
+
.filter((value) => value !== null && value !== undefined)
|
|
40
|
+
.map((value) => cell(own, column, value)),
|
|
41
|
+
);
|
|
42
|
+
const anyUnnamed = named.some((value) => value === null || value === undefined);
|
|
43
|
+
for (const row of returned) {
|
|
44
|
+
const value = valueAt(row, column);
|
|
45
|
+
if (value !== null && value !== undefined && allowed.has(cell(own, column, value))) continue;
|
|
46
|
+
if (anyUnnamed) {
|
|
47
|
+
assertRowTenant(entity.$name, column, operation, row);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
throw driverRowOutOfScope(driverName, entity.$name, operation, column);
|
|
51
|
+
}
|
|
52
|
+
return returned;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The properties holding a same-tenant reference — a patch naming one has to be judged. */
|
|
56
|
+
export const sameTenantProperties = <Row>(entity: EntityCore<Row>): ReadonlySet<string> =>
|
|
57
|
+
entity.$tenantColumn === null
|
|
58
|
+
? new Set()
|
|
59
|
+
: new Set(
|
|
60
|
+
describeReferences(entity.$name, Object.entries(entity.$columns))
|
|
61
|
+
.filter((edge) => edge.sameTenant !== null)
|
|
62
|
+
.map((edge) => edge.property),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `sameTenantJudge` over a table of targets fetched first: each referenced value is read from the
|
|
67
|
+
* target's store UNSCOPED — the judge has to find a target in another tenant to refuse it — and the
|
|
68
|
+
* judge then answers exactly as it does for the memory driver (`X_DB_FOREIGN_KEY_VIOLATION`).
|
|
69
|
+
*/
|
|
70
|
+
export const sameTenantReferences = <Row>(entity: EntityCore<Row>, rawFor: RawFor) => {
|
|
71
|
+
const judge = (tables: MemoryTables) => sameTenantJudge(entity, tables);
|
|
72
|
+
return async (rows: readonly Row[]): Promise<void> => {
|
|
73
|
+
if (entity.$tenantColumn === null || rows.length === 0) return;
|
|
74
|
+
const edges = describeReferences(entity.$name, Object.entries(entity.$columns)).filter(
|
|
75
|
+
(edge) => edge.sameTenant !== null,
|
|
76
|
+
);
|
|
77
|
+
if (edges.length === 0) return;
|
|
78
|
+
const tables: MemoryTables = new Map();
|
|
79
|
+
for (const edge of edges) {
|
|
80
|
+
const target = getEntity(edge.targetEntity)?.core;
|
|
81
|
+
if (target === undefined || target.$tenantColumn === null) continue;
|
|
82
|
+
const values = [
|
|
83
|
+
...new Set(
|
|
84
|
+
rows
|
|
85
|
+
.map((row) => valueAt(row, edge.property))
|
|
86
|
+
.filter((value) => value !== null && value !== undefined),
|
|
87
|
+
),
|
|
88
|
+
];
|
|
89
|
+
if (values.length === 0) continue;
|
|
90
|
+
const where = [{ column: edge.targetProperty, op: 'in' as const, value: values }];
|
|
91
|
+
const orderBy = target.$primaryKey.map((column) => ({ column, direction: 'asc' as const }));
|
|
92
|
+
const plan = { entity: target.$name, where, orderBy, limit: values.length };
|
|
93
|
+
const found = await rawFor(target).select({ plan, after: undefined, limit: values.length });
|
|
94
|
+
const prior = tables.get(target.$name);
|
|
95
|
+
tables.set(target.$name, {
|
|
96
|
+
entity: target,
|
|
97
|
+
find: (property, value) =>
|
|
98
|
+
found.find(
|
|
99
|
+
(row) =>
|
|
100
|
+
cell(target, property, valueAt(row, property)) === cell(target, property, value),
|
|
101
|
+
) ?? prior?.find(property, value),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const verdict = judge(tables);
|
|
105
|
+
for (const row of rows) verdict(row, rows);
|
|
106
|
+
};
|
|
107
|
+
};
|
package/src/entity-error.ts
CHANGED
|
@@ -49,6 +49,10 @@ export const ENTITY_ERROR_CODES = [
|
|
|
49
49
|
'X_ENTITY_RETAIN_UNINDEXED',
|
|
50
50
|
'X_ENTITY_COUNTS_INVALID',
|
|
51
51
|
'X_ENTITY_READ_ONLY',
|
|
52
|
+
'X_DRIVER_UNSUPPORTED',
|
|
53
|
+
'X_DRIVER_CAPABILITY_MISSING',
|
|
54
|
+
'X_DRIVER_ROW_OUT_OF_SCOPE',
|
|
55
|
+
'X_ENTITY_PARTITION_INVALID',
|
|
52
56
|
] as const;
|
|
53
57
|
|
|
54
58
|
export type EntityErrorCode = (typeof ENTITY_ERROR_CODES)[number];
|
|
@@ -93,6 +97,12 @@ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityErrorCode, string>> = {
|
|
|
93
97
|
X_ENTITY_COUNTS_INVALID:
|
|
94
98
|
'references(…, { countedIn }) names no integer of its parent, or is no key to its primary key',
|
|
95
99
|
X_ENTITY_READ_ONLY: 'a read-only entity was asked to write a row',
|
|
100
|
+
X_DRIVER_UNSUPPORTED: 'a custom driver was asked for a capability it does not declare',
|
|
101
|
+
X_DRIVER_CAPABILITY_MISSING:
|
|
102
|
+
'a custom driver declares a capability its raw repo does not implement',
|
|
103
|
+
X_DRIVER_ROW_OUT_OF_SCOPE: 'a custom driver returned a row outside the plan it was given',
|
|
104
|
+
X_ENTITY_PARTITION_INVALID:
|
|
105
|
+
'entity({ partitionBy }) declares a partitioning Postgres cannot build',
|
|
96
106
|
};
|
|
97
107
|
|
|
98
108
|
// Registered at module load, unconditionally, in one call. Without this the registry humanises the
|
package/src/entity-parse.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { describeValue } from '@ultimat3/schema';
|
|
|
6
6
|
import { entityNow } from './clock';
|
|
7
7
|
import { newId } from './columns';
|
|
8
8
|
import { invariantViolated } from './errors';
|
|
9
|
+
import { holdsJsonNull } from './json-null';
|
|
9
10
|
import { sealedMissing } from './sealed-errors';
|
|
10
11
|
import type { AnyColumn, ColumnMeta } from './types';
|
|
11
12
|
|
|
@@ -41,7 +42,7 @@ export const rowParsers = <Row, Wire>(
|
|
|
41
42
|
// spread of an omitted optional key produces and is the shape every existing caller has.
|
|
42
43
|
const raw = input[property];
|
|
43
44
|
const given = raw === undefined ? defaultValue(column.$meta) : raw;
|
|
44
|
-
if (given === undefined || given === null) {
|
|
45
|
+
if (given === undefined || (given === null && !holdsJsonNull(column.$meta))) {
|
|
45
46
|
if (column.$meta.notNull) {
|
|
46
47
|
// A required sealed column ABSENT from a row is nearly always a spread of a repository
|
|
47
48
|
// row, which does not enumerate it — so the refusal names that, and the rewrite.
|
|
@@ -72,7 +73,7 @@ export const rowParsers = <Row, Wire>(
|
|
|
72
73
|
for (const [property, column] of wireEntries) {
|
|
73
74
|
const raw = input[property];
|
|
74
75
|
const given = raw === undefined ? defaultValue(column.$meta) : raw;
|
|
75
|
-
if (given === undefined || given === null) {
|
|
76
|
+
if (given === undefined || (given === null && !holdsJsonNull(column.$meta))) {
|
|
76
77
|
if (column.$meta.notNull) {
|
|
77
78
|
throw invariantViolated(name, property, 'is required and has no default');
|
|
78
79
|
}
|
package/src/entity-patch.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// would rewrite every column it did not name.
|
|
4
4
|
|
|
5
5
|
import { invariantViolated } from './errors';
|
|
6
|
+
import { holdsJsonNull } from './json-null';
|
|
6
7
|
import { copyRow } from './sealed';
|
|
7
8
|
import type { AnyColumn } from './types';
|
|
8
9
|
|
|
@@ -24,7 +25,8 @@ export const parseEntityPatch = <Patch>(
|
|
|
24
25
|
const column = Object.hasOwn(columns, property) ? columns[property] : undefined;
|
|
25
26
|
const value: unknown = descriptor.get === undefined ? descriptor.value : descriptor.get();
|
|
26
27
|
if (column === undefined || value === undefined) continue;
|
|
27
|
-
|
|
28
|
+
// A NOT NULL `jsonb` column's `null` is the JSON value, judged by its schema below.
|
|
29
|
+
if (value === null && !holdsJsonNull(column.$meta)) {
|
|
28
30
|
if (column.$meta.notNull) {
|
|
29
31
|
throw invariantViolated(name, property, 'is required and has no default');
|
|
30
32
|
}
|
package/src/entity.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { IndexMethod } from '@ultimat3/db';
|
|
|
7
7
|
import type { Schema } from '@ultimat3/schema';
|
|
8
8
|
import { assertAppendOnlyColumns } from './append-only';
|
|
9
9
|
import { assertTableName, bindColumn, columnName, moneyColumns } from './column';
|
|
10
|
+
import { declaredIndexOf } from './declared-index';
|
|
10
11
|
import {
|
|
11
12
|
assertNotRetired,
|
|
12
13
|
assertScan,
|
|
@@ -26,6 +27,7 @@ import { indexName } from './index-name';
|
|
|
26
27
|
import type { Invariant, InvariantDef } from './invariants';
|
|
27
28
|
import { assertInvariants, bindInvariant } from './invariants';
|
|
28
29
|
import { describeLifecycle, lifecycleHistory } from './lifecycle';
|
|
30
|
+
import { type PartitionInit, partitionOf } from './partition';
|
|
29
31
|
import { assertReadOnly } from './read-only';
|
|
30
32
|
import { recordProjection } from './record-projection';
|
|
31
33
|
import type { EntityDescription, ReferenceDescription } from './registry';
|
|
@@ -38,6 +40,7 @@ import { sealedInView } from './sealed-errors';
|
|
|
38
40
|
import type { SearchInit, SearchSource, SearchVector } from './search';
|
|
39
41
|
import { searchVectorOf } from './search';
|
|
40
42
|
import { resolveTenantColumn } from './tenancy';
|
|
43
|
+
import { coveredByTenantIndex, tenantIndexOf, withoutDerivedDuplicate } from './tenant-index';
|
|
41
44
|
import type { AnyColumn, ColumnMap, IndexDef, RowOf, WireRow } from './types';
|
|
42
45
|
import type { EntityView } from './view';
|
|
43
46
|
import { viewFor } from './view';
|
|
@@ -125,6 +128,12 @@ export interface EntityInit<C extends ColumnMap> {
|
|
|
125
128
|
readonly retain?: RetainInit<keyof C & string>;
|
|
126
129
|
/** Every row change recorded into `<table>_history` by trigger (`history.ts`, `$history`). */
|
|
127
130
|
readonly history?: HistoryInit;
|
|
131
|
+
/**
|
|
132
|
+
* Range-partitioned by a `timestamp()`/`date()` in the primary key, one partition per `interval`
|
|
133
|
+
* (`partition.ts`). `x db gen` writes `partition by range` and the first partitions; a scheduled
|
|
134
|
+
* maintenance run creates `ahead` more and drops what `retain` expired. Never beside `retain`.
|
|
135
|
+
*/
|
|
136
|
+
readonly partitionBy?: PartitionInit<keyof C & string>;
|
|
128
137
|
/** Set by `history.ts` on the history entity it declares: the parent's name. Never by hand. */
|
|
129
138
|
readonly historyOf?: string;
|
|
130
139
|
}
|
|
@@ -307,6 +316,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
307
316
|
const physical = [
|
|
308
317
|
meta.kind === 'money' ? moneyColumns(property, meta).minor : columnName(property, meta),
|
|
309
318
|
];
|
|
319
|
+
if (coveredByTenantIndex(physical[0] ?? '', tenant, meta.unique)) return [];
|
|
310
320
|
return [
|
|
311
321
|
{
|
|
312
322
|
name: indexName(name, table, physical, meta.unique),
|
|
@@ -315,58 +325,9 @@ export const entity = <const C extends ColumnMap>(
|
|
|
315
325
|
},
|
|
316
326
|
];
|
|
317
327
|
}),
|
|
318
|
-
...(init.indexes ?? []).map((index) =>
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
// `on: []` is type-legal and names nothing: the generated DDL would be `on "posts" ()`, a
|
|
322
|
-
// syntax error one migration later. Refused where it was written instead.
|
|
323
|
-
if (columns.length === 0) {
|
|
324
|
-
throw invariantViolated(name, 'index', 'an index must name at least one column');
|
|
325
|
-
}
|
|
326
|
-
const where = index.where?.(columnsExpr).toSql(resolve) ?? null;
|
|
327
|
-
if (index.where !== undefined && where === null) {
|
|
328
|
-
throw invariantViolated(
|
|
329
|
-
name,
|
|
330
|
-
'index',
|
|
331
|
-
'a partial index predicate must be expressible in SQL; a JS predicate cannot be one',
|
|
332
|
-
);
|
|
333
|
-
}
|
|
334
|
-
// Two rules Postgres has that a declaration can break, refused where the author wrote it.
|
|
335
|
-
// `@ultimat3/db` refuses both again at `createIndex` — that is not a duplicate, it is the
|
|
336
|
-
// guard for a description nobody built here — but its refusal lands at `x db gen` or, if a
|
|
337
|
-
// migration was already written, inside `ROLE=migrate` as the server's own syntax error with
|
|
338
|
-
// none of the entity's words in it.
|
|
339
|
-
if (index.using !== undefined && index.using !== 'btree') {
|
|
340
|
-
if (unique) {
|
|
341
|
-
throw invariantViolated(
|
|
342
|
-
name,
|
|
343
|
-
'index',
|
|
344
|
-
`the index on (${columns.join(', ')}) is unique and ${index.using}; ` +
|
|
345
|
-
`Postgres has no unique ${index.using} index — drop unique, or drop using`,
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
|
-
if (index.order !== undefined) {
|
|
349
|
-
throw invariantViolated(
|
|
350
|
-
name,
|
|
351
|
-
'index',
|
|
352
|
-
`the index on (${columns.join(', ')}) is ${index.using} and ${index.order}; ` +
|
|
353
|
-
'only a btree orders its keys — drop order, or drop using',
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
return {
|
|
358
|
-
name: indexName(name, table, columns, unique, index.order, where, index.using),
|
|
359
|
-
columns,
|
|
360
|
-
unique,
|
|
361
|
-
...(index.order === undefined ? {} : { order: index.order }),
|
|
362
|
-
...(where === null ? {} : { where }),
|
|
363
|
-
// Absent stays absent: `btree` written out would be a field every existing snapshot lacks,
|
|
364
|
-
// and `indexMethodOf` reads the two the same way precisely so nothing regenerates.
|
|
365
|
-
...(index.using === undefined || index.using === 'btree' ? {} : { using: index.using }),
|
|
366
|
-
// How it is BUILT, so not part of the name: `true` or absent, like `appendOnly`.
|
|
367
|
-
...(index.concurrently === true ? { concurrently: true as const } : {}),
|
|
368
|
-
};
|
|
369
|
-
}),
|
|
328
|
+
...(init.indexes ?? []).map((index) =>
|
|
329
|
+
declaredIndexOf(index, { name, table, resolve, columns: columnsExpr }),
|
|
330
|
+
),
|
|
370
331
|
// The one index nobody declared and every search needs. Through the SAME `IndexInit` path a
|
|
371
332
|
// hand-written `using: 'gin'` takes — `indexName` gives it the method discriminator, so it can
|
|
372
333
|
// never collide with a btree an author declares on the same column.
|
|
@@ -381,10 +342,34 @@ export const entity = <const C extends ColumnMap>(
|
|
|
381
342
|
},
|
|
382
343
|
]),
|
|
383
344
|
];
|
|
345
|
+
// After every other index, so one that already starts with `(tenant, pk)` is seen (`tenant-index.ts`).
|
|
346
|
+
const physicalKey = primaryKey.map((property) => resolve([property]));
|
|
347
|
+
const [tenantIndex] = tenantIndexOf({
|
|
348
|
+
entityName: name,
|
|
349
|
+
table,
|
|
350
|
+
tenant,
|
|
351
|
+
primaryKey: physicalKey,
|
|
352
|
+
plainView: init.view != null && init.view.materialized !== true,
|
|
353
|
+
covering: [
|
|
354
|
+
...declared.filter((index) => index.where == null && index.using === undefined),
|
|
355
|
+
...invariants.filter((inv) => inv.kind === 'unique' && inv.where == null),
|
|
356
|
+
].map((each) => each.columns),
|
|
357
|
+
});
|
|
384
358
|
// A foreign key's own index named again in `indexes` is one index, not two (`index-identity.ts`).
|
|
385
|
-
const indexes = distinctIndexes(
|
|
359
|
+
const indexes = distinctIndexes(
|
|
360
|
+
tenantIndex === undefined ? declared : [...declared, tenantIndex],
|
|
361
|
+
);
|
|
386
362
|
|
|
387
363
|
const retain = retainOf(name, init.retain, entries, indexes, appendOnly);
|
|
364
|
+
const partition = partitionOf(name, init.partitionBy, {
|
|
365
|
+
table,
|
|
366
|
+
columns: entries,
|
|
367
|
+
primaryKey,
|
|
368
|
+
indexes,
|
|
369
|
+
uniques: invariants.filter((inv) => inv.kind === 'unique').map((inv) => inv.columns),
|
|
370
|
+
retain: init.retain !== undefined,
|
|
371
|
+
view: init.view != null,
|
|
372
|
+
});
|
|
388
373
|
const sealed = sealedFields({ $name: name, $table: table, $columns: init.columns });
|
|
389
374
|
assertSealedDeclaration({
|
|
390
375
|
name,
|
|
@@ -411,7 +396,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
411
396
|
columns: entries,
|
|
412
397
|
primaryKey,
|
|
413
398
|
invariants,
|
|
414
|
-
indexes,
|
|
399
|
+
indexes: withoutDerivedDuplicate(table, physicalKey, tenantIndex, indexes),
|
|
415
400
|
tags,
|
|
416
401
|
cacheTag,
|
|
417
402
|
softDelete,
|
|
@@ -424,6 +409,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
424
409
|
readOnly: init.readOnly,
|
|
425
410
|
view: viewQuery,
|
|
426
411
|
lifecycle: describeLifecycle(core as EntityCore, retain, init.historyOf),
|
|
412
|
+
partition,
|
|
427
413
|
});
|
|
428
414
|
assertNotRetired(name, entries, init.retired);
|
|
429
415
|
const references = (): readonly ReferenceDescription[] => describeReferences(name, entries);
|
package/src/enum-column.ts
CHANGED
|
@@ -14,6 +14,14 @@ import type { ColumnMeta, EnumeratedColumn } from './types';
|
|
|
14
14
|
* transaction on older servers.
|
|
15
15
|
*/
|
|
16
16
|
export const enumerated = <const V extends readonly string[]>(values: V): EnumeratedColumn<V> => {
|
|
17
|
+
// An empty set renders `x in ()`, which `x db migrate` dies on with 42601 — refused here instead.
|
|
18
|
+
if (values.length === 0) {
|
|
19
|
+
refuseColumn(
|
|
20
|
+
'enum',
|
|
21
|
+
'the set of allowed values is empty — no row could ever hold this column',
|
|
22
|
+
"enumerated(['draft', 'published']) — declare at least one value; a column with no legal value is a column to delete",
|
|
23
|
+
);
|
|
24
|
+
}
|
|
17
25
|
const allowed = new Set<string>(values);
|
|
18
26
|
const parse = (value: unknown): V[number] =>
|
|
19
27
|
typeof value === 'string' && allowed.has(value)
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
// The public surface of @ultimat3/entity. Explicit, never `export *`.
|
|
2
2
|
|
|
3
|
+
// The `entity.*` payloads are core's (the bus is tier 0); re-exported, never a second shape.
|
|
4
|
+
export type {
|
|
5
|
+
EntityBulkEvent,
|
|
6
|
+
EntityCrossTenantEvent,
|
|
7
|
+
EntityRowEvent,
|
|
8
|
+
EntityRowOp,
|
|
9
|
+
} from '@ultimat3/core';
|
|
3
10
|
/** Re-exported so an `entity` file needs one import, not two. Same object as schema's. */
|
|
4
11
|
export type { Infer } from '@ultimat3/schema';
|
|
5
12
|
export { t } from '@ultimat3/schema';
|
|
@@ -32,10 +39,11 @@ export type { Counter } from './counter';
|
|
|
32
39
|
export { countersOf, describeCounter } from './counter';
|
|
33
40
|
// `crossTenantReason` stays internal: an app that could read the flag would have a second way to
|
|
34
41
|
// reason about tenant scope — branch on it — next to the one way, which is entering the scope.
|
|
35
|
-
export { CROSS_TENANT_SCOPE, crossTenant
|
|
42
|
+
export { CROSS_TENANT_SCOPE, crossTenant } from './cross-tenant';
|
|
43
|
+
// Leaves, so a reader of a row's key (`@ultimat3/jobs`' reindex sweep) skips `./adapter`'s driver kit.
|
|
44
|
+
export { kindOf } from './cursor';
|
|
36
45
|
export type { Database, DatabaseOptions, Driver, EntitySet } from './database';
|
|
37
46
|
export { database, defaultDriver, memoryDriver } from './database';
|
|
38
|
-
export type { DescribeInput } from './describe';
|
|
39
47
|
export type { TenantColumnDescription } from './describe-tenancy';
|
|
40
48
|
export { describeTenancy } from './describe-tenancy';
|
|
41
49
|
export type { Entity, EntityCore, EntityInit } from './entity';
|
|
@@ -46,41 +54,24 @@ export { entity, SOFT_DELETE_COLUMN } from './entity';
|
|
|
46
54
|
export type {
|
|
47
55
|
EntityErrorCode,
|
|
48
56
|
PreloadCandidate,
|
|
49
|
-
QueryLoopBatch,
|
|
50
|
-
WriteLoopBatch,
|
|
51
57
|
} from './errors';
|
|
52
58
|
// Every error factory this package owns, its three tenancy siblings included: `Driver` is public,
|
|
53
59
|
// so a third-party driver has to be able to raise the same refusals the two shipped ones do.
|
|
54
60
|
export {
|
|
55
|
-
crossTenantDenied,
|
|
56
61
|
ENTITY_ERROR_CODES,
|
|
57
62
|
ENTITY_ERROR_TITLES,
|
|
58
63
|
EntityError,
|
|
59
64
|
entityDuplicate,
|
|
60
65
|
invariantViolated,
|
|
61
66
|
notFound,
|
|
62
|
-
nPlusOneQuery,
|
|
63
|
-
nPlusOneWrite,
|
|
64
67
|
patchEmpty,
|
|
65
68
|
preloadUnknownRelation,
|
|
66
|
-
repoClientPinned,
|
|
67
|
-
tenancyActorMismatch,
|
|
68
69
|
tenancyActorOrgRequired,
|
|
69
70
|
tenancyRowMismatch,
|
|
70
|
-
tenancyUnscoped,
|
|
71
|
-
writeUnfiltered,
|
|
72
71
|
} from './errors';
|
|
73
72
|
export type { ColumnExpr, Expr, InvariantColumns, Resolve } from './expr';
|
|
74
73
|
export { iff } from './expr';
|
|
75
74
|
/** The two DECLARED capabilities' refusals — a third-party driver raises the same ones. */
|
|
76
|
-
export type { IllegalTransition } from './feature-errors';
|
|
77
|
-
export {
|
|
78
|
-
searchInMemory,
|
|
79
|
-
searchUndeclared,
|
|
80
|
-
stateConflict,
|
|
81
|
-
stateTransitionIllegal,
|
|
82
|
-
stateUndeclared,
|
|
83
|
-
} from './feature-errors';
|
|
84
75
|
export type { HistoryColumns, HistoryEntity, HistoryInit } from './history';
|
|
85
76
|
export type { IndexInit } from './index-init';
|
|
86
77
|
export type { Invariant, InvariantDef, InvariantKind } from './invariants';
|
|
@@ -94,7 +85,20 @@ export { memoryRepo, memoryTransactor } from './memory-repo';
|
|
|
94
85
|
export type { StatementLoop } from './n-plus-one';
|
|
95
86
|
export { N_PLUS_ONE_THRESHOLD, nPlusOne, preloadsFor } from './n-plus-one';
|
|
96
87
|
export { isOperator, OPERATORS } from './operator';
|
|
88
|
+
// `entity({ partitionBy })`, checked at declaration (`partition.ts`); the SQL is `@ultimat3/db`'s.
|
|
89
|
+
export type {
|
|
90
|
+
PartitionContext,
|
|
91
|
+
PartitionDescription,
|
|
92
|
+
PartitionInit,
|
|
93
|
+
} from './partition';
|
|
94
|
+
export { partitionOf } from './partition';
|
|
97
95
|
export { persistedRecordTypes } from './persisted-types';
|
|
96
|
+
// GDPR: the personal-data column marker and its readers (`personal-data.ts`).
|
|
97
|
+
export type { PersonalDataColumn, PersonalDataEntity, PersonalDataKind } from './personal-data';
|
|
98
|
+
export {
|
|
99
|
+
personalDataEntities,
|
|
100
|
+
personalDataOf,
|
|
101
|
+
} from './personal-data';
|
|
98
102
|
// For a change feed holding a table name and raw columns: `entityForTable` + `decodeRow` is the row
|
|
99
103
|
// the app declared, money and all. Exported for `@ultimat3/realtime` (plan 101, 06 i).
|
|
100
104
|
export type { PgArray } from './pg-array-literal';
|
|
@@ -154,8 +158,8 @@ export type {
|
|
|
154
158
|
UpsertArgs,
|
|
155
159
|
} from './repo';
|
|
156
160
|
export type { Retain, RetainInit } from './retain';
|
|
157
|
-
export
|
|
158
|
-
export { observedRepo
|
|
161
|
+
export { rowKey } from './row-key';
|
|
162
|
+
export { observedRepo } from './row-observer';
|
|
159
163
|
export { rowSecuritySettings } from './row-security-settings';
|
|
160
164
|
export type { RecordsByKey, RecordsByType } from './rows-of';
|
|
161
165
|
export { hasEntityRows, projectionsIn, rowsOf } from './rows-of';
|
|
@@ -168,14 +172,27 @@ export { sealedRepo } from './sealed-repo';
|
|
|
168
172
|
// `SEARCH_PROPERTY` is what a `matches` predicate names, which a hand-built `QueryPlan` needs.
|
|
169
173
|
export type { SearchInit, SearchLanguage, SearchSource, SearchVector } from './search';
|
|
170
174
|
export {
|
|
171
|
-
DEFAULT_SEARCH_COLUMN,
|
|
172
|
-
DEFAULT_SEARCH_LANGUAGE,
|
|
173
|
-
DEFAULT_SEARCH_WEIGHT,
|
|
174
|
-
isSearchLanguage,
|
|
175
|
-
isSearchWeight,
|
|
176
175
|
SEARCH_LANGUAGES,
|
|
177
176
|
SEARCH_WEIGHTS,
|
|
178
177
|
} from './search';
|
|
178
|
+
// The searchIndex port: types, the document builder, the installed index, the two shipped ones.
|
|
179
|
+
export type {
|
|
180
|
+
SearchDocument,
|
|
181
|
+
SearchHit,
|
|
182
|
+
SearchIndex,
|
|
183
|
+
SearchIndexOptions,
|
|
184
|
+
SearchListEntry,
|
|
185
|
+
SearchListPage,
|
|
186
|
+
SearchListQuery,
|
|
187
|
+
SearchQuery,
|
|
188
|
+
SearchScope,
|
|
189
|
+
} from './search-index';
|
|
190
|
+
export { searchDocumentOf } from './search-index';
|
|
191
|
+
export { searchIndex } from './search-index-installed';
|
|
192
|
+
export { memorySearchIndex } from './search-index-memory';
|
|
193
|
+
export type { PostgresSearchIndexOptions } from './search-index-postgres';
|
|
194
|
+
export { postgresSearchIndex } from './search-index-postgres';
|
|
195
|
+
export { searchVersionColumn, searchVersionOf } from './search-index-version';
|
|
179
196
|
export type {
|
|
180
197
|
Seed,
|
|
181
198
|
SeedContext,
|
|
@@ -195,7 +212,6 @@ export {
|
|
|
195
212
|
canMove,
|
|
196
213
|
isState,
|
|
197
214
|
isTerminal,
|
|
198
|
-
movesFrom,
|
|
199
215
|
stateMachineOf,
|
|
200
216
|
} from './state-machine';
|
|
201
217
|
export type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
|
package/src/json-null.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Single responsibility: when a `null` handed to a column is the JSON VALUE `null` rather than SQL
|
|
2
|
+
// NULL. One rule, read by the parsers, the Postgres binder and decoder, and the memory matcher —
|
|
3
|
+
// a copy in any one of them is a row the two drivers spell differently.
|
|
4
|
+
|
|
5
|
+
import type { ColumnMeta } from './types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A NOT NULL `jsonb` column: the table holds no SQL NULL, so a `null` reaching it can only be the
|
|
9
|
+
* JSON literal, judged by the column's schema like any other JSON value and stored as
|
|
10
|
+
* `'null'::jsonb`. A NULLABLE `jsonb` column keeps `null` as SQL NULL — the clear every other
|
|
11
|
+
* nullable column means by it, and what `is null` answers in both drivers.
|
|
12
|
+
*/
|
|
13
|
+
export const holdsJsonNull = (meta: ColumnMeta): boolean => meta.kind === 'jsonb' && meta.notNull;
|
package/src/memory-match.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// deciding by `typeof` is answering a different question.
|
|
6
6
|
|
|
7
7
|
import { keyOf } from './batch-read';
|
|
8
|
+
import { columnFor } from './column';
|
|
8
9
|
import { arrayContains, arrayOverlaps, jsonContains, jsonHasKey } from './containment';
|
|
9
10
|
import { kindOf, valueAt } from './cursor';
|
|
10
11
|
import type { EntityCore } from './entity';
|
|
@@ -12,6 +13,7 @@ import { EntityError } from './errors';
|
|
|
12
13
|
import { searchInMemory } from './feature-errors';
|
|
13
14
|
import { instantMicros } from './instant';
|
|
14
15
|
import { isNullish as isNull } from './is-null';
|
|
16
|
+
import { holdsJsonNull } from './json-null';
|
|
15
17
|
import { likeMatches } from './like';
|
|
16
18
|
import { DECIMAL_TEXT, numericOrder } from './numeric-compare';
|
|
17
19
|
import { operatorUnknown } from './operator';
|
|
@@ -21,15 +23,44 @@ import type { ColumnKind } from './types';
|
|
|
21
23
|
const sign = <T extends number | bigint | string>(left: T, right: T): number =>
|
|
22
24
|
left < right ? -1 : left > right ? 1 : 0;
|
|
23
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Text in CODE-POINT order, which is byte order over UTF-8 and so Postgres' `C` collation — the
|
|
28
|
+
* order `auth`'s memory adapter already keeps. JS `<` compares UTF-16 code units, which puts an
|
|
29
|
+
* astral character (lead surrogate 0xD800–0xDBFF) ahead of U+E000–U+FFFF: `'😀' < '!'` is true
|
|
30
|
+
* here and false there, so a page cut on a text key differed between dev and prod. The first
|
|
31
|
+
* differing unit decides; `codePointAt` there reads the whole character when it is a lead surrogate.
|
|
32
|
+
*/
|
|
33
|
+
export const byCodePoint = (left: string, right: string): number => {
|
|
34
|
+
const shorter = Math.min(left.length, right.length);
|
|
35
|
+
for (let at = 0; at < shorter; at += 1) {
|
|
36
|
+
if (left.charCodeAt(at) !== right.charCodeAt(at)) {
|
|
37
|
+
return sign(left.codePointAt(at) ?? 0, right.codePointAt(at) ?? 0);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return sign(left.length, right.length);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* An `integer()` operand written as TEXT is the number Postgres parses it as: the parameter takes
|
|
45
|
+
* the column's type, so `id = '007'` finds row 7 there, and `===` against `7` found nothing here.
|
|
46
|
+
* Only an integer literal is read; anything else stays as written and compares as text.
|
|
47
|
+
*/
|
|
48
|
+
const INTEGER_TEXT = /^\s*[+-]?\d+\s*$/;
|
|
49
|
+
const asInteger = (kind: ColumnKind | undefined, value: unknown): unknown =>
|
|
50
|
+
kind === 'integer' && typeof value === 'string' && INTEGER_TEXT.test(value)
|
|
51
|
+
? Number(value)
|
|
52
|
+
: value;
|
|
53
|
+
|
|
24
54
|
/**
|
|
25
55
|
* Two values of one column, ordered as Postgres orders that column. `-1`, `0` or `1` — never a
|
|
26
56
|
* difference, so a `bigint` pair needs no subtraction it cannot express in a `number`.
|
|
27
57
|
*/
|
|
28
58
|
export const compareByKind = (
|
|
29
59
|
kind: ColumnKind | undefined,
|
|
30
|
-
|
|
31
|
-
|
|
60
|
+
given: unknown,
|
|
61
|
+
operand: unknown,
|
|
32
62
|
): number => {
|
|
63
|
+
const [left, right] = [asInteger(kind, given), asInteger(kind, operand)];
|
|
33
64
|
// NULL is the LARGEST value, which is what `order by` means in Postgres and what `nulls last`
|
|
34
65
|
// ascending / `nulls first` descending spell out (`pg-sql.ts`'s `orderSql`, `@ultimat3/query`'s
|
|
35
66
|
// `compareValues`). Two absences are equal, so the next sort key decides — without this rule a
|
|
@@ -62,7 +93,7 @@ export const compareByKind = (
|
|
|
62
93
|
// `undefined` means the pair is not a numeric comparison, and it falls through as text.
|
|
63
94
|
const numeric = numericOrder(kind !== undefined && DECIMAL_TEXT.has(kind), left, right);
|
|
64
95
|
if (numeric !== undefined) return numeric;
|
|
65
|
-
return
|
|
96
|
+
return byCodePoint(String(left), String(right));
|
|
66
97
|
};
|
|
67
98
|
|
|
68
99
|
/**
|
|
@@ -74,9 +105,10 @@ export const compareByKind = (
|
|
|
74
105
|
*/
|
|
75
106
|
export const sameValueOfKind = (
|
|
76
107
|
kind: ColumnKind | undefined,
|
|
77
|
-
|
|
78
|
-
|
|
108
|
+
given: unknown,
|
|
109
|
+
operand: unknown,
|
|
79
110
|
): boolean => {
|
|
111
|
+
const [left, right] = [asInteger(kind, given), asInteger(kind, operand)];
|
|
80
112
|
if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime();
|
|
81
113
|
// An instant handed over as ISO text is the instant it names, as Postgres parses the bound
|
|
82
114
|
// parameter: `eq` against a stored `Date` compared an object to a string and matched no row.
|
|
@@ -111,6 +143,10 @@ export const matchesPredicate = <Row>(
|
|
|
111
143
|
// predicate and a money sort key both name.
|
|
112
144
|
const kind = kindOf(entity, predicate.column);
|
|
113
145
|
const actual = valueAt(row, predicate.column);
|
|
146
|
+
// The row side of every NULL test. A NOT NULL `jsonb` column holding `null` holds the JSON
|
|
147
|
+
// literal (`json-null.ts`), which Postgres' `is null` never matches and its `@>` compares.
|
|
148
|
+
const held = columnFor(entity.$columns, predicate.column);
|
|
149
|
+
const rowIsNull = isNull(actual) && !(held !== undefined && holdsJsonNull(held.$meta));
|
|
114
150
|
/**
|
|
115
151
|
* `col = <value>`, with SQL's three-valued logic on both sides: a NULL is never EQUAL to
|
|
116
152
|
* anything, the other NULL included, so `equals` answers false the moment either side is one
|
|
@@ -124,34 +160,32 @@ export const matchesPredicate = <Row>(
|
|
|
124
160
|
* `valueAt(row, 'price.minor')` has nothing to read.
|
|
125
161
|
*/
|
|
126
162
|
const equals = (candidate: unknown): boolean =>
|
|
127
|
-
!
|
|
163
|
+
!rowIsNull && !isNull(candidate) && sameValueOfKind(kind, actual, candidate);
|
|
128
164
|
// `col > NULL` is UNKNOWN in SQL and UNKNOWN is not a match, so a NULL on EITHER side matches no
|
|
129
165
|
// row here either — `predicateSql` emits a bare `"col" > $1` and Postgres returns nothing. Without
|
|
130
166
|
// this the fall-through compared `String(null)` as the text `"null"`, which sorts after `"5"` and
|
|
131
167
|
// before `"z"`: `gt(seats, 5)` answered the null row in memory and never in production, and
|
|
132
168
|
// `lt(seats, null)` answered every row. The guard is HERE and not in `compareByKind`, which also
|
|
133
169
|
// orders a page — a sort puts NULLs last (`asc nulls last`) rather than dropping them.
|
|
134
|
-
const unknown = (): boolean =>
|
|
170
|
+
const unknown = (): boolean => rowIsNull || isNull(predicate.value);
|
|
135
171
|
const order = (): number => compareByKind(kind, actual, predicate.value);
|
|
136
172
|
switch (predicate.op) {
|
|
137
173
|
// `predicateSql` compiles a null operand to `"col" is null` rather than binding it, so this
|
|
138
174
|
// is the same predicate, not a widening of it.
|
|
139
175
|
case 'eq':
|
|
140
|
-
return predicate.value === null ?
|
|
176
|
+
return predicate.value === null ? rowIsNull : equals(predicate.value);
|
|
141
177
|
// `is distinct from` reads a NULL as a value on BOTH sides: TRUE where one side is null and
|
|
142
178
|
// the other is not, FALSE where both are. A bound `undefined` is a NULL parameter there, so
|
|
143
179
|
// the operand side reads through `isNull` and the two spellings mean one thing.
|
|
144
180
|
case 'neq':
|
|
145
|
-
return isNull(predicate.value) ? !
|
|
181
|
+
return isNull(predicate.value) ? !rowIsNull : !equals(predicate.value);
|
|
146
182
|
// `in` reads a LIST or nothing: an operand that is not an array matches no row, which is what
|
|
147
183
|
// `predicateSql` now compiles it to and what `@ultimat3/query` answers for the same operand.
|
|
148
184
|
// A NULL inside the list is asked as `is null` beside the list there, for the same reason.
|
|
149
185
|
case 'in':
|
|
150
186
|
return (
|
|
151
187
|
Array.isArray(predicate.value) &&
|
|
152
|
-
predicate.value.some((candidate) =>
|
|
153
|
-
isNull(candidate) ? isNull(actual) : equals(candidate),
|
|
154
|
-
)
|
|
188
|
+
predicate.value.some((candidate) => (isNull(candidate) ? rowIsNull : equals(candidate)))
|
|
155
189
|
);
|
|
156
190
|
case 'gt':
|
|
157
191
|
return !unknown() && order() > 0;
|
|
@@ -166,22 +200,22 @@ export const matchesPredicate = <Row>(
|
|
|
166
200
|
case 'like':
|
|
167
201
|
return !unknown() && likeMatches(entity.$name, String(predicate.value), String(actual));
|
|
168
202
|
case 'is-null':
|
|
169
|
-
return
|
|
203
|
+
return rowIsNull;
|
|
170
204
|
case 'is-not-null':
|
|
171
|
-
return !
|
|
205
|
+
return !rowIsNull;
|
|
172
206
|
// The containment half, decided by the column's DECLARED kind exactly as everything above it
|
|
173
207
|
// is: `@>` on a `jsonb` is recursive structural containment and `@>` on an array is plain
|
|
174
208
|
// element containment, and those are two different operators that happen to share a symbol.
|
|
175
209
|
// A NULL column value matches nothing, which is what the SQL answers too.
|
|
176
210
|
case 'contains':
|
|
177
|
-
return !
|
|
211
|
+
return !rowIsNull && containsBy(kind, actual, predicate.value);
|
|
178
212
|
case 'contained-by':
|
|
179
|
-
return !
|
|
213
|
+
return !rowIsNull && containsBy(kind, predicate.value, actual);
|
|
180
214
|
// `&&` is arrays only. A `jsonb` column reaching it is refused rather than guessed at: there
|
|
181
215
|
// is no `jsonb && jsonb` in Postgres, so any answer here would be one no statement can make.
|
|
182
216
|
case 'overlaps':
|
|
183
217
|
return (
|
|
184
|
-
!
|
|
218
|
+
!rowIsNull &&
|
|
185
219
|
arrayOverlaps(
|
|
186
220
|
asArray(entity, predicate, actual),
|
|
187
221
|
asArray(entity, predicate, predicate.value),
|