@ultimat3/entity 30.9.0 → 31.1.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,66 @@
|
|
|
1
|
+
// The readers a GDPR export or erase job walks by: every column `.personalData(kind)` marked, per
|
|
2
|
+
// entity and across the registry. The mark is `$meta.personalData`, never on `$describe()` — being
|
|
3
|
+
// personal data changes no column, so it must not move the drift hash.
|
|
4
|
+
|
|
5
|
+
import { columnName } from './column';
|
|
6
|
+
import type { EntityCore } from './entity';
|
|
7
|
+
import type { PersonalDataKind } from './personal-data-kind';
|
|
8
|
+
import { registeredEntities } from './registry';
|
|
9
|
+
import type { AnyColumn, ColumnKind } from './types';
|
|
10
|
+
|
|
11
|
+
// The kind set's home is `personal-data-kind.ts` (the chain reads it); the barrel takes it from here.
|
|
12
|
+
export type { PersonalDataKind } from './personal-data-kind';
|
|
13
|
+
export { PERSONAL_DATA_KINDS } from './personal-data-kind';
|
|
14
|
+
|
|
15
|
+
/** The kind a column was marked with, or `undefined` for a column that holds no personal data. */
|
|
16
|
+
export const personalDataKindOf = (column: AnyColumn): PersonalDataKind | undefined =>
|
|
17
|
+
column.$meta.personalData;
|
|
18
|
+
|
|
19
|
+
/** One marked column of one entity, resolved to where it landed. */
|
|
20
|
+
export interface PersonalDataColumn {
|
|
21
|
+
/** The row property — what a repository write names. */
|
|
22
|
+
readonly property: string;
|
|
23
|
+
/** The physical column (the base name, for a money column's parts). */
|
|
24
|
+
readonly column: string;
|
|
25
|
+
readonly kind: PersonalDataKind;
|
|
26
|
+
readonly columnKind: ColumnKind;
|
|
27
|
+
/** Whether an erase may write `null`; otherwise it must write a replacement value. */
|
|
28
|
+
readonly nullable: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Every marked column of `entity`, in declaration order. */
|
|
32
|
+
export const personalDataOf = (
|
|
33
|
+
entity: Pick<EntityCore<unknown>, '$columns'>,
|
|
34
|
+
): readonly PersonalDataColumn[] =>
|
|
35
|
+
Object.entries(entity.$columns).flatMap(([property, column]) => {
|
|
36
|
+
const kind = column.$meta.personalData;
|
|
37
|
+
if (kind === undefined) return [];
|
|
38
|
+
return [
|
|
39
|
+
{
|
|
40
|
+
property,
|
|
41
|
+
column: columnName(property, column.$meta),
|
|
42
|
+
kind,
|
|
43
|
+
columnKind: column.$meta.kind,
|
|
44
|
+
nullable: !column.$meta.notNull,
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** A registered entity holding personal data, with the tenant a subject's rows are scoped by. */
|
|
50
|
+
export interface PersonalDataEntity {
|
|
51
|
+
readonly entity: EntityCore<unknown>;
|
|
52
|
+
/** The tenant column's property, or `null` for an untenanted entity. */
|
|
53
|
+
readonly tenantColumn: string | null;
|
|
54
|
+
readonly columns: readonly PersonalDataColumn[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Every registered entity carrying at least one marked column, in the registry's name order. */
|
|
58
|
+
export const personalDataEntities = (): readonly PersonalDataEntity[] =>
|
|
59
|
+
registeredEntities().flatMap((entry) => {
|
|
60
|
+
const core = entry.core;
|
|
61
|
+
if (core === undefined) return [];
|
|
62
|
+
const columns = personalDataOf(core);
|
|
63
|
+
return columns.length === 0
|
|
64
|
+
? []
|
|
65
|
+
: [{ entity: core, tenantColumn: core.$tenantColumn, columns }];
|
|
66
|
+
});
|
package/src/pg-driver.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
db,
|
|
14
14
|
refuseAbandoned,
|
|
15
15
|
type SqlFragment,
|
|
16
|
+
sql,
|
|
16
17
|
withStatementAttribution,
|
|
17
18
|
} from '@ultimat3/db';
|
|
18
19
|
import { appendOnlyRepo } from './append-only';
|
|
@@ -21,8 +22,9 @@ import { writeChunks } from './bulk-write-send';
|
|
|
21
22
|
import { entityNow } from './clock';
|
|
22
23
|
import { coalesceFindById } from './coalesce';
|
|
23
24
|
import { narrowRow } from './columns';
|
|
24
|
-
import { cursorFor, seekFrom
|
|
25
|
+
import { cursorFor, seekFrom } from './cursor';
|
|
25
26
|
import type { Driver } from './database';
|
|
27
|
+
import { noteDriverStore } from './driver-registry';
|
|
26
28
|
import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
27
29
|
import { notFound, repoClientPinned } from './errors';
|
|
28
30
|
import { stampHistoryActor } from './history-actor';
|
|
@@ -37,7 +39,10 @@ import { postgresTransactor } from './pg-transactor';
|
|
|
37
39
|
import { type ConflictTarget, deleteStatement, updateStatement } from './pg-write-sql';
|
|
38
40
|
import { deletePlan, idPlan, readPlan, updatePlan } from './plan';
|
|
39
41
|
import type { FindManyArgs, Repo, UpsertArgs } from './repo';
|
|
42
|
+
import { rowKey } from './row-key';
|
|
40
43
|
import { sealedRepo } from './sealed-repo';
|
|
44
|
+
import { deletionStamp } from './search-index-version';
|
|
45
|
+
import { writtenRowsSink } from './search-written-rows';
|
|
41
46
|
import type { QueryPlan } from './tenancy';
|
|
42
47
|
import { assertRowTenant } from './tenancy';
|
|
43
48
|
import type { RowWrite } from './types';
|
|
@@ -104,14 +109,25 @@ export const postgresRepo = <Row>(
|
|
|
104
109
|
const attributed = <T>(op: string, send: () => Promise<T>): Promise<T> =>
|
|
105
110
|
withStatementAttribution(entity.$name, op, send);
|
|
106
111
|
|
|
107
|
-
|
|
108
|
-
|
|
112
|
+
/** The cursor's id — `row-key.ts`, the spelling the memory driver stores a row under. */
|
|
113
|
+
const idOf = (row: Row): string => rowKey(entity, row);
|
|
109
114
|
|
|
110
115
|
/** Where a batch stops being wide — before `writeRows` or `upsertPlan` read a row. `narrowRow`
|
|
111
116
|
* says why the position matters, and `memoryRepo` narrows at the same one. */
|
|
112
117
|
const narrowed = (batch: readonly RowWrite<Row>[]): readonly Row[] =>
|
|
113
118
|
batch.map((row) => narrowRow<Row>(entity.$columns, row));
|
|
114
119
|
|
|
120
|
+
/**
|
|
121
|
+
* A soft delete's SET list: the stamp, plus a searchable entity's version — advanced past the
|
|
122
|
+
* stored one in the statement (`pg-write-sql.ts`), so a deletion is a newer version too.
|
|
123
|
+
*/
|
|
124
|
+
const softDeletion = (): ReadonlyMap<string, unknown> =>
|
|
125
|
+
new Map(
|
|
126
|
+
Object.entries(
|
|
127
|
+
deletionStamp(entity as EntityCore<unknown>, SOFT_DELETE_COLUMN, entityNow()),
|
|
128
|
+
).map(([property, value]) => [physicalName(entity, property), value]),
|
|
129
|
+
);
|
|
130
|
+
|
|
115
131
|
const one = async (plan: QueryPlan, args: FindManyArgs): Promise<Row | null> => {
|
|
116
132
|
const [found] = await client().query<PhysicalRow>(
|
|
117
133
|
selectStatement(entity, plan, shapeOf(args), 1),
|
|
@@ -124,20 +140,54 @@ export const postgresRepo = <Row>(
|
|
|
124
140
|
* delete hides the row without losing it; the column's presence is the switch, and `shapeOf({})`
|
|
125
141
|
* keeps the `deleted_at is null` clause so a second call cannot move an existing stamp forward.
|
|
126
142
|
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
143
|
+
* `returning` only when the search mirror asked for the rows, and only up to the judged bound
|
|
144
|
+
* (`counted`): otherwise both callers read a count, and the rows would be a whole tenant's table
|
|
145
|
+
* crossing the wire for nobody.
|
|
130
146
|
*/
|
|
131
|
-
|
|
147
|
+
/** What a removal matches: the live rows of a soft delete, every row of a hard one. */
|
|
148
|
+
const removalShape = (): ReadShape => shapeOf({ includeDeleted: !entity.$softDelete });
|
|
149
|
+
|
|
150
|
+
const removal = (plan: QueryPlan, returning: boolean): SqlFragment =>
|
|
132
151
|
entity.$softDelete
|
|
133
|
-
? updateStatement(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
152
|
+
? updateStatement(entity, plan, softDeletion(), shapeOf({}), returning)
|
|
153
|
+
: returning
|
|
154
|
+
? sql`${deleteStatement(entity, plan)} returning *`
|
|
155
|
+
: deleteStatement(entity, plan);
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A count, or — when the search mirror asked (`search-written-rows.ts`) — the same statement with
|
|
159
|
+
* `returning *`, its rows handed over and counted. The rows ride only for a searchable entity
|
|
160
|
+
* under an external index, and only when a count says they fit the judged bound: past
|
|
161
|
+
* `MAX_ASSERTED_ROWS` the write stays a count and the mirror logs the rows it could not name. A
|
|
162
|
+
* concurrent writer can drift the count, which moves the bound, never the write.
|
|
163
|
+
*/
|
|
164
|
+
const counted = async (
|
|
165
|
+
op: string,
|
|
166
|
+
plan: QueryPlan,
|
|
167
|
+
shape: ReadShape,
|
|
168
|
+
statement: (returning: boolean) => SqlFragment,
|
|
169
|
+
): Promise<number> => {
|
|
170
|
+
const sink = writtenRowsSink();
|
|
171
|
+
const fits =
|
|
172
|
+
sink !== undefined &&
|
|
173
|
+
Number(
|
|
174
|
+
(
|
|
175
|
+
await attributed(op, () =>
|
|
176
|
+
client().one<{ count: unknown }>(countStatement(entity, plan, shape)),
|
|
177
|
+
)
|
|
178
|
+
)?.count ?? 0,
|
|
179
|
+
) <= MAX_ASSERTED_ROWS;
|
|
180
|
+
if (sink === undefined || !fits) {
|
|
181
|
+
return attributed(op, () => writing(() => client().execute(statement(false))));
|
|
182
|
+
}
|
|
183
|
+
return attributed(op, () =>
|
|
184
|
+
writing(async () => {
|
|
185
|
+
const rows = await client().query<PhysicalRow>(statement(true));
|
|
186
|
+
sink(rows.map((row) => decodeRow(entity, row)));
|
|
187
|
+
return rows.length;
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
};
|
|
141
191
|
|
|
142
192
|
/**
|
|
143
193
|
* Every write goes out through here: the ONE place the request's preloaded rows are dropped (a
|
|
@@ -172,7 +222,9 @@ export const postgresRepo = <Row>(
|
|
|
172
222
|
const statement = updateStatement(entity, plan, values, shape, true);
|
|
173
223
|
return judged(async (send) => {
|
|
174
224
|
const written = await attributed(op, () => writing(() => send.query<PhysicalRow>(statement)));
|
|
175
|
-
|
|
225
|
+
const decoded = written.map((row) => decodeRow(entity, row));
|
|
226
|
+
for (const row of decoded) entity.$assert(row);
|
|
227
|
+
writtenRowsSink()?.(decoded);
|
|
176
228
|
return written.length;
|
|
177
229
|
});
|
|
178
230
|
};
|
|
@@ -183,13 +235,8 @@ export const postgresRepo = <Row>(
|
|
|
183
235
|
*/
|
|
184
236
|
const removed = (op: string, plan: QueryPlan): Promise<number> =>
|
|
185
237
|
entity.$softDelete && hasJsOnlyInvariant(entity.$invariants)
|
|
186
|
-
? judgedRows(
|
|
187
|
-
|
|
188
|
-
plan,
|
|
189
|
-
new Map([[physicalName(entity, SOFT_DELETE_COLUMN), entityNow()]]),
|
|
190
|
-
shapeOf({}),
|
|
191
|
-
)
|
|
192
|
-
: attributed(op, () => writing(() => client().execute(removal(plan))));
|
|
238
|
+
? judgedRows(op, plan, softDeletion(), shapeOf({}))
|
|
239
|
+
: counted(op, plan, removalShape(), (returning) => removal(plan, returning));
|
|
193
240
|
|
|
194
241
|
/**
|
|
195
242
|
* The one insert path, for one row or ten thousand: the batch's own column list, split into as
|
|
@@ -372,8 +419,9 @@ export const postgresRepo = <Row>(
|
|
|
372
419
|
// decoded each one, asserted nothing about any of them, and answered with a number.
|
|
373
420
|
// `deleteWhere` was the tell: same predicate, same table, `execute()` and a count.
|
|
374
421
|
if (!hasJsOnlyInvariant(entity.$invariants)) {
|
|
375
|
-
|
|
376
|
-
|
|
422
|
+
return counted(op, plan, shape, (returning) =>
|
|
423
|
+
updateStatement(entity, plan, values, shape, returning),
|
|
424
|
+
);
|
|
377
425
|
}
|
|
378
426
|
return judgedRows(op, plan, values, shape);
|
|
379
427
|
},
|
|
@@ -399,8 +447,13 @@ export const postgresRepo = <Row>(
|
|
|
399
447
|
* `database(entities, { driver: postgresDriver() })` — the one line that moves an app off the
|
|
400
448
|
* in-memory default. Repos are stateless, so there is nothing to memoise.
|
|
401
449
|
*/
|
|
402
|
-
export const postgresDriver = (config: PostgresDriverOptions = {}): Driver =>
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
450
|
+
export const postgresDriver = (config: PostgresDriverOptions = {}): Driver => {
|
|
451
|
+
// Noted as it is built, so `x verify`'s drift step can tell a Postgres app from one whose store
|
|
452
|
+
// owns its own schema (`driver-registry.ts`). `testDriver()` builds one, so it is noted too.
|
|
453
|
+
noteDriverStore('sql', 'postgres');
|
|
454
|
+
return {
|
|
455
|
+
repo: <Row>(entity: EntityCore<Row>) => postgresRepo(entity, config),
|
|
456
|
+
transactor: () =>
|
|
457
|
+
postgresTransactor(config.client === undefined ? {} : { client: config.client }),
|
|
458
|
+
};
|
|
459
|
+
};
|
package/src/pg-write-sql.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { identifier, join, raw, type SqlFragment, sql } from '@ultimat3/db';
|
|
|
13
13
|
import { columnName } from './column';
|
|
14
14
|
import type { EntityCore } from './entity';
|
|
15
15
|
import { conditions, type ReadShape } from './pg-sql';
|
|
16
|
+
import { advancingVersionColumn } from './search-index-version';
|
|
16
17
|
import type { QueryPlan } from './tenancy';
|
|
17
18
|
|
|
18
19
|
/** `on conflict (…) do update set …`, or `do nothing` when there is nothing to overwrite. */
|
|
@@ -39,12 +40,41 @@ export interface InsertShape {
|
|
|
39
40
|
*/
|
|
40
41
|
const DEFAULT_CELL = raw('default');
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
/**
|
|
44
|
+
* A searchable entity's version column, physical — the one a write must ADVANCE past the stored
|
|
45
|
+
* value (`search-index-version.ts`), so two writes inside one millisecond are two versions.
|
|
46
|
+
*/
|
|
47
|
+
const advancingColumn = <Row>(entity: EntityCore<Row>): string | undefined => {
|
|
48
|
+
const property = advancingVersionColumn(entity as EntityCore<unknown>);
|
|
49
|
+
const meta = property === undefined ? undefined : entity.$columns[property]?.$meta;
|
|
50
|
+
return property === undefined || meta === undefined ? undefined : columnName(property, meta);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `VERSION_STEP_MS` as an interval — a closed literal like `default` above, and the same one
|
|
55
|
+
* millisecond (`search-version-monotonic.test.ts` pins the two together).
|
|
56
|
+
*/
|
|
57
|
+
export const VERSION_STEP_SQL = raw("interval '1 millisecond'");
|
|
58
|
+
|
|
59
|
+
/** `greatest(next, stored + 1 ms)`: the version moves forward even when the clock has not. */
|
|
60
|
+
const advanced = (column: string, next: SqlFragment, stored: SqlFragment): SqlFragment =>
|
|
61
|
+
sql`${identifier(column)} = greatest(${next}, ${stored} + ${VERSION_STEP_SQL})`;
|
|
62
|
+
|
|
63
|
+
const conflictSql = <Row>(entity: EntityCore<Row>, conflict: ConflictTarget): SqlFragment => {
|
|
43
64
|
const target = join(conflict.columns.map(identifier));
|
|
65
|
+
const version = advancingColumn(entity);
|
|
44
66
|
return conflict.set.length === 0
|
|
45
67
|
? sql` on conflict (${target}) do nothing`
|
|
46
68
|
: sql` on conflict (${target}) do update set ${join(
|
|
47
|
-
conflict.set.map((column) =>
|
|
69
|
+
conflict.set.map((column) =>
|
|
70
|
+
column === version
|
|
71
|
+
? advanced(
|
|
72
|
+
column,
|
|
73
|
+
sql`excluded.${identifier(column)}`,
|
|
74
|
+
sql`${identifier(entity.$table)}.${identifier(column)}`,
|
|
75
|
+
)
|
|
76
|
+
: sql`${identifier(column)} = excluded.${identifier(column)}`,
|
|
77
|
+
),
|
|
48
78
|
)}`;
|
|
49
79
|
};
|
|
50
80
|
|
|
@@ -96,7 +126,7 @@ export const insertStatement = <Row>(
|
|
|
96
126
|
),
|
|
97
127
|
)})`,
|
|
98
128
|
);
|
|
99
|
-
const conflict = shape.conflict === undefined ? sql`` : conflictSql(shape.conflict);
|
|
129
|
+
const conflict = shape.conflict === undefined ? sql`` : conflictSql(entity, shape.conflict);
|
|
100
130
|
return sql`insert into ${identifier(entity.$table)} (${join(
|
|
101
131
|
shape.columns.map(identifier),
|
|
102
132
|
)}) values ${join(tuples)}${conflict} returning *`;
|
|
@@ -117,8 +147,13 @@ export const updateStatement = <Row>(
|
|
|
117
147
|
returning: boolean,
|
|
118
148
|
): SqlFragment => {
|
|
119
149
|
const json = jsonColumns(entity);
|
|
150
|
+
const version = advancingColumn(entity);
|
|
120
151
|
return sql`update ${identifier(entity.$table)} set ${join(
|
|
121
|
-
[...values].map(([column, value]) =>
|
|
152
|
+
[...values].map(([column, value]) =>
|
|
153
|
+
column === version
|
|
154
|
+
? advanced(column, cell(json, column, value), identifier(column))
|
|
155
|
+
: sql`${identifier(column)} = ${cell(json, column, value)}`,
|
|
156
|
+
),
|
|
122
157
|
)} where ${conditions(entity, plan, shape)}${returning ? sql` returning *` : sql``}`;
|
|
123
158
|
};
|
|
124
159
|
|
package/src/plan.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
patchEmpty,
|
|
13
13
|
writeUnfiltered,
|
|
14
14
|
} from './errors';
|
|
15
|
+
import { screenPredicates } from './predicate-screen';
|
|
15
16
|
import type { FindManyArgs, RepoOptions } from './repo';
|
|
16
17
|
import type { Predicate, QueryPlan, SortKey } from './tenancy';
|
|
17
18
|
import { scopedPlan } from './tenancy';
|
|
@@ -116,6 +117,7 @@ export const assertFinitePageSize = (entityName: string, rows: number): number =
|
|
|
116
117
|
export const planFor = <Row>(entity: EntityCore<Row>, args: FindManyArgs): QueryPlan => {
|
|
117
118
|
const orderBy = totalOrder(entity, args.orderBy ?? []);
|
|
118
119
|
assertSeekable(entity, orderBy);
|
|
120
|
+
screenPredicates(entity, args.where ?? []);
|
|
119
121
|
const scoped =
|
|
120
122
|
args.orgId === undefined || entity.$tenantColumn === null
|
|
121
123
|
? []
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Single responsibility: refuse, at plan time, a predicate no driver can answer — so the refusal
|
|
2
|
+
// never depends on whether a table holds rows. Memory judged an operator only against a row, and
|
|
3
|
+
// answered `[]` for an unknown one on an empty table where Postgres refused; a whole money value
|
|
4
|
+
// compared as one column is the same shape: Postgres has no column to compare it with.
|
|
5
|
+
|
|
6
|
+
import { columnFor } from './column';
|
|
7
|
+
import type { EntityCore } from './entity';
|
|
8
|
+
import { invariantViolated } from './errors';
|
|
9
|
+
import { isOperator, operatorUnknown } from './operator';
|
|
10
|
+
import type { Predicate } from './tenancy';
|
|
11
|
+
|
|
12
|
+
/** A NULL test on a whole money property asks about its amount (`pg-sql.ts` `nullTestPath`). */
|
|
13
|
+
const isNullTest = (predicate: Predicate): boolean =>
|
|
14
|
+
predicate.op === 'is-null' ||
|
|
15
|
+
predicate.op === 'is-not-null' ||
|
|
16
|
+
((predicate.op === 'eq' || predicate.op === 'neq') && predicate.value === null);
|
|
17
|
+
|
|
18
|
+
export const screenPredicates = <Row>(
|
|
19
|
+
entity: EntityCore<Row>,
|
|
20
|
+
where: readonly Predicate[],
|
|
21
|
+
): void => {
|
|
22
|
+
for (const predicate of where) {
|
|
23
|
+
if (!isOperator(predicate.op)) {
|
|
24
|
+
throw operatorUnknown(entity.$name, predicate.column, predicate.op);
|
|
25
|
+
}
|
|
26
|
+
const column = columnFor(entity.$columns, predicate.column);
|
|
27
|
+
if (column?.$meta.kind === 'money' && !isNullTest(predicate)) {
|
|
28
|
+
const property = predicate.column;
|
|
29
|
+
throw invariantViolated(
|
|
30
|
+
entity.$name,
|
|
31
|
+
property,
|
|
32
|
+
`${property} is money: name ${property}.minor or ${property}.currency`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
package/src/query.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { type ReadAggregates, readAggregates } from './read-aggregates';
|
|
|
18
18
|
import type { Repo, RepoOptions, UpsertArgs } from './repo';
|
|
19
19
|
import { pickRow } from './sealed';
|
|
20
20
|
import { SEARCH_PROPERTY } from './search';
|
|
21
|
+
import { searchedRepo } from './search-repo';
|
|
21
22
|
import { type TableLocks, tableLocks } from './table-locks';
|
|
22
23
|
import type { Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
|
|
23
24
|
import { stampedColumns, touch } from './touch';
|
|
@@ -74,6 +75,10 @@ export interface ReadBuilder<Row, S extends SealedKeySet = NoSealedKeys>
|
|
|
74
75
|
* chain can serve: `ts_rank` is a computed value and the cursor carries columns, so the order
|
|
75
76
|
* stays the one the caller declared. An entity with no searchable column is `X_SEARCH_UNDECLARED`
|
|
76
77
|
* and the in-memory driver is `X_SEARCH_IN_MEMORY` — never a different answer from the two.
|
|
78
|
+
*
|
|
79
|
+
* The match is the INSTALLED `SearchIndex`'s (`searchIndex()`, `runtime.ts`'s `searchIndex`).
|
|
80
|
+
* The built-in Postgres one is this predicate in the statement, as above; any other index is
|
|
81
|
+
* asked for ids first (at most `MAX_PAGE_SIZE`), which the chain then reads (`search-repo.ts`).
|
|
77
82
|
*/
|
|
78
83
|
search(term: string): ReadBuilder<Row, S>;
|
|
79
84
|
orderBy(
|
|
@@ -379,10 +384,14 @@ export const tableFor = <Row, C extends ColumnMap>(
|
|
|
379
384
|
related?: RelatedTables,
|
|
380
385
|
): Table<Row, C> => ({
|
|
381
386
|
// One cast: `sealed-repo.ts` refuses at runtime, `SealedKeysOf<C>` is the same rule as a type.
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
387
|
+
// Reads go through `searchedRepo`: a `.search()` asks the installed `SearchIndex` (`search-repo.ts`).
|
|
388
|
+
...(builder<Row, Row>(
|
|
389
|
+
entity,
|
|
390
|
+
searchedRepo(entity, repo),
|
|
391
|
+
EMPTY,
|
|
392
|
+
(row) => row,
|
|
393
|
+
related,
|
|
394
|
+
) as ReadBuilder<Row, SealedKeysOf<C>>),
|
|
386
395
|
insert: async (values, options) => repo.insert(entity.$parse(values), options),
|
|
387
396
|
insertAll: async (rows, options) =>
|
|
388
397
|
repo.insertAll(
|
package/src/registry.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
import type { EntityCore } from './entity';
|
|
13
13
|
import { entityDuplicate } from './entity-error';
|
|
14
14
|
import type { InvariantKind } from './invariants';
|
|
15
|
+
import type { PartitionDescription } from './partition';
|
|
15
16
|
import type { RecordProjection } from './record-projection';
|
|
16
17
|
import type { ColumnDefault, OnDelete } from './types';
|
|
17
18
|
|
|
@@ -177,6 +178,8 @@ export interface EntityDescription {
|
|
|
177
178
|
readonly retain?: RetainDescriptionLike;
|
|
178
179
|
/** On a history entity: its parent's name. */
|
|
179
180
|
readonly historyOf?: string;
|
|
181
|
+
/** `entity({ partitionBy })`, checked (`partition.ts`). Absent otherwise. `x db gen` reads it. */
|
|
182
|
+
readonly partition?: PartitionDescription;
|
|
180
183
|
}
|
|
181
184
|
|
|
182
185
|
export interface RegistryEntry {
|
package/src/row-key.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Single responsibility: a row's identity as one string — the key the memory driver stores a row
|
|
2
|
+
// under and the id both drivers hand `cursorFor`. One definition, so the two cannot drift.
|
|
3
|
+
|
|
4
|
+
import { keyOf } from './batch-read';
|
|
5
|
+
import { kindOf, valueAt } from './cursor';
|
|
6
|
+
import type { EntityCore } from './entity';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Each primary-key value spelled as `keyOf` spells it (a `uuid` lower-cased, a number by value),
|
|
10
|
+
* so `findById(UPPER)` finds the row Postgres finds. A single key is that spelling alone; a
|
|
11
|
+
* composite one is a JSON array of them — joined with `''`, `('a', 'bc')` and `('ab', 'c')` were
|
|
12
|
+
* one key, and the memory driver stored the second row over the first.
|
|
13
|
+
*/
|
|
14
|
+
export const rowKey = <Row>(entity: EntityCore<Row>, row: unknown): string => {
|
|
15
|
+
const keys = entity.$primaryKey.map((property) =>
|
|
16
|
+
keyOf(kindOf(entity, property) ?? '', valueAt(row, property)),
|
|
17
|
+
);
|
|
18
|
+
return keys.length === 1 ? (keys[0] ?? '') : JSON.stringify(keys);
|
|
19
|
+
};
|