@ultimat3/entity 11.2.0 → 12.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 +190 -20
- package/README.md +120 -1
- package/package.json +5 -5
- package/src/aggregate-decode.ts +35 -0
- package/src/aggregate-fold.ts +91 -0
- package/src/aggregate.ts +232 -0
- package/src/batch.ts +2 -1
- package/src/column.ts +15 -1
- package/src/containment.ts +94 -0
- package/src/cursor.ts +100 -14
- package/src/database.ts +1 -1
- package/src/describe.ts +3 -0
- package/src/entity.ts +153 -7
- package/src/errors.ts +6 -0
- package/src/index.ts +3 -1
- package/src/instant.ts +93 -0
- package/src/memory-match.ts +67 -0
- package/src/memory-repo.ts +357 -0
- package/src/pg-driver.ts +94 -7
- package/src/pg-row.ts +38 -1
- package/src/pg-sql.ts +279 -133
- package/src/pg-write-sql.ts +129 -0
- package/src/plan.ts +71 -10
- package/src/query.ts +59 -3
- package/src/registry.ts +7 -0
- package/src/repo.ts +16 -328
- package/src/tenancy.ts +18 -2
- package/src/types.ts +10 -0
package/src/plan.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// which rows are in scope, what the total sort order is, how big a page is. A guard only one
|
|
4
4
|
// driver applies is worse than none: the test passes and production leaks another tenant's rows.
|
|
5
5
|
|
|
6
|
+
import { assertSeekable } from './cursor';
|
|
6
7
|
import type { EntityCore } from './entity';
|
|
7
8
|
import { EntityError, invariantViolated, patchEmpty, writeUnfiltered } from './errors';
|
|
8
9
|
import type { FindManyArgs, RepoOptions } from './repo';
|
|
@@ -49,19 +50,32 @@ export const singleKeyOf = <Row>(entity: EntityCore<Row>, operation: string): st
|
|
|
49
50
|
* The primary key is always the final key — a cursor needs a total order, or two rows with the same
|
|
50
51
|
* sort value straddle a page boundary.
|
|
51
52
|
*
|
|
53
|
+
* The tiebreak takes the LAST DECLARED key's direction rather than an unconditional `asc`, and
|
|
54
|
+
* that is not a preference. `IndexInit.order` is ONE direction for a whole index, so
|
|
55
|
+
* `orderBy('createdAt', 'desc')` used to run `created_at desc, id asc` — an order this framework's
|
|
56
|
+
* own DSL cannot declare an index for, whatever the author wrote. It also decided the seek's
|
|
57
|
+
* shape: a mixed order has no row comparison, so the seek fell to the or-chain, which measured on
|
|
58
|
+
* Postgres 16 as a BitmapOr plus a Sort where `(created_at, id) < ($1, $2)` is an Index Only Scan.
|
|
59
|
+
* A caller who wants the mixed order still writes it — naming the key itself is what turns the
|
|
60
|
+
* append off.
|
|
61
|
+
*
|
|
52
62
|
* Exported because a chain can be judged before it runs: `inBatches()` refuses an ordering that
|
|
53
|
-
* cannot carry a cursor
|
|
54
|
-
*
|
|
63
|
+
* cannot carry a cursor — an undeclared column, or a nullable key in the TIEBREAK, never an
|
|
64
|
+
* ordinary nullable one — and it has to be looking at the order the driver will send rather than
|
|
65
|
+
* at the one the caller typed.
|
|
55
66
|
*/
|
|
56
67
|
export const totalOrder = <Row>(
|
|
57
68
|
entity: EntityCore<Row>,
|
|
58
69
|
ordered: readonly SortKey[],
|
|
59
|
-
): readonly SortKey[] =>
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
70
|
+
): readonly SortKey[] => {
|
|
71
|
+
const direction = ordered.at(-1)?.direction ?? 'asc';
|
|
72
|
+
return [
|
|
73
|
+
...ordered,
|
|
74
|
+
...entity.$primaryKey
|
|
75
|
+
.filter((property) => !ordered.some((entry) => entry.column === property))
|
|
76
|
+
.map((property) => ({ column: property, direction })),
|
|
77
|
+
];
|
|
78
|
+
};
|
|
65
79
|
|
|
66
80
|
/**
|
|
67
81
|
* What a page size has to be before a statement carries it: rows, whole, at least one and at most
|
|
@@ -83,8 +97,20 @@ export const assertPageSize = (entityName: string, rows: number): void => {
|
|
|
83
97
|
});
|
|
84
98
|
};
|
|
85
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Whether an ordering can carry a page position is a property of the ORDER, so it is decided here
|
|
102
|
+
* — where the order the driver will send is first known — and not in `cursorFor`, which runs only
|
|
103
|
+
* when a page found one row past its limit. That is what made the refusal depend on the table:
|
|
104
|
+
* `orderBy('publishedAt', 'desc').limit(20)` over a nullable column was green on fifteen seeded
|
|
105
|
+
* rows for as long as the test suite existed and `X_INVARIANT_VIOLATED` on the first read past
|
|
106
|
+
* twenty in production. `assertBatchable` has always judged `inBatches()` this way.
|
|
107
|
+
*
|
|
108
|
+
* `cursorFor` and `seekFrom` still call it: they are reached from a driver directly.
|
|
109
|
+
*/
|
|
86
110
|
export const planFor = <Row>(entity: EntityCore<Row>, args: FindManyArgs): QueryPlan => {
|
|
87
111
|
if (args.limit !== undefined) assertPageSize(entity.$name, args.limit);
|
|
112
|
+
const orderBy = totalOrder(entity, args.orderBy ?? []);
|
|
113
|
+
assertSeekable(entity, orderBy);
|
|
88
114
|
const scoped =
|
|
89
115
|
args.orgId === undefined || entity.$tenantColumn === null
|
|
90
116
|
? []
|
|
@@ -92,7 +118,7 @@ export const planFor = <Row>(entity: EntityCore<Row>, args: FindManyArgs): Query
|
|
|
92
118
|
return {
|
|
93
119
|
entity: entity.$name,
|
|
94
120
|
where: [...(args.where ?? []), ...scoped],
|
|
95
|
-
orderBy
|
|
121
|
+
orderBy,
|
|
96
122
|
limit: args.limit ?? DEFAULT_PAGE_SIZE,
|
|
97
123
|
...(args.cursor === undefined || args.cursor === null ? {} : { cursor: args.cursor }),
|
|
98
124
|
...(args.select === undefined ? {} : { select: args.select }),
|
|
@@ -109,7 +135,42 @@ export const readPlan = <Row>(
|
|
|
109
135
|
entity: EntityCore<Row>,
|
|
110
136
|
args: FindManyArgs,
|
|
111
137
|
operation: string,
|
|
112
|
-
): QueryPlan =>
|
|
138
|
+
): QueryPlan => {
|
|
139
|
+
const plan = planFor(entity, args);
|
|
140
|
+
// BEFORE tenancy, deliberately. Applied after, a tenant-scoped entity had no reachable call at
|
|
141
|
+
// all: unscoped it was `X_TENANCY_UNSCOPED` and scoped it was the refusal below, so the method
|
|
142
|
+
// was declared and unusable — the defect class this repo keeps re-shipping. One refusal, and it
|
|
143
|
+
// names which of the two situations the caller is in.
|
|
144
|
+
if (operation === 'approximateCount') assertEstimable(entity, plan);
|
|
145
|
+
return scopedPlan(entity.$name, entity.$tenantColumn, operation, plan);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* An estimate is the TABLE's. `reltuples` knows nothing about a predicate, so a filtered chain
|
|
150
|
+
* asking for one would be answered a different question from the one it asked — and a caller
|
|
151
|
+
* reading `posts.where({ orgId }).approximateCount()` as "roughly how many of mine" would be
|
|
152
|
+
* handed every other tenant's rows too, which is the reading that matters.
|
|
153
|
+
*
|
|
154
|
+
* A TENANT-SCOPED entity is therefore refused outright, filters or none: its whole-table estimate
|
|
155
|
+
* is a number about every tenant, and a per-tenant row count is not a thing the planner holds.
|
|
156
|
+
* That refusal is not a limitation of this method, it is what the method means.
|
|
157
|
+
*/
|
|
158
|
+
const assertEstimable = <Row>(entity: EntityCore<Row>, plan: QueryPlan): void => {
|
|
159
|
+
if (entity.$tenantColumn !== null) {
|
|
160
|
+
throw new EntityError({
|
|
161
|
+
code: 'X_APPROXIMATE_COUNT_FILTERED',
|
|
162
|
+
cause: `${entity.$name}.approximateCount() is a whole-TABLE estimate and ${entity.$name} is scoped by ${entity.$tenantColumn} — the planner holds one number for every tenant together`,
|
|
163
|
+
fix: `${entity.$name}.count() # the exact answer, scoped to the acting actor's tenant as every other read is`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (plan.where.length === 0) return;
|
|
167
|
+
const named = plan.where.map((each) => each.column).join(', ');
|
|
168
|
+
throw new EntityError({
|
|
169
|
+
code: 'X_APPROXIMATE_COUNT_FILTERED',
|
|
170
|
+
cause: `${entity.$name}.approximateCount() carries ${plan.where.length} predicate(s) (${named}) — the planner estimates the TABLE and knows nothing about them`,
|
|
171
|
+
fix: `${entity.$name}.count() # the exact answer for a filtered chain; approximateCount() answers for the whole table only`,
|
|
172
|
+
});
|
|
173
|
+
};
|
|
113
174
|
|
|
114
175
|
/**
|
|
115
176
|
* The plan for an id-addressed write. A write is a query too: without the same guard,
|
package/src/query.ts
CHANGED
|
@@ -15,7 +15,7 @@ import type { Relation } from './relations';
|
|
|
15
15
|
import { relationNamed } from './relations';
|
|
16
16
|
import type { Page, Repo, RepoOptions, UpsertArgs } from './repo';
|
|
17
17
|
import type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
|
|
18
|
-
import type { ColumnMap, IdOf, Insertable, RowPatch } from './types';
|
|
18
|
+
import type { ColumnMap, IdOf, Insertable, MoneyValue, RowPatch } from './types';
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* What a preloaded relation adds to a row. `unknown` because the name is a string resolved at
|
|
@@ -59,8 +59,12 @@ export interface ReadBuilder<Row> {
|
|
|
59
59
|
*
|
|
60
60
|
* The loop closes it: `break`, `return` and a throw all stop the next statement, and
|
|
61
61
|
* `await using` does the same for a handle kept in a variable. A chain that cannot carry a
|
|
62
|
-
* cursor
|
|
63
|
-
* not one
|
|
62
|
+
* cursor and a chain that also called `limit()` are refused here, not one batch later. An
|
|
63
|
+
* ORDINARY nullable sort column is not one of those: it orders `nulls last` ascending and
|
|
64
|
+
* `nulls first` descending, and the cursor carries that position. What is refused is a sort key
|
|
65
|
+
* that leaves the order un-total — an undeclared column, a money property named without its
|
|
66
|
+
* part, or a nullable PRIMARY-KEY column, where `null = null` is unknown and two such rows are
|
|
67
|
+
* one position to the seek (`cursor.ts`'s `assertSeekable`).
|
|
64
68
|
*/
|
|
65
69
|
inBatches(size: number): BatchIterator<Row>;
|
|
66
70
|
/** The terminal: one bounded page and the cursor that continues it. */
|
|
@@ -83,6 +87,42 @@ export interface ReadBuilder<Row> {
|
|
|
83
87
|
* jsonb, money), and a chain matching more distinct values than one statement should answer with.
|
|
84
88
|
*/
|
|
85
89
|
countBy<K extends keyof Row & string>(column: K): Promise<ReadonlyMap<Row[K], number>>;
|
|
90
|
+
/**
|
|
91
|
+
* The four SQL aggregates, over exactly the rows `count()` counts — the chain's filters, its
|
|
92
|
+
* tenancy and its soft-delete visibility, never its page. "Total spend this month" is
|
|
93
|
+
* `payments.where({ orgId }).andWhere('paidAt', 'gte', from).sum('amount')`, one statement,
|
|
94
|
+
* rather than a page loop or a hand-written query outside every guard this layer applies.
|
|
95
|
+
*
|
|
96
|
+
* **Never a float.** `sum` and `avg` answer decimal TEXT whatever the column was — the sum of a
|
|
97
|
+
* million `integer` rows is not an `integer` and `Number()` on it loses digits past 2^53 — and a
|
|
98
|
+
* money column answers a `MoneyValue` in integer minor units. `min`/`max` answer the row's own
|
|
99
|
+
* type, because the answer is one of the values that went in.
|
|
100
|
+
*
|
|
101
|
+
* `null` for an empty set in every one of them, which is what SQL answers: a `0` would claim
|
|
102
|
+
* rows were seen.
|
|
103
|
+
*
|
|
104
|
+
* Refused rather than answered: a kind with no aggregate the two drivers can agree on (`text`
|
|
105
|
+
* ordering is the database's collation here and JS code-unit order there), `avg` over money
|
|
106
|
+
* (every answer would be a silent rounding of a fraction of a minor unit), and an amount
|
|
107
|
+
* covering more than one currency or scale.
|
|
108
|
+
*/
|
|
109
|
+
sum<K extends keyof Row & string>(
|
|
110
|
+
column: K,
|
|
111
|
+
): Promise<(Row[K] extends MoneyValue | null ? MoneyValue : string) | null>;
|
|
112
|
+
avg<K extends keyof Row & string>(column: K): Promise<string | null>;
|
|
113
|
+
min<K extends keyof Row & string>(column: K): Promise<Row[K] | null>;
|
|
114
|
+
max<K extends keyof Row & string>(column: K): Promise<Row[K] | null>;
|
|
115
|
+
/**
|
|
116
|
+
* The planner's own row estimate for the table — `reltuples`, one row out of `pg_class`, and the
|
|
117
|
+
* only count that stays constant time as the table grows. `count()` walks every visible row
|
|
118
|
+
* because MVCC gives it no shortcut, so past a few million it is the read that trips a web
|
|
119
|
+
* role's `statement_timeout` and no index can make it cheaper.
|
|
120
|
+
*
|
|
121
|
+
* The whole TABLE, never the chain's filters — a filtered chain is `X_APPROXIMATE_COUNT_FILTERED`
|
|
122
|
+
* rather than an estimate that answers a different question than the one asked. `null` when the
|
|
123
|
+
* table has never been analysed, which is the absence of an estimate and not an estimate of zero.
|
|
124
|
+
*/
|
|
125
|
+
approximateCount(): Promise<number | null>;
|
|
86
126
|
/** The plan this chain describes. Safe to log — `describePlan()` elides values. */
|
|
87
127
|
plan(): QueryPlan;
|
|
88
128
|
}
|
|
@@ -276,6 +316,22 @@ const builder = <Source, Row>(
|
|
|
276
316
|
|
|
277
317
|
count: () => repo.count(args()),
|
|
278
318
|
|
|
319
|
+
// The one cast on each of these, and it is the same seam `countBy` and `select()` have: the
|
|
320
|
+
// driver contract is row-agnostic because a column name is a runtime string there, while the
|
|
321
|
+
// chain knows which property it just named and therefore what comes back.
|
|
322
|
+
sum: async <K extends keyof Row & string>(column: K) =>
|
|
323
|
+
(await repo.aggregate('sum', column, args())) as
|
|
324
|
+
| (Row[K] extends MoneyValue | null ? MoneyValue : string)
|
|
325
|
+
| null,
|
|
326
|
+
avg: async (column: keyof Row & string) =>
|
|
327
|
+
(await repo.aggregate('avg', column, args())) as string | null,
|
|
328
|
+
min: async <K extends keyof Row & string>(column: K) =>
|
|
329
|
+
(await repo.aggregate('min', column, args())) as Row[K] | null,
|
|
330
|
+
max: async <K extends keyof Row & string>(column: K) =>
|
|
331
|
+
(await repo.aggregate('max', column, args())) as Row[K] | null,
|
|
332
|
+
|
|
333
|
+
approximateCount: () => repo.approximateCount(args()),
|
|
334
|
+
|
|
279
335
|
async countBy<K extends keyof Row & string>(column: K) {
|
|
280
336
|
// The one cast on this terminal, and it is the same seam `select()` has: the driver contract
|
|
281
337
|
// is row-agnostic because a column name is a runtime string there, while the chain knows
|
package/src/registry.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// whole domain without importing it — and what makes a duplicate name a build error rather
|
|
4
4
|
// than a silent last-one-wins.
|
|
5
5
|
|
|
6
|
+
import type { IndexMethod } from '@ultimat3/db';
|
|
6
7
|
import { entityDuplicate } from './errors';
|
|
7
8
|
import type { InvariantKind } from './invariants';
|
|
8
9
|
import type { OnDelete } from './types';
|
|
@@ -72,6 +73,12 @@ export interface IndexDescription {
|
|
|
72
73
|
readonly where: string | null;
|
|
73
74
|
/** `null` is Postgres' own default (`asc`), never written out. */
|
|
74
75
|
readonly order: 'asc' | 'desc' | null;
|
|
76
|
+
/**
|
|
77
|
+
* The access method, `undefined` for the `btree` every index was before this existed. Absent
|
|
78
|
+
* rather than `null`, matching `IndexDescriptionLike.using` in `@ultimat3/db`: a snapshot that
|
|
79
|
+
* predates the field and an index that declares nothing read the same, so nothing regenerates.
|
|
80
|
+
*/
|
|
81
|
+
readonly using?: IndexMethod | undefined;
|
|
75
82
|
}
|
|
76
83
|
|
|
77
84
|
export interface EntityDescription {
|
package/src/repo.ts
CHANGED
|
@@ -7,18 +7,8 @@
|
|
|
7
7
|
// table silently skips and repeats rows. A keyset cursor is stable because it names a
|
|
8
8
|
// position in the sort order, not a row count.
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { entityNow } from './clock';
|
|
13
|
-
import { narrowMoney } from './columns';
|
|
14
|
-
import { countsFrom, groupColumnOf } from './count-by';
|
|
15
|
-
import { cursorFor, kindOf, seekFrom, valueAt } from './cursor';
|
|
16
|
-
import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
17
|
-
import { notFound } from './errors';
|
|
18
|
-
import { compareByKind, matchesPredicate } from './memory-match';
|
|
19
|
-
import { deletePlan, idPlan, readPlan, singleKeyOf, updatePlan } from './plan';
|
|
20
|
-
import type { Predicate, QueryPlan, SortKey } from './tenancy';
|
|
21
|
-
import { assertRowTenant } from './tenancy';
|
|
10
|
+
import type { AggregateFn } from './aggregate';
|
|
11
|
+
import type { Predicate, SortKey } from './tenancy';
|
|
22
12
|
import type { IdOf, RowPatch } from './types';
|
|
23
13
|
|
|
24
14
|
export interface Tx {
|
|
@@ -136,6 +126,20 @@ export interface Repo<T = unknown> {
|
|
|
136
126
|
* `ReadBuilder.countBy`, which knows the row. Ordered by count, biggest group first.
|
|
137
127
|
*/
|
|
138
128
|
countBy(column: string, args?: FindManyArgs): Promise<ReadonlyMap<unknown, number>>;
|
|
129
|
+
/**
|
|
130
|
+
* One aggregate over exactly the rows `count(args)` counts. Row-agnostic here and typed on the
|
|
131
|
+
* chain, the same seam `countBy` has: a column name is a runtime string at this contract.
|
|
132
|
+
*
|
|
133
|
+
* `null` for an empty set in every function, which is what SQL answers — a `0` would claim rows
|
|
134
|
+
* were seen. A `sum` or an `avg` comes back as decimal TEXT and a money aggregate as a
|
|
135
|
+
* `MoneyValue`; neither is ever a float.
|
|
136
|
+
*/
|
|
137
|
+
aggregate(fn: AggregateFn, column: string, args?: FindManyArgs): Promise<unknown>;
|
|
138
|
+
/**
|
|
139
|
+
* The planner's own row estimate for the table — not a count, and never filtered. `null` when
|
|
140
|
+
* the table has never been analysed, which is a fact and not an estimate.
|
|
141
|
+
*/
|
|
142
|
+
approximateCount(args?: FindManyArgs): Promise<number | null>;
|
|
139
143
|
}
|
|
140
144
|
|
|
141
145
|
/**
|
|
@@ -154,319 +158,3 @@ export interface MemoryRepo<Row> extends Repo<Row> {
|
|
|
154
158
|
export interface Transactor {
|
|
155
159
|
run<R>(work: (tx: Tx) => Promise<R>): Promise<R>;
|
|
156
160
|
}
|
|
157
|
-
|
|
158
|
-
const field = (row: unknown, property: string): unknown =>
|
|
159
|
-
typeof row === 'object' && row !== null ? (row as Record<string, unknown>)[property] : undefined;
|
|
160
|
-
|
|
161
|
-
/** Lexicographic over the sort keys, direction applied. `> 0` means "after the cursor". */
|
|
162
|
-
const compareToSeek = <Row>(
|
|
163
|
-
entity: EntityCore<Row>,
|
|
164
|
-
plan: QueryPlan,
|
|
165
|
-
row: unknown,
|
|
166
|
-
seek: readonly unknown[],
|
|
167
|
-
): number => {
|
|
168
|
-
for (const [index, entry] of plan.orderBy.entries()) {
|
|
169
|
-
// The COLUMN's kind, not the value's: the seek was revived from the same kind (`cursor.ts`),
|
|
170
|
-
// so a `bigint` column compares its stored decimal string against a revived `BigInt` as one
|
|
171
|
-
// number instead of as two pieces of text.
|
|
172
|
-
const order = compareByKind(
|
|
173
|
-
kindOf(entity, entry.column),
|
|
174
|
-
valueAt(row, entry.column),
|
|
175
|
-
seek[index],
|
|
176
|
-
);
|
|
177
|
-
if (order !== 0) return entry.direction === 'desc' ? -order : order;
|
|
178
|
-
}
|
|
179
|
-
return 0;
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Where the next page starts. By sort position, not by the previous row's id: that row may have
|
|
184
|
-
* been deleted between the two requests, and an id that is no longer there would restart
|
|
185
|
-
* pagination at the top instead of continuing it.
|
|
186
|
-
*/
|
|
187
|
-
const afterCursor = <Row>(
|
|
188
|
-
entity: EntityCore<Row>,
|
|
189
|
-
plan: QueryPlan,
|
|
190
|
-
found: readonly Row[],
|
|
191
|
-
): number => {
|
|
192
|
-
const seek = seekFrom(entity, plan);
|
|
193
|
-
if (seek === undefined) return 0;
|
|
194
|
-
const start = found.findIndex((row) => compareToSeek(entity, plan, row, seek) > 0);
|
|
195
|
-
return start === -1 ? found.length : start;
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* The default driver: correct semantics, no database. `x dev` uses it before the first
|
|
200
|
-
* migration and tests use it everywhere. Postgres is the production driver and implements
|
|
201
|
-
* this same interface.
|
|
202
|
-
*/
|
|
203
|
-
export const memoryRepo = <Row>(
|
|
204
|
-
entity: EntityCore<Row>,
|
|
205
|
-
seed: readonly Row[] = [],
|
|
206
|
-
): MemoryRepo<Row> => {
|
|
207
|
-
/**
|
|
208
|
-
* A stored row's key, spelled the way `batch-read.ts` spells an id — because Postgres compares a
|
|
209
|
-
* `uuid` as a VALUE and prints it lower-cased, so `findById(UPPER)` reads the row there while
|
|
210
|
-
* `String(...)` missed it here: `null` from a read and `X_NOT_FOUND` from a write, against a row
|
|
211
|
-
* that exists, reachable from a path parameter, a client-supplied id or a legacy import.
|
|
212
|
-
*/
|
|
213
|
-
const storeKey = (row: unknown): string =>
|
|
214
|
-
entity.$primaryKey
|
|
215
|
-
.map((property) => keyOf(kindOf(entity, property) ?? '', field(row, property)))
|
|
216
|
-
.join('');
|
|
217
|
-
/** The same key, from the id a caller named rather than from a row it has in hand. */
|
|
218
|
-
const idStoreKey = (id: unknown, operation: string): string =>
|
|
219
|
-
keyOf(kindOf(entity, singleKeyOf(entity, operation)) ?? '', id);
|
|
220
|
-
const rows = new Map<string, Row>(seed.map((row) => [storeKey(row), row]));
|
|
221
|
-
|
|
222
|
-
const rowsOf = (plan: QueryPlan, args: FindManyArgs): Row[] => {
|
|
223
|
-
const visible = (row: Row): boolean =>
|
|
224
|
-
!entity.$softDelete ||
|
|
225
|
-
args.includeDeleted === true ||
|
|
226
|
-
field(row, SOFT_DELETE_COLUMN) === null ||
|
|
227
|
-
field(row, SOFT_DELETE_COLUMN) === undefined;
|
|
228
|
-
return [...rows.values()]
|
|
229
|
-
.filter((row) => plan.where.every((predicate) => matchesPredicate(entity, row, predicate)))
|
|
230
|
-
.filter(visible)
|
|
231
|
-
.sort((left, right) => {
|
|
232
|
-
for (const entry of plan.orderBy) {
|
|
233
|
-
const order = compareByKind(
|
|
234
|
-
kindOf(entity, entry.column),
|
|
235
|
-
valueAt(left, entry.column),
|
|
236
|
-
valueAt(right, entry.column),
|
|
237
|
-
);
|
|
238
|
-
if (order !== 0) return entry.direction === 'desc' ? -order : order;
|
|
239
|
-
}
|
|
240
|
-
return 0;
|
|
241
|
-
});
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
const select = (args: FindManyArgs, operation: string): { plan: QueryPlan; found: Row[] } => {
|
|
245
|
-
const plan = readPlan(entity, args, operation);
|
|
246
|
-
return { plan, found: rowsOf(plan, args) };
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
const write = (given: Row, options: RepoOptions | undefined, operation: string): Row => {
|
|
250
|
-
// `MoneyInput` lets a writer hand a `bigint`; a stored row holds the value type. The Postgres
|
|
251
|
-
// driver narrows in `bindValues` and reads its answer back through `returning *`, so without
|
|
252
|
-
// this an in-memory row would be the one row in the framework `JSON.stringify` refuses.
|
|
253
|
-
const row = narrowMoney(entity.$columns, given);
|
|
254
|
-
// Beside `$assert`, and before the row lands: a write is judged by the tenant it names as well
|
|
255
|
-
// as by the invariants it declares, and the Postgres driver runs the same pair in `writeRows`.
|
|
256
|
-
// `update` reaches here with the STORED row merged under its patch, so a patch that moves a row
|
|
257
|
-
// out of this tenant is refused by the same call that refuses an insert into another one.
|
|
258
|
-
assertRowTenant(entity.$name, entity.$tenantColumn, operation, row);
|
|
259
|
-
entity.$assert(row);
|
|
260
|
-
const key = storeKey(row);
|
|
261
|
-
const previous = rows.get(key);
|
|
262
|
-
options?.tx?.onRollback(() => {
|
|
263
|
-
if (previous === undefined) rows.delete(key);
|
|
264
|
-
else rows.set(key, previous);
|
|
265
|
-
});
|
|
266
|
-
rows.set(key, row);
|
|
267
|
-
return row;
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
// The same guard the read path applies: on a tenant-scoped entity an id alone is not enough
|
|
271
|
-
// to name a row, so `update`/`delete` resolve through a plan rather than through the map.
|
|
272
|
-
const addressed = (id: string, options: RepoOptions | undefined, operation: string): Row => {
|
|
273
|
-
const plan = idPlan(entity, id, options, operation);
|
|
274
|
-
const current = rows.get(idStoreKey(id, operation));
|
|
275
|
-
// A soft-deleted row is hidden from writes too — `delete` on one is `X_NOT_FOUND`, not a
|
|
276
|
-
// second stamp, which is what the Postgres driver's `deleted_at is null` clause already says.
|
|
277
|
-
const hidden =
|
|
278
|
-
current !== undefined &&
|
|
279
|
-
entity.$softDelete &&
|
|
280
|
-
field(current, SOFT_DELETE_COLUMN) !== null &&
|
|
281
|
-
field(current, SOFT_DELETE_COLUMN) !== undefined;
|
|
282
|
-
if (
|
|
283
|
-
current === undefined ||
|
|
284
|
-
hidden ||
|
|
285
|
-
!plan.where.every((predicate) => matchesPredicate(entity, current, predicate))
|
|
286
|
-
) {
|
|
287
|
-
throw notFound(entity.$name, id);
|
|
288
|
-
}
|
|
289
|
-
return current;
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
// Every method is async: a repository call that fails must reject, never throw
|
|
293
|
-
// synchronously, or half the call sites would need two error paths.
|
|
294
|
-
return {
|
|
295
|
-
async findById(id, options) {
|
|
296
|
-
const { found } = select(
|
|
297
|
-
{ ...options, where: [{ column: singleKeyOf(entity, 'findById'), op: 'eq', value: id }] },
|
|
298
|
-
'findById',
|
|
299
|
-
);
|
|
300
|
-
return found[0] ?? null;
|
|
301
|
-
},
|
|
302
|
-
|
|
303
|
-
async findMany(args = {}) {
|
|
304
|
-
const { plan, found } = select(args, 'findMany');
|
|
305
|
-
const start = afterCursor(entity, plan, found);
|
|
306
|
-
const page = found.slice(start, start + plan.limit);
|
|
307
|
-
const last = page.at(-1);
|
|
308
|
-
const more = start + page.length < found.length;
|
|
309
|
-
return {
|
|
310
|
-
rows: page,
|
|
311
|
-
nextCursor:
|
|
312
|
-
more && last !== undefined ? cursorFor(entity, plan, last, storeKey(last)) : null,
|
|
313
|
-
};
|
|
314
|
-
},
|
|
315
|
-
|
|
316
|
-
async insert(values, options) {
|
|
317
|
-
return write(values, options, 'insert');
|
|
318
|
-
},
|
|
319
|
-
|
|
320
|
-
async insertAll(batch, options) {
|
|
321
|
-
// The whole batch is judged before any of it lands: Postgres refuses the statement as one,
|
|
322
|
-
// so a row an invariant rejects — or one naming a tenant this actor may not write — must not
|
|
323
|
-
// leave the rows before it stored here either. `write` re-checks both per row; this loop is
|
|
324
|
-
// what makes the batch all-or-nothing, which is the half a per-row check cannot give.
|
|
325
|
-
for (const row of batch) {
|
|
326
|
-
assertRowTenant(entity.$name, entity.$tenantColumn, 'insertAll', row);
|
|
327
|
-
entity.$assert(row);
|
|
328
|
-
}
|
|
329
|
-
return batch.map((row) => write(row, options, 'insertAll'));
|
|
330
|
-
},
|
|
331
|
-
|
|
332
|
-
async upsertAll(batch, args) {
|
|
333
|
-
// The INCOMING rows, judged before any of them is matched: under `onMatch: 'nothing'` a
|
|
334
|
-
// colliding row is skipped and never reaches `write()`, so checking only what lands would
|
|
335
|
-
// let a row naming another tenant through whenever it happened to collide.
|
|
336
|
-
for (const row of batch) {
|
|
337
|
-
assertRowTenant(entity.$name, entity.$tenantColumn, 'upsertAll', row);
|
|
338
|
-
entity.$assert(row);
|
|
339
|
-
}
|
|
340
|
-
const plan = upsertPlan(entity, batch, args.onConflict, args.onMatch ?? 'update');
|
|
341
|
-
const keys = conflictKeys(entity, plan, batch);
|
|
342
|
-
// The stored rows under the same key, so "does this collide" is the question the unique
|
|
343
|
-
// index answers in Postgres and not a scan per row. A soft-deleted row still occupies its
|
|
344
|
-
// key here, because the index it would collide with there is not partial either — and a row
|
|
345
|
-
// whose target holds a null occupies none, because the index is `NULLS DISTINCT`.
|
|
346
|
-
const stored = new Map<string, Row>();
|
|
347
|
-
for (const row of rows.values()) {
|
|
348
|
-
const key = conflictKeyOf(entity, plan.on, row);
|
|
349
|
-
if (key !== undefined) stored.set(key, row);
|
|
350
|
-
}
|
|
351
|
-
const written: Row[] = [];
|
|
352
|
-
for (const [position, row] of batch.entries()) {
|
|
353
|
-
const key = keys[position];
|
|
354
|
-
const existing = key === undefined ? undefined : stored.get(key);
|
|
355
|
-
// `do nothing` writes no row, and `returning *` therefore names none: a skipped row is
|
|
356
|
-
// absent from the result rather than present and unchanged.
|
|
357
|
-
if (existing !== undefined && plan.set.length === 0) continue;
|
|
358
|
-
const merged =
|
|
359
|
-
existing === undefined
|
|
360
|
-
? row
|
|
361
|
-
: Object.assign(
|
|
362
|
-
{},
|
|
363
|
-
existing,
|
|
364
|
-
Object.fromEntries(plan.set.map((property) => [property, field(row, property)])),
|
|
365
|
-
);
|
|
366
|
-
// `UpsertArgs extends RepoOptions`, so the args ARE the options — one bag, and a `tx`
|
|
367
|
-
// passed to an upsert registers its undo exactly as it does for every other write here.
|
|
368
|
-
const result = write(merged, args, 'upsertAll');
|
|
369
|
-
// Filed as it lands, so a later row of the same batch collides with an earlier one exactly
|
|
370
|
-
// as it would with a row the request stored a moment before it.
|
|
371
|
-
if (key !== undefined) stored.set(key, result);
|
|
372
|
-
written.push(result);
|
|
373
|
-
}
|
|
374
|
-
return written;
|
|
375
|
-
},
|
|
376
|
-
|
|
377
|
-
async update(id, patch, options) {
|
|
378
|
-
return write(Object.assign({}, addressed(id, options, 'update'), patch), options, 'update');
|
|
379
|
-
},
|
|
380
|
-
|
|
381
|
-
async delete(id, options) {
|
|
382
|
-
const current = addressed(id, options, 'delete');
|
|
383
|
-
// Soft delete hides the row without losing it; the column's presence is the switch.
|
|
384
|
-
if (entity.$softDelete) {
|
|
385
|
-
write(Object.assign({}, current, { [SOFT_DELETE_COLUMN]: entityNow() }), options, 'delete');
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
const key = storeKey(current);
|
|
389
|
-
options?.tx?.onRollback(() => rows.set(key, current));
|
|
390
|
-
rows.delete(key);
|
|
391
|
-
},
|
|
392
|
-
|
|
393
|
-
async deleteWhere(filter, options) {
|
|
394
|
-
// `rowsOf` is the read path: the same predicates, the same tenant scoping, and the same
|
|
395
|
-
// soft-delete visibility. A row already stamped is not matched, so a second call cannot
|
|
396
|
-
// move `deletedAt` forward — which is what the Postgres driver's `deleted_at is null`
|
|
397
|
-
// clause says there.
|
|
398
|
-
const doomed = rowsOf(deletePlan(entity, filter, options, 'deleteWhere'), {});
|
|
399
|
-
for (const row of doomed) {
|
|
400
|
-
if (entity.$softDelete) {
|
|
401
|
-
write(
|
|
402
|
-
Object.assign({}, row, { [SOFT_DELETE_COLUMN]: entityNow() }),
|
|
403
|
-
options,
|
|
404
|
-
'deleteWhere',
|
|
405
|
-
);
|
|
406
|
-
continue;
|
|
407
|
-
}
|
|
408
|
-
const key = storeKey(row);
|
|
409
|
-
options?.tx?.onRollback(() => rows.set(key, row));
|
|
410
|
-
rows.delete(key);
|
|
411
|
-
}
|
|
412
|
-
return doomed.length;
|
|
413
|
-
},
|
|
414
|
-
|
|
415
|
-
async updateWhere(filter, patch, options) {
|
|
416
|
-
const plan = updatePlan(entity, filter, patch, options, 'updateWhere');
|
|
417
|
-
// The PATCH, judged whole and before the rows are read — the same call `postgresRepo` makes
|
|
418
|
-
// before its statement exists. Inside the loop below it is judged only where a row was
|
|
419
|
-
// matched, so a patch handing rows to another tenant was refused or accepted depending on
|
|
420
|
-
// what the table happened to hold: `updateWhere(filter, { orgId: theirs })` over a filter
|
|
421
|
-
// matching nothing answered `0` here and threw there, from one call.
|
|
422
|
-
assertRowTenant(entity.$name, entity.$tenantColumn, 'updateWhere', patch);
|
|
423
|
-
// `rowsOf` again, so a soft-deleted row is as unreachable here as it is through
|
|
424
|
-
// `addressed()` — patching a row the app has already deleted is not an update, it is a
|
|
425
|
-
// resurrection nobody asked for. `write` re-asserts the invariants on each result.
|
|
426
|
-
const found = rowsOf(plan, {});
|
|
427
|
-
for (const row of found) write(Object.assign({}, row, patch), options, 'updateWhere');
|
|
428
|
-
return found.length;
|
|
429
|
-
},
|
|
430
|
-
|
|
431
|
-
async count(args = {}) {
|
|
432
|
-
return select(args, 'count').found.length;
|
|
433
|
-
},
|
|
434
|
-
|
|
435
|
-
async countBy(column, args = {}) {
|
|
436
|
-
// Refused before a row is read, and by the same function the Postgres driver calls: a column
|
|
437
|
-
// a map cannot be keyed by is that mistake in both drivers or in neither.
|
|
438
|
-
groupColumnOf(entity, column, 'countBy');
|
|
439
|
-
const { found } = select(args, 'countBy');
|
|
440
|
-
const groups = new Map<unknown, number>();
|
|
441
|
-
for (const row of found) {
|
|
442
|
-
// `?? null`, so a property this row never carried lands in the same group Postgres puts a
|
|
443
|
-
// NULL row in — and `0`, `''` and `false` stay the values they are.
|
|
444
|
-
const value = field(row, column) ?? null;
|
|
445
|
-
groups.set(value, (groups.get(value) ?? 0) + 1);
|
|
446
|
-
}
|
|
447
|
-
return countsFrom(entity, column, 'countBy', [...groups]);
|
|
448
|
-
},
|
|
449
|
-
|
|
450
|
-
reset() {
|
|
451
|
-
rows.clear();
|
|
452
|
-
for (const row of seed) rows.set(storeKey(row), row);
|
|
453
|
-
},
|
|
454
|
-
};
|
|
455
|
-
};
|
|
456
|
-
|
|
457
|
-
let txCounter = 0;
|
|
458
|
-
|
|
459
|
-
/** In-memory transactor: undo closures registered by drivers run on failure. */
|
|
460
|
-
export const memoryTransactor = (): Transactor => ({
|
|
461
|
-
async run(work) {
|
|
462
|
-
const undos: (() => void)[] = [];
|
|
463
|
-
txCounter += 1;
|
|
464
|
-
const tx: Tx = { id: `tx-${txCounter}`, onRollback: (undo) => undos.push(undo) };
|
|
465
|
-
try {
|
|
466
|
-
return await work(tx);
|
|
467
|
-
} catch (error) {
|
|
468
|
-
for (const undo of undos.reverse()) undo();
|
|
469
|
-
throw error;
|
|
470
|
-
}
|
|
471
|
-
},
|
|
472
|
-
});
|
package/src/tenancy.ts
CHANGED
|
@@ -13,6 +13,18 @@ import {
|
|
|
13
13
|
} from './errors';
|
|
14
14
|
import type { ColumnMap } from './types';
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The predicate vocabulary, closed. The last four are the CONTAINMENT half, added 2026-08-24: a
|
|
18
|
+
* `json()` or `arrayOf()` column was declared, written and then unfilterable — the ten operators
|
|
19
|
+
* before them could compare a column to a scalar and nothing else — so an app storing either had
|
|
20
|
+
* to leave the query language for hand-written SQL, which is the one read path in this framework
|
|
21
|
+
* with no tenancy guard on it. Their meaning is Postgres', written once in `containment.ts` and
|
|
22
|
+
* read by both drivers.
|
|
23
|
+
*
|
|
24
|
+
* There is deliberately no jsonpath EXPRESSION operator beside them: `contains` already matches
|
|
25
|
+
* nested structure (`data @> '{"a":{"b":1}}'`), and a path language inside the query language
|
|
26
|
+
* would be a second way to ask one question.
|
|
27
|
+
*/
|
|
16
28
|
export type Operator =
|
|
17
29
|
| 'eq'
|
|
18
30
|
| 'neq'
|
|
@@ -23,7 +35,11 @@ export type Operator =
|
|
|
23
35
|
| 'lte'
|
|
24
36
|
| 'like'
|
|
25
37
|
| 'is-null'
|
|
26
|
-
| 'is-not-null'
|
|
38
|
+
| 'is-not-null'
|
|
39
|
+
| 'contains'
|
|
40
|
+
| 'contained-by'
|
|
41
|
+
| 'overlaps'
|
|
42
|
+
| 'has-key';
|
|
27
43
|
|
|
28
44
|
export interface Predicate {
|
|
29
45
|
readonly column: string;
|
|
@@ -195,7 +211,7 @@ const verifyScope = (
|
|
|
195
211
|
* every repository operation through `readPlan`, so both drivers and every read, write and count
|
|
196
212
|
* pass through this one derivation.
|
|
197
213
|
*
|
|
198
|
-
* Runtime only. There is no build-time tenancy step in `x verify` — its
|
|
214
|
+
* Runtime only. There is no build-time tenancy step in `x verify` — its 20 steps check none — and
|
|
199
215
|
* there cannot usefully be one: the tenant is a request-time value, so a compiler could only prove
|
|
200
216
|
* that some argument was passed, which is exactly the thing that was never a guarantee. That is
|
|
201
217
|
* why this is the seam every plan is built through rather than a lint.
|
package/src/types.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// A column carries its TypeScript type in `$parse`, which is what lets the row type be derived
|
|
7
7
|
// from the column set instead of being written a second time as a hand-maintained schema.
|
|
8
8
|
|
|
9
|
+
import type { IndexMethod } from '@ultimat3/db';
|
|
10
|
+
|
|
9
11
|
/**
|
|
10
12
|
* Postgres types the builders emit. `money` expands to `bigint` + `char(3)` (+ a nullable
|
|
11
13
|
* `integer` scale); `array` expands to its element's type with `[]` after it.
|
|
@@ -268,4 +270,12 @@ export interface IndexDef {
|
|
|
268
270
|
readonly order?: 'asc' | 'desc';
|
|
269
271
|
/** Partial index predicate — a soft-deleted row is excluded with this. */
|
|
270
272
|
readonly where?: string;
|
|
273
|
+
/**
|
|
274
|
+
* The access method. Absent is `btree`, which is Postgres' own default and what every index
|
|
275
|
+
* declared before this field existed is — so an entity that names none emits the statement it
|
|
276
|
+
* always emitted, byte for byte, and no app's sidecar regenerates. `@ultimat3/db` owns the
|
|
277
|
+
* closed set (`INDEX_METHODS`); redeclaring the union here would be the second declaration of
|
|
278
|
+
* one fact that this release exists to stop.
|
|
279
|
+
*/
|
|
280
|
+
readonly using?: IndexMethod;
|
|
271
281
|
}
|