@ultimat3/entity 30.9.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.ts +40 -54
- package/src/enum-column.ts +8 -0
- package/src/index.ts +43 -27
- package/src/memory-match.ts +35 -5
- 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-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/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.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/memory-match.ts
CHANGED
|
@@ -23,15 +23,44 @@ import type { ColumnKind } from './types';
|
|
|
23
23
|
const sign = <T extends number | bigint | string>(left: T, right: T): number =>
|
|
24
24
|
left < right ? -1 : left > right ? 1 : 0;
|
|
25
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
|
+
|
|
26
54
|
/**
|
|
27
55
|
* Two values of one column, ordered as Postgres orders that column. `-1`, `0` or `1` — never a
|
|
28
56
|
* difference, so a `bigint` pair needs no subtraction it cannot express in a `number`.
|
|
29
57
|
*/
|
|
30
58
|
export const compareByKind = (
|
|
31
59
|
kind: ColumnKind | undefined,
|
|
32
|
-
|
|
33
|
-
|
|
60
|
+
given: unknown,
|
|
61
|
+
operand: unknown,
|
|
34
62
|
): number => {
|
|
63
|
+
const [left, right] = [asInteger(kind, given), asInteger(kind, operand)];
|
|
35
64
|
// NULL is the LARGEST value, which is what `order by` means in Postgres and what `nulls last`
|
|
36
65
|
// ascending / `nulls first` descending spell out (`pg-sql.ts`'s `orderSql`, `@ultimat3/query`'s
|
|
37
66
|
// `compareValues`). Two absences are equal, so the next sort key decides — without this rule a
|
|
@@ -64,7 +93,7 @@ export const compareByKind = (
|
|
|
64
93
|
// `undefined` means the pair is not a numeric comparison, and it falls through as text.
|
|
65
94
|
const numeric = numericOrder(kind !== undefined && DECIMAL_TEXT.has(kind), left, right);
|
|
66
95
|
if (numeric !== undefined) return numeric;
|
|
67
|
-
return
|
|
96
|
+
return byCodePoint(String(left), String(right));
|
|
68
97
|
};
|
|
69
98
|
|
|
70
99
|
/**
|
|
@@ -76,9 +105,10 @@ export const compareByKind = (
|
|
|
76
105
|
*/
|
|
77
106
|
export const sameValueOfKind = (
|
|
78
107
|
kind: ColumnKind | undefined,
|
|
79
|
-
|
|
80
|
-
|
|
108
|
+
given: unknown,
|
|
109
|
+
operand: unknown,
|
|
81
110
|
): boolean => {
|
|
111
|
+
const [left, right] = [asInteger(kind, given), asInteger(kind, operand)];
|
|
82
112
|
if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime();
|
|
83
113
|
// An instant handed over as ISO text is the instant it names, as Postgres parses the bound
|
|
84
114
|
// parameter: `eq` against a stored `Date` compared an object to a string and matched no row.
|
package/src/memory-observed.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// Single responsibility: the memory driver's half of
|
|
1
|
+
// Single responsibility: the memory driver's half of the `db.statement` event. Generated
|
|
2
2
|
// tests run on this driver, so a loop it never reported was a `statements` assertion that could not
|
|
3
3
|
// fail. One event per repository operation, attributed exactly as `postgresRepo` attributes its
|
|
4
4
|
// statement; nothing is sent, so `text` names the operation and holds no SQL.
|
|
5
5
|
|
|
6
|
-
import { tryUseContext } from '@ultimat3/core';
|
|
7
|
-
import { expectedQueryLoopReason
|
|
6
|
+
import { callerStack, instrument, subscribed, tryUseContext } from '@ultimat3/core';
|
|
7
|
+
import { expectedQueryLoopReason } from '@ultimat3/db';
|
|
8
8
|
import type { MemoryRepo } from './repo';
|
|
9
9
|
|
|
10
10
|
/** What one operation reports: the verb `statementKind` reads, and its row count. */
|
|
@@ -48,11 +48,13 @@ const report = (
|
|
|
48
48
|
op: string,
|
|
49
49
|
verb: Reported['verb'],
|
|
50
50
|
rows: number,
|
|
51
|
+
callerStack: string | undefined,
|
|
51
52
|
error?: unknown,
|
|
52
53
|
): void => {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
if (!subscribed('db.statement')) return;
|
|
55
|
+
// Emitted here, not through `@ultimat3/db`: nothing is sent, so no funnel sees it — this IS the
|
|
56
|
+
// memory driver's funnel, and a subscriber must not be able to tell the two drivers apart.
|
|
57
|
+
instrument('db.statement', {
|
|
56
58
|
text: `${verb} ${entity}.${op} (memory driver, no SQL sent)`,
|
|
57
59
|
values: [],
|
|
58
60
|
// Nothing crossed a wire, and a duration made up of `Map` reads would only rank as noise.
|
|
@@ -61,6 +63,7 @@ const report = (
|
|
|
61
63
|
...(error === undefined ? {} : { error }),
|
|
62
64
|
attribution: { entity, op },
|
|
63
65
|
expected: expectedQueryLoopReason(),
|
|
66
|
+
callerStack,
|
|
64
67
|
});
|
|
65
68
|
};
|
|
66
69
|
|
|
@@ -86,7 +89,7 @@ const pointLookups = (): (() => boolean) => {
|
|
|
86
89
|
|
|
87
90
|
type AnyMethod = (...args: readonly unknown[]) => Promise<unknown>;
|
|
88
91
|
|
|
89
|
-
/** `repo`, each statement-shaped method
|
|
92
|
+
/** `repo`, each statement-shaped method emitting once it settles, as the funnels emit. */
|
|
90
93
|
export function observedMemoryRepo<Row>(entity: string, repo: MemoryRepo<Row>): MemoryRepo<Row> {
|
|
91
94
|
const wrapped: Record<string, AnyMethod> = {};
|
|
92
95
|
const firstInMicrotask = pointLookups();
|
|
@@ -94,26 +97,28 @@ export function observedMemoryRepo<Row>(entity: string, repo: MemoryRepo<Row>):
|
|
|
94
97
|
const method = repo[op] as AnyMethod;
|
|
95
98
|
const { verb, rows } = OPERATIONS[op];
|
|
96
99
|
wrapped[op] = async (...args) => {
|
|
97
|
-
if (
|
|
100
|
+
if (!subscribed('db.statement')) return method(...args);
|
|
98
101
|
// Decided as the call is made: by the time the row is back the microtask has moved on.
|
|
99
102
|
const reports = op !== 'findById' || firstInMicrotask();
|
|
100
103
|
// `postgresRepo` attributes an aggregate to its function, never to `aggregate`.
|
|
101
104
|
const name = op === 'aggregate' ? String(args[0]) : op;
|
|
105
|
+
// Before the `await`, as the Postgres funnel reads it: nothing unless capture is on.
|
|
106
|
+
const stack = callerStack();
|
|
102
107
|
let answer: unknown;
|
|
103
108
|
try {
|
|
104
109
|
answer = await method(...args);
|
|
105
110
|
} catch (error) {
|
|
106
|
-
if (reports) report(entity, name, verb, 0, error);
|
|
111
|
+
if (reports) report(entity, name, verb, 0, stack, error);
|
|
107
112
|
throw error;
|
|
108
113
|
}
|
|
109
|
-
if (reports) report(entity, name, verb, rows(answer));
|
|
114
|
+
if (reports) report(entity, name, verb, rows(answer), stack);
|
|
110
115
|
return answer;
|
|
111
116
|
};
|
|
112
117
|
}
|
|
113
118
|
for (const op of LOCKS) {
|
|
114
119
|
const method = repo[op] as AnyMethod;
|
|
115
120
|
wrapped[op] = async (...args) => {
|
|
116
|
-
report(entity, op, 'select', 1);
|
|
121
|
+
report(entity, op, 'select', 1, callerStack());
|
|
117
122
|
return method(...args);
|
|
118
123
|
};
|
|
119
124
|
}
|