@ultimat3/entity 1.2.0 → 3.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 +694 -0
- package/README.md +467 -16
- package/package.json +6 -4
- package/src/batch-read.ts +134 -0
- package/src/batch.ts +125 -0
- package/src/bulk-write.ts +285 -0
- package/src/coalesce.ts +189 -0
- package/src/column.ts +91 -2
- package/src/columns-data.ts +205 -0
- package/src/columns.ts +228 -38
- package/src/count-by.ts +148 -0
- package/src/cross-tenant.ts +76 -0
- package/src/cursor.ts +17 -3
- package/src/database.ts +35 -2
- package/src/describe.ts +121 -39
- package/src/entity.ts +65 -20
- package/src/errors.ts +346 -5
- package/src/expr.ts +65 -15
- package/src/index.ts +72 -7
- package/src/invariants.ts +56 -14
- package/src/jit-preload.ts +216 -0
- package/src/n-plus-one.ts +122 -0
- package/src/pg-driver.ts +282 -35
- package/src/pg-row.ts +87 -16
- package/src/pg-sql.ts +156 -13
- package/src/plan.ts +130 -20
- package/src/preload.ts +184 -0
- package/src/query.ts +231 -27
- package/src/registry.ts +63 -5
- package/src/relations.ts +212 -0
- package/src/repo.ts +226 -12
- package/src/seed.ts +288 -19
- package/src/tenancy.ts +194 -12
- package/src/type-pins.ts +311 -0
- package/src/types.ts +114 -12
- package/src/view.ts +8 -2
package/src/query.ts
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
// The chainable read. Every chain terminates in a cursor page — `page()` returns rows plus the
|
|
2
|
-
// cursor for the next one,
|
|
3
|
-
//
|
|
4
|
-
// page, so a client silently
|
|
2
|
+
// cursor for the next one, `all()`/`one()` are that page's rows, and `inBatches()` is that page
|
|
3
|
+
// repeated until the cursor runs out. There is no `offset()` and there will not be one: under
|
|
4
|
+
// concurrent writes an insert before the offset shifts every later page, so a client silently
|
|
5
|
+
// skips and repeats rows.
|
|
5
6
|
|
|
7
|
+
import { systemClock } from '@ultimat3/core';
|
|
8
|
+
import type { BatchIterator } from './batch';
|
|
9
|
+
import { assertBatchable, batchIterator } from './batch';
|
|
6
10
|
import type { EntityCore } from './entity';
|
|
7
|
-
import
|
|
11
|
+
import { assertPageSize, DEFAULT_PAGE_SIZE, namedColumns } from './plan';
|
|
12
|
+
import type { RelatedTables } from './preload';
|
|
13
|
+
import { preloaded } from './preload';
|
|
14
|
+
import type { Relation } from './relations';
|
|
15
|
+
import { relationNamed } from './relations';
|
|
16
|
+
import type { Page, Repo, RepoOptions, UpsertArgs } from './repo';
|
|
8
17
|
import type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
|
|
9
|
-
import type { ColumnMap, Insertable } from './types';
|
|
18
|
+
import type { ColumnMap, IdOf, Insertable } from './types';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What a preloaded relation adds to a row. `unknown` because the name is a string resolved at
|
|
22
|
+
* runtime against the relation map: the row on the other side is parsed by its own entity, never
|
|
23
|
+
* asserted into shape here.
|
|
24
|
+
*/
|
|
25
|
+
export type Preloaded<Name extends string> = { readonly [K in Name]: unknown };
|
|
10
26
|
|
|
11
27
|
export interface ReadBuilder<Row> {
|
|
12
28
|
/** Equality on the columns given. `where({ orgId })` is what satisfies the tenancy guard. */
|
|
@@ -19,30 +35,117 @@ export interface ReadBuilder<Row> {
|
|
|
19
35
|
select<K extends keyof Row & string>(
|
|
20
36
|
fields: { readonly [P in K]: true },
|
|
21
37
|
): ReadBuilder<Pick<Row, K>>;
|
|
38
|
+
/**
|
|
39
|
+
* One relation, read for the whole page in one extra `where <key> in (…)` and attached to every
|
|
40
|
+
* row under its own name — the eager form of the batching a point lookup does for itself, and
|
|
41
|
+
* the line an N+1 warning names. The relation is the `references()` already declared, so there
|
|
42
|
+
* is nothing to declare here; a name no foreign key produces is `X_PRELOAD_UNKNOWN_RELATION`,
|
|
43
|
+
* listing the ones that exist.
|
|
44
|
+
*
|
|
45
|
+
* A `belongsTo` attaches the row or `null`, a `hasMany` an array — always present, so "no
|
|
46
|
+
* author" never reads like "nobody preloaded the author". Attached after the projection: a
|
|
47
|
+
* `select()` narrows the columns, never the relations.
|
|
48
|
+
*/
|
|
49
|
+
preload<Name extends string>(relation: Name): ReadBuilder<Row & Preloaded<Name>>;
|
|
50
|
+
/**
|
|
51
|
+
* Every row the chain matches, `size` at a time and one statement per batch — the terminal a
|
|
52
|
+
* `for await` consumes instead of holding a whole table in memory. A batch is the page `page()`
|
|
53
|
+
* would have returned at that position, so filters, tenancy, soft delete, the projection and
|
|
54
|
+
* every `preload()` mean here what they mean there, and an empty batch is never yielded.
|
|
55
|
+
*
|
|
56
|
+
* Keyset, never OFFSET: each batch resumes from the cursor the previous one ended on, so a row
|
|
57
|
+
* written mid-iteration cannot make the loop skip or repeat one. `after(cursor)` is where it
|
|
58
|
+
* starts and `.cursor` is where it stopped — persist that and a job resumes the iteration.
|
|
59
|
+
*
|
|
60
|
+
* The loop closes it: `break`, `return` and a throw all stop the next statement, and
|
|
61
|
+
* `await using` does the same for a handle kept in a variable. A chain that cannot carry a
|
|
62
|
+
* cursor — a nullable sort column — and a chain that also called `limit()` are refused here,
|
|
63
|
+
* not one batch later.
|
|
64
|
+
*/
|
|
65
|
+
inBatches(size: number): BatchIterator<Row>;
|
|
22
66
|
/** The terminal: one bounded page and the cursor that continues it. */
|
|
23
67
|
page(): Promise<Page<Row>>;
|
|
24
68
|
all(): Promise<readonly Row[]>;
|
|
25
69
|
one(): Promise<Row | null>;
|
|
26
70
|
count(): Promise<number>;
|
|
71
|
+
/**
|
|
72
|
+
* The grouped count: one statement, one entry per distinct value of `column`, keyed by that
|
|
73
|
+
* value — the aggregate a `count()` per row is the N+1 of. `recount every post's likes` is one
|
|
74
|
+
* `likes.andWhere('postId', 'in', ids).countBy('postId')`, not one statement per post.
|
|
75
|
+
*
|
|
76
|
+
* Counts the whole predicate, exactly as `count()` does: the chain's filters, its tenancy and
|
|
77
|
+
* its soft-delete visibility, never its page. A value nothing matched is absent rather than `0`,
|
|
78
|
+
* which is what `group by` returns and what lets a caller tell "none" from "never asked".
|
|
79
|
+
* Entries come back biggest group first, ties by the value, `null` — the group every row without
|
|
80
|
+
* one shares — last.
|
|
81
|
+
*
|
|
82
|
+
* Refused rather than answered: a column whose values a map cannot be keyed by (a timestamp, a
|
|
83
|
+
* jsonb, money), and a chain matching more distinct values than one statement should answer with.
|
|
84
|
+
*/
|
|
85
|
+
countBy<K extends keyof Row & string>(column: K): Promise<ReadonlyMap<Row[K], number>>;
|
|
27
86
|
/** The plan this chain describes. Safe to log — `describePlan()` elides values. */
|
|
28
87
|
plan(): QueryPlan;
|
|
29
88
|
}
|
|
30
89
|
|
|
31
90
|
export interface Table<Row, C extends ColumnMap = ColumnMap> extends ReadBuilder<Row> {
|
|
32
91
|
insert(values: Insertable<C>, options?: RepoOptions): Promise<Row>;
|
|
33
|
-
|
|
34
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Many rows, one statement — the bulk write a per-row `insert` loop is the N+1 of, and the line
|
|
94
|
+
* an N+1 warning on a write loop names. Every row is parsed and asserted exactly as `insert`
|
|
95
|
+
* parses one, so declared defaults are filled here and not by the caller. Resolves with the rows
|
|
96
|
+
* as stored, in order; `insertAll([])` writes nothing. A collision is an error — `upsertAll` is
|
|
97
|
+
* the call that tolerates one.
|
|
98
|
+
*/
|
|
99
|
+
insertAll(rows: readonly Insertable<C>[], options?: RepoOptions): Promise<readonly Row[]>;
|
|
100
|
+
/**
|
|
101
|
+
* `insertAll` that resolves a collision instead of failing on it: `on conflict (…) do update`,
|
|
102
|
+
* or `do nothing` with `onMatch: 'nothing'`. Resolves with the rows this call actually wrote, so
|
|
103
|
+
* a row left alone is absent from the result — which is how a caller counts what it inserted.
|
|
104
|
+
* The conflict target and the primary key are never overwritten: they are how the stored row was
|
|
105
|
+
* found and where it lives.
|
|
106
|
+
*/
|
|
107
|
+
upsertAll(rows: readonly Insertable<C>[], args: UpsertArgs<Row>): Promise<readonly Row[]>;
|
|
108
|
+
/** `IdOf<Row>`: an entity that declared `uuid<PostId>()` is addressed by a `PostId` only. */
|
|
109
|
+
update(id: IdOf<Row>, patch: Partial<Row>, options?: RepoOptions): Promise<Row>;
|
|
110
|
+
delete(id: IdOf<Row>, options?: RepoOptions): Promise<void>;
|
|
111
|
+
/**
|
|
112
|
+
* Delete by equality filter; resolves with the number of rows removed. The only way to remove a
|
|
113
|
+
* row from an entity whose primary key is composite — `likes`, `blocks`, a join table — where
|
|
114
|
+
* one id cannot name it. `deleteWhere({})` is `X_WRITE_UNFILTERED`, never every row.
|
|
115
|
+
*/
|
|
116
|
+
deleteWhere(filter: Partial<Row>, options?: RepoOptions): Promise<number>;
|
|
117
|
+
/**
|
|
118
|
+
* Update by equality filter; resolves with the number of rows written. The `update(id, patch)`
|
|
119
|
+
* a composite primary key cannot express — `participants.updateWhere({ conversationId, userId },
|
|
120
|
+
* { lastReadAt })` is the reference case. Empty filter: `X_WRITE_UNFILTERED`. Empty patch:
|
|
121
|
+
* `X_PATCH_EMPTY`. `onUpdateNow()` columns are stamped exactly as `update(id, patch)` stamps them.
|
|
122
|
+
*/
|
|
123
|
+
updateWhere(filter: Partial<Row>, patch: Partial<Row>, options?: RepoOptions): Promise<number>;
|
|
35
124
|
}
|
|
36
125
|
|
|
37
126
|
interface State {
|
|
38
127
|
readonly where: readonly Predicate[];
|
|
39
128
|
readonly orderBy: readonly SortKey[];
|
|
40
|
-
|
|
129
|
+
/**
|
|
130
|
+
* `undefined` until `limit()` is called, and not the default spelled a second time: the driver
|
|
131
|
+
* already defaults an unnamed page to `DEFAULT_PAGE_SIZE`, and only "the caller named a page
|
|
132
|
+
* size" tells `inBatches()` it was handed one number for two jobs.
|
|
133
|
+
*/
|
|
134
|
+
readonly limit: number | undefined;
|
|
41
135
|
readonly cursor: string | null;
|
|
42
136
|
readonly select: readonly string[] | undefined;
|
|
137
|
+
/** Resolved when `preload()` was called, so an unknown name fails at the chain and not a page later. */
|
|
138
|
+
readonly preload: readonly Relation[];
|
|
43
139
|
}
|
|
44
140
|
|
|
45
|
-
const EMPTY: State = {
|
|
141
|
+
const EMPTY: State = {
|
|
142
|
+
where: [],
|
|
143
|
+
orderBy: [],
|
|
144
|
+
limit: undefined,
|
|
145
|
+
cursor: null,
|
|
146
|
+
select: undefined,
|
|
147
|
+
preload: [],
|
|
148
|
+
};
|
|
46
149
|
|
|
47
150
|
const asRecord = (value: unknown): Readonly<Record<string, unknown>> =>
|
|
48
151
|
typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
|
|
@@ -52,18 +155,39 @@ const builder = <Source, Row>(
|
|
|
52
155
|
repo: Repo<Source>,
|
|
53
156
|
state: State,
|
|
54
157
|
pick: (row: Source) => Row,
|
|
158
|
+
related?: RelatedTables,
|
|
55
159
|
): ReadBuilder<Row> => {
|
|
56
160
|
const next = (patch: Partial<State>): ReadBuilder<Row> =>
|
|
57
|
-
builder(entity, repo, { ...state, ...patch }, pick);
|
|
161
|
+
builder(entity, repo, { ...state, ...patch }, pick, related);
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A projection that drops a key the page is about to be preloaded on would send the statement
|
|
165
|
+
* looking for a column it did not select. The framework asks for what it needs; `pick` still
|
|
166
|
+
* hands the caller only the columns they named.
|
|
167
|
+
*/
|
|
168
|
+
const selected: readonly string[] | undefined =
|
|
169
|
+
state.select === undefined
|
|
170
|
+
? undefined
|
|
171
|
+
: [...new Set([...state.select, ...state.preload.map((relation) => relation.localKey)])];
|
|
58
172
|
|
|
59
173
|
const args = () => ({
|
|
60
174
|
where: state.where,
|
|
61
175
|
orderBy: state.orderBy,
|
|
62
|
-
|
|
176
|
+
// Sent only when the caller named one: an unnamed page is the driver's default, and passing
|
|
177
|
+
// it from here would be the same number written in two files.
|
|
178
|
+
...(state.limit === undefined ? {} : { limit: state.limit }),
|
|
63
179
|
cursor: state.cursor,
|
|
64
|
-
...(
|
|
180
|
+
...(selected === undefined ? {} : { select: selected }),
|
|
65
181
|
});
|
|
66
182
|
|
|
183
|
+
/** The page the caller gets: projected, then every named relation attached to it by position. */
|
|
184
|
+
const attach = (rows: readonly Source[]): Promise<readonly Row[]> =>
|
|
185
|
+
preloaded(
|
|
186
|
+
{ entity, related, relations: state.preload, where: state.where },
|
|
187
|
+
rows,
|
|
188
|
+
rows.map(pick),
|
|
189
|
+
);
|
|
190
|
+
|
|
67
191
|
return {
|
|
68
192
|
where: (filter) =>
|
|
69
193
|
next({
|
|
@@ -80,52 +204,114 @@ const builder = <Source, Row>(
|
|
|
80
204
|
orderBy: (column, direction = 'asc') =>
|
|
81
205
|
next({ orderBy: [...state.orderBy, { column, direction }] }),
|
|
82
206
|
|
|
83
|
-
|
|
207
|
+
// Judged on the chain, like `inBatches(size)` and for the same reason: the number is the
|
|
208
|
+
// author's own text, and a page size that arrived as action input is exactly the one nobody
|
|
209
|
+
// sized. `planFor` applies the identical guard, so a caller reaching the repository directly
|
|
210
|
+
// cannot go round it.
|
|
211
|
+
limit: (rows) => {
|
|
212
|
+
assertPageSize(entity.$name, rows);
|
|
213
|
+
return next({ limit: rows });
|
|
214
|
+
},
|
|
84
215
|
|
|
85
216
|
after: (cursor) => next({ cursor }),
|
|
86
217
|
|
|
87
218
|
select<K extends keyof Row & string>(fields: { readonly [P in K]: true }) {
|
|
88
219
|
// The predicate is what carries the literal key type through `Object.keys`.
|
|
89
220
|
const keys = Object.keys(fields).filter((key): key is K => Object.hasOwn(fields, key));
|
|
90
|
-
return builder<Source, Pick<Row, K>>(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
221
|
+
return builder<Source, Pick<Row, K>>(
|
|
222
|
+
entity,
|
|
223
|
+
repo,
|
|
224
|
+
{ ...state, select: keys },
|
|
225
|
+
(row) => {
|
|
226
|
+
const source = pick(row);
|
|
227
|
+
const picked = {} as Pick<Row, K>;
|
|
228
|
+
for (const key of keys) picked[key] = source[key];
|
|
229
|
+
return picked;
|
|
230
|
+
},
|
|
231
|
+
related,
|
|
232
|
+
);
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
preload<Name extends string>(relation: Name) {
|
|
236
|
+
// Resolved here, so a name no foreign key produces fails on the chain rather than one page
|
|
237
|
+
// later — and naming one relation twice is one statement, not two identical ones.
|
|
238
|
+
const resolved = relationNamed(entity.$name, relation);
|
|
239
|
+
const already = state.preload.some((held) => held.name === resolved.name);
|
|
240
|
+
return builder<Source, Row & Preloaded<Name>>(
|
|
241
|
+
entity,
|
|
242
|
+
repo,
|
|
243
|
+
{ ...state, preload: already ? state.preload : [...state.preload, resolved] },
|
|
244
|
+
// The relation is attached after the projection, so `pick` is unchanged and the row type
|
|
245
|
+
// is the only thing that grows — one cast, where a runtime name becomes a static one.
|
|
246
|
+
pick as (row: Source) => Row & Preloaded<Name>,
|
|
247
|
+
related,
|
|
248
|
+
);
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
inBatches(size) {
|
|
252
|
+
assertBatchable(entity, size, state);
|
|
253
|
+
return batchIterator<Row>({
|
|
254
|
+
from: state.cursor,
|
|
255
|
+
// The chain's own arguments with the batch as the page size and the iteration's position
|
|
256
|
+
// in place of the chain's: one statement per batch, and the same one `page()` sends.
|
|
257
|
+
page: async (cursor) => {
|
|
258
|
+
const result = await repo.findMany({ ...args(), limit: size, cursor });
|
|
259
|
+
return { rows: await attach(result.rows), nextCursor: result.nextCursor };
|
|
260
|
+
},
|
|
95
261
|
});
|
|
96
262
|
},
|
|
97
263
|
|
|
98
264
|
page: async () => {
|
|
99
265
|
const result = await repo.findMany(args());
|
|
100
|
-
return { rows: result.rows
|
|
266
|
+
return { rows: await attach(result.rows), nextCursor: result.nextCursor };
|
|
101
267
|
},
|
|
102
268
|
|
|
103
|
-
all: async () => (await repo.findMany(args())).rows
|
|
269
|
+
all: async () => attach((await repo.findMany(args())).rows),
|
|
104
270
|
|
|
105
271
|
one: async () => {
|
|
106
272
|
const { rows } = await repo.findMany({ ...args(), limit: 1 });
|
|
107
273
|
const row = rows[0];
|
|
108
|
-
return row === undefined ? null :
|
|
274
|
+
return row === undefined ? null : ((await attach([row]))[0] ?? null);
|
|
109
275
|
},
|
|
110
276
|
|
|
111
277
|
count: () => repo.count(args()),
|
|
112
278
|
|
|
279
|
+
async countBy<K extends keyof Row & string>(column: K) {
|
|
280
|
+
// The one cast on this terminal, and it is the same seam `select()` has: the driver contract
|
|
281
|
+
// is row-agnostic because a column name is a runtime string there, while the chain knows
|
|
282
|
+
// which property it just named and therefore what the map is keyed by.
|
|
283
|
+
return (await repo.countBy(column, args())) as ReadonlyMap<Row[K], number>;
|
|
284
|
+
},
|
|
285
|
+
|
|
113
286
|
plan: (): QueryPlan => ({
|
|
114
287
|
entity: entity.$name,
|
|
115
288
|
where: state.where,
|
|
116
289
|
orderBy: state.orderBy,
|
|
117
|
-
|
|
290
|
+
// The page that will actually run, so an unnamed one still reads as the bound it has.
|
|
291
|
+
limit: state.limit ?? DEFAULT_PAGE_SIZE,
|
|
118
292
|
...(state.cursor === null ? {} : { cursor: state.cursor }),
|
|
119
|
-
|
|
293
|
+
// The projection actually sent, preload keys included: a plan that is safe to log is only
|
|
294
|
+
// useful if it is the plan that ran.
|
|
295
|
+
...(selected === undefined ? {} : { select: selected }),
|
|
120
296
|
}),
|
|
121
297
|
};
|
|
122
298
|
};
|
|
123
299
|
|
|
124
|
-
/**
|
|
125
|
-
|
|
300
|
+
/**
|
|
301
|
+
* Columns declared `onUpdateNow()` are written by the framework, never by the caller. One helper,
|
|
302
|
+
* so `update(id, patch)` and `updateWhere(filter, patch)` stamp the same columns at the same
|
|
303
|
+
* moment — a second copy is how one of them ends up with a stale `updatedAt`.
|
|
304
|
+
*
|
|
305
|
+
* A patch that names nothing is returned untouched. Stamping `updatedAt` onto "the caller named no
|
|
306
|
+
* columns" would turn that mistake into a real write on any entity that happens to declare the
|
|
307
|
+
* column, and `X_PATCH_EMPTY` downstream would never see it — so whether the refusal fires would
|
|
308
|
+
* depend on the schema rather than on the call.
|
|
309
|
+
*/
|
|
310
|
+
const touch = <Row, Patch>(entity: EntityCore<Row>, patch: Patch): Patch => {
|
|
311
|
+
if (namedColumns(patch).length === 0) return patch;
|
|
126
312
|
const stamped: Record<string, unknown> = {};
|
|
127
313
|
for (const [property, column] of Object.entries(entity.$columns)) {
|
|
128
|
-
if (column.$meta.onUpdate !== undefined) stamped[property] =
|
|
314
|
+
if (column.$meta.onUpdate !== undefined) stamped[property] = systemClock.now();
|
|
129
315
|
}
|
|
130
316
|
return Object.assign({}, patch, stamped);
|
|
131
317
|
};
|
|
@@ -136,9 +322,27 @@ const touch = <Row>(entity: EntityCore<Row>, patch: Partial<Row>): Partial<Row>
|
|
|
136
322
|
export const tableFor = <Row, C extends ColumnMap>(
|
|
137
323
|
entity: EntityCore<Row, C>,
|
|
138
324
|
repo: Repo<Row>,
|
|
325
|
+
/** How this table reaches another — `database()` passes it; a table built by hand has none. */
|
|
326
|
+
related?: RelatedTables,
|
|
139
327
|
): Table<Row, C> => ({
|
|
140
|
-
...builder<Row, Row>(entity, repo, EMPTY, (row) => row),
|
|
328
|
+
...builder<Row, Row>(entity, repo, EMPTY, (row) => row, related),
|
|
141
329
|
insert: async (values, options) => repo.insert(entity.$parse(values), options),
|
|
330
|
+
insertAll: async (rows, options) =>
|
|
331
|
+
repo.insertAll(
|
|
332
|
+
rows.map((row) => entity.$parse(row)),
|
|
333
|
+
options,
|
|
334
|
+
),
|
|
335
|
+
// `touch` and not `$parse` alone: an upsert that lands on a stored row IS an update, so an
|
|
336
|
+
// `onUpdateNow()` column has to move exactly as `update(id, patch)` moves it — and stamping it
|
|
337
|
+
// in a second place is how one of the two ends up writing a stale `updatedAt`.
|
|
338
|
+
upsertAll: async (rows, args) =>
|
|
339
|
+
repo.upsertAll(
|
|
340
|
+
rows.map((row) => touch(entity, entity.$parse(row))),
|
|
341
|
+
args,
|
|
342
|
+
),
|
|
142
343
|
update: async (id, patch, options) => repo.update(id, touch(entity, patch), options),
|
|
143
344
|
delete: async (id, options) => repo.delete(id, options),
|
|
345
|
+
deleteWhere: async (filter, options) => repo.deleteWhere(filter, options),
|
|
346
|
+
updateWhere: async (filter, patch, options) =>
|
|
347
|
+
repo.updateWhere(filter, touch(entity, patch), options),
|
|
144
348
|
});
|
package/src/registry.ts
CHANGED
|
@@ -18,6 +18,25 @@ export interface ColumnDescription {
|
|
|
18
18
|
readonly references: string | null;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* One `references()`, resolved: both ends, both names. `ColumnDescription.references` renders
|
|
23
|
+
* this as `"<table>.<column>"` for the migration generator (tier 1, which cannot import this
|
|
24
|
+
* package) and the manifest — physical names, which is their whole vocabulary. A traversal needs
|
|
25
|
+
* the row *properties* too, so it reads the record; the string is written, never parsed back.
|
|
26
|
+
*/
|
|
27
|
+
export interface ReferenceDescription {
|
|
28
|
+
/** Property key on the declaring row — what a JS caller reads and a preload collects. */
|
|
29
|
+
readonly property: string;
|
|
30
|
+
/** Physical column on the declaring table — what SQL names. */
|
|
31
|
+
readonly column: string;
|
|
32
|
+
/** The key accepts null, so a target that resolves to nothing is data, not a broken key. */
|
|
33
|
+
readonly nullable: boolean;
|
|
34
|
+
/** The entity referenced. Not necessarily registered — an entity may point outside a set. */
|
|
35
|
+
readonly targetEntity: string;
|
|
36
|
+
readonly targetProperty: string;
|
|
37
|
+
readonly targetColumn: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
21
40
|
export interface InvariantDescription {
|
|
22
41
|
readonly name: string;
|
|
23
42
|
readonly kind: InvariantKind;
|
|
@@ -27,13 +46,31 @@ export interface InvariantDescription {
|
|
|
27
46
|
readonly where: string | null;
|
|
28
47
|
}
|
|
29
48
|
|
|
49
|
+
/**
|
|
50
|
+
* An index as the migration generator has to emit it. The columns are carried, never recovered
|
|
51
|
+
* from `name`: `<table>_<a>_<b>_idx` is one string for two columns, and the convention that built
|
|
52
|
+
* it cannot be run backwards — `posts_org_id_created_at_idx` reads as the single column
|
|
53
|
+
* `"org_id_created_at"`, which is a `42703` at apply time. `where` and `order` are here for the
|
|
54
|
+
* same reason: a partial index emitted as a total one refuses rows the entity allows.
|
|
55
|
+
*/
|
|
56
|
+
export interface IndexDescription {
|
|
57
|
+
readonly name: string;
|
|
58
|
+
/** Physical columns, in index order. Always at least one. */
|
|
59
|
+
readonly columns: readonly string[];
|
|
60
|
+
readonly unique: boolean;
|
|
61
|
+
/** Partial index predicate as SQL, `null` when the index covers every row. */
|
|
62
|
+
readonly where: string | null;
|
|
63
|
+
/** `null` is Postgres' own default (`asc`), never written out. */
|
|
64
|
+
readonly order: 'asc' | 'desc' | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
30
67
|
export interface EntityDescription {
|
|
31
68
|
readonly name: string;
|
|
32
69
|
readonly table: string;
|
|
33
70
|
readonly primaryKey: readonly string[];
|
|
34
71
|
readonly columns: readonly ColumnDescription[];
|
|
35
72
|
readonly invariants: readonly InvariantDescription[];
|
|
36
|
-
readonly indexes: readonly
|
|
73
|
+
readonly indexes: readonly IndexDescription[];
|
|
37
74
|
readonly tags: readonly string[];
|
|
38
75
|
readonly cacheTag: string;
|
|
39
76
|
readonly softDelete: boolean;
|
|
@@ -44,9 +81,16 @@ export interface RegistryEntry {
|
|
|
44
81
|
readonly name: string;
|
|
45
82
|
readonly tableName: string;
|
|
46
83
|
describe(): EntityDescription;
|
|
84
|
+
/**
|
|
85
|
+
* The foreign keys this entity declares, resolved. This is how a relation reaches query time:
|
|
86
|
+
* a method and not a field because a `references()` thunk may point at an entity declared
|
|
87
|
+
* later in an import cycle, so resolving at registration would read a half-evaluated module.
|
|
88
|
+
*/
|
|
89
|
+
references(): readonly ReferenceDescription[];
|
|
47
90
|
}
|
|
48
91
|
|
|
49
92
|
const entities = new Map<string, RegistryEntry>();
|
|
93
|
+
let generation = 0;
|
|
50
94
|
|
|
51
95
|
export const registerEntity = <E extends RegistryEntry>(entry: E): E => {
|
|
52
96
|
const existing = entities.get(entry.name);
|
|
@@ -54,6 +98,7 @@ export const registerEntity = <E extends RegistryEntry>(entry: E): E => {
|
|
|
54
98
|
throw entityDuplicate(entry.name, existing.tableName);
|
|
55
99
|
}
|
|
56
100
|
entities.set(entry.name, entry);
|
|
101
|
+
generation += 1;
|
|
57
102
|
return entry;
|
|
58
103
|
};
|
|
59
104
|
|
|
@@ -61,13 +106,26 @@ export const getEntity = (name: string): RegistryEntry | undefined => entities.g
|
|
|
61
106
|
|
|
62
107
|
export const entityNames = (): readonly string[] => [...entities.keys()].sort();
|
|
63
108
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Bumped by every mutation. A projection of the WHOLE registry — the relation map — caches
|
|
111
|
+
* against it, so a module imported late registers one more entity and invalidates that cache
|
|
112
|
+
* instead of being missed by it.
|
|
113
|
+
*/
|
|
114
|
+
export const registryGeneration = (): number => generation;
|
|
115
|
+
|
|
116
|
+
/** Deterministic order: every projection of the registry is a build input and must diff cleanly. */
|
|
117
|
+
export const registeredEntities = (): readonly RegistryEntry[] =>
|
|
66
118
|
entityNames().map((name) => {
|
|
67
119
|
const entry = entities.get(name);
|
|
68
120
|
if (entry === undefined) throw entityDuplicate(name, 'unknown');
|
|
69
|
-
return entry
|
|
121
|
+
return entry;
|
|
70
122
|
});
|
|
71
123
|
|
|
124
|
+
export const describeEntities = (): readonly EntityDescription[] =>
|
|
125
|
+
registeredEntities().map((entry) => entry.describe());
|
|
126
|
+
|
|
72
127
|
/** Test seam. Production code never unregisters an entity. */
|
|
73
|
-
export const clearRegistry = (): void =>
|
|
128
|
+
export const clearRegistry = (): void => {
|
|
129
|
+
entities.clear();
|
|
130
|
+
generation += 1;
|
|
131
|
+
};
|