@ultimat3/entity 1.1.0 → 2.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 +614 -0
- package/README.md +391 -11
- package/package.json +5 -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 +175 -0
- package/src/column.ts +24 -0
- package/src/columns.ts +183 -35
- 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 +82 -37
- package/src/entity.ts +41 -12
- package/src/errors.ts +346 -5
- package/src/expr.ts +65 -15
- package/src/index.ts +49 -4
- 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 +281 -33
- package/src/pg-row.ts +32 -7
- package/src/pg-sql.ts +117 -8
- 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/tenancy.ts +194 -12
- package/src/type-pins.ts +311 -0
- package/src/types.ts +64 -12
- package/src/view.ts +8 -2
package/src/columns.ts
CHANGED
|
@@ -3,14 +3,45 @@
|
|
|
3
3
|
// currency) are the bugs this file exists to make unreachable.
|
|
4
4
|
|
|
5
5
|
import { uuid as uuidV7 } from '@ultimat3/core';
|
|
6
|
+
import {
|
|
7
|
+
CURRENCY_CODE_PATTERN,
|
|
8
|
+
describeValue,
|
|
9
|
+
isCurrencyCode,
|
|
10
|
+
isMoneyScale,
|
|
11
|
+
MAX_MONEY_SCALE,
|
|
12
|
+
} from '@ultimat3/schema';
|
|
6
13
|
import { BARE, column, GENERATED_UUID, makeColumn, makeTimestamp } from './column';
|
|
7
14
|
import { invariantViolated } from './errors';
|
|
8
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
Column,
|
|
17
|
+
ColumnMap,
|
|
18
|
+
MoneyInput,
|
|
19
|
+
MoneyValue,
|
|
20
|
+
TimestampColumn,
|
|
21
|
+
UuidColumn,
|
|
22
|
+
} from './types';
|
|
9
23
|
|
|
10
24
|
const reject = (rule: string, detail: string): never => {
|
|
11
25
|
throw invariantViolated('column', rule, detail);
|
|
12
26
|
};
|
|
13
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The rejected value, rendered as its SHAPE and never its content — `@ultimat3/schema`'s
|
|
30
|
+
* `describeValue`, the same renderer every builtin validator fails through, so a column and a
|
|
31
|
+
* schema describe one bad value the same way.
|
|
32
|
+
*
|
|
33
|
+
* WHY it is not `String(value)`: a column rejection is not a private diagnostic. It becomes
|
|
34
|
+
* `X_INVARIANT_VIOLATED`'s `cause` and a `$view` issue, which `@ultimat3/http` returns to the
|
|
35
|
+
* caller AND writes into the log line — and core's logger redacts by KEY, so a value baked into a
|
|
36
|
+
* message has no key left to redact. `text()` on a password field wrote the mistyped password to
|
|
37
|
+
* the central log index in cleartext and into the user's own network tab; a `uuid()` holding an
|
|
38
|
+
* API key surrogate does the same. A column is the worse half of that pair, because the value can
|
|
39
|
+
* arrive from the DATABASE — so the leak is not bounded by what someone just typed.
|
|
40
|
+
*
|
|
41
|
+
* `got` stays `got` and the "expected …" half is untouched: only what follows it changes.
|
|
42
|
+
*/
|
|
43
|
+
const got = (value: unknown): string => `got ${describeValue(value)}`;
|
|
44
|
+
|
|
14
45
|
/** uuid v7: time-ordered, so a primary key index stays append-friendly. */
|
|
15
46
|
export const newId = (): string => uuidV7();
|
|
16
47
|
|
|
@@ -19,16 +50,28 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
19
50
|
const parseUuid = (value: unknown): string =>
|
|
20
51
|
typeof value === 'string' && UUID.test(value)
|
|
21
52
|
? value
|
|
22
|
-
: reject('format', `expected a uuid,
|
|
53
|
+
: reject('format', `expected a uuid, ${got(value)}`);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The one place a brand is applied. A brand is a compile-time tag with no runtime witness, so
|
|
57
|
+
* there is nothing here to check that `parseUuid` has not already checked — same shape as core's
|
|
58
|
+
* `parseId`, and the reason `uuid<PostId>()` needs no cast at any call site afterwards.
|
|
59
|
+
*/
|
|
60
|
+
const parseBrandedUuid = <T extends string>(value: unknown): T => parseUuid(value) as T;
|
|
23
61
|
|
|
24
|
-
|
|
25
|
-
|
|
62
|
+
/**
|
|
63
|
+
* `uuid()` for a plain id, `uuid<PostId>()` to declare the brand ONCE. The brand then rides the
|
|
64
|
+
* derivation — row, insert, `findById`, `update`, `delete` — so mixing two entities' ids is a
|
|
65
|
+
* compile error instead of a query that silently matches nothing.
|
|
66
|
+
*/
|
|
67
|
+
export const uuid = <T extends string = string>(): UuidColumn<T> => ({
|
|
68
|
+
...makeColumn<T, false>({ ...BARE, kind: 'uuid' }, parseBrandedUuid, false),
|
|
26
69
|
// Narrower than the generic chain: a uuid key is generated when omitted, so it is the one
|
|
27
70
|
// primary key an insert may leave out.
|
|
28
71
|
primaryKey: () =>
|
|
29
|
-
makeColumn<
|
|
72
|
+
makeColumn<T, true>(
|
|
30
73
|
{ ...BARE, kind: 'uuid', primaryKey: true, default: GENERATED_UUID },
|
|
31
|
-
|
|
74
|
+
parseBrandedUuid,
|
|
32
75
|
true,
|
|
33
76
|
),
|
|
34
77
|
});
|
|
@@ -42,7 +85,7 @@ export const text = (options: TextOptions = {}): Column<string> =>
|
|
|
42
85
|
column<string>(
|
|
43
86
|
'text',
|
|
44
87
|
(value) =>
|
|
45
|
-
typeof value === 'string' ? value : reject('type', `expected a string,
|
|
88
|
+
typeof value === 'string' ? value : reject('type', `expected a string, ${got(value)}`),
|
|
46
89
|
options.max === undefined
|
|
47
90
|
? {}
|
|
48
91
|
: { length: options.max, check: (name) => `char_length(${name}) <= ${options.max}` },
|
|
@@ -52,12 +95,12 @@ export const integer = (): Column<number> =>
|
|
|
52
95
|
column<number>('integer', (value) =>
|
|
53
96
|
typeof value === 'number' && Number.isSafeInteger(value)
|
|
54
97
|
? value
|
|
55
|
-
: reject('type', `expected a safe integer,
|
|
98
|
+
: reject('type', `expected a safe integer, ${got(value)}`),
|
|
56
99
|
);
|
|
57
100
|
|
|
58
101
|
export const boolean = (): Column<boolean> =>
|
|
59
102
|
column<boolean>('boolean', (value) =>
|
|
60
|
-
typeof value === 'boolean' ? value : reject('type', `expected a boolean,
|
|
103
|
+
typeof value === 'boolean' ? value : reject('type', `expected a boolean, ${got(value)}`),
|
|
61
104
|
);
|
|
62
105
|
|
|
63
106
|
const parseInstant = (value: unknown): Date => {
|
|
@@ -66,7 +109,7 @@ const parseInstant = (value: unknown): Date => {
|
|
|
66
109
|
const parsed = new Date(value);
|
|
67
110
|
if (!Number.isNaN(parsed.getTime())) return parsed;
|
|
68
111
|
}
|
|
69
|
-
return reject('format', `expected a UTC instant,
|
|
112
|
+
return reject('format', `expected a UTC instant, ${got(value)}`);
|
|
70
113
|
};
|
|
71
114
|
|
|
72
115
|
/** Always `timestamptz`. UTC storage is not a per-table decision. */
|
|
@@ -92,7 +135,7 @@ export const enumerated = <const V extends readonly string[]>(values: V): Column
|
|
|
92
135
|
(value) =>
|
|
93
136
|
typeof value === 'string' && allowed.has(value)
|
|
94
137
|
? value
|
|
95
|
-
: reject('enum', `expected one of ${values.join(' | ')},
|
|
138
|
+
: reject('enum', `expected one of ${values.join(' | ')}, ${got(value)}`),
|
|
96
139
|
{ values, check: oneOf(values) },
|
|
97
140
|
);
|
|
98
141
|
};
|
|
@@ -113,7 +156,7 @@ export const url = (): Column<string> =>
|
|
|
113
156
|
// fall through to the shared rejection so the error names the rule
|
|
114
157
|
}
|
|
115
158
|
}
|
|
116
|
-
return reject('format', `expected an absolute http(s) URL,
|
|
159
|
+
return reject('format', `expected an absolute http(s) URL, ${got(value)}`);
|
|
117
160
|
},
|
|
118
161
|
{ check: (name) => `${name} ~ '^https?://'` },
|
|
119
162
|
);
|
|
@@ -141,7 +184,7 @@ export const tz = <const Z extends readonly string[]>(zones: Z): Column<Z[number
|
|
|
141
184
|
(value) =>
|
|
142
185
|
typeof value === 'string' && allowed.has(value)
|
|
143
186
|
? value
|
|
144
|
-
: reject('iana-tz', `expected one of ${zones.join(' | ')},
|
|
187
|
+
: reject('iana-tz', `expected one of ${zones.join(' | ')}, ${got(value)}`),
|
|
145
188
|
{ values: zones, check: oneOf(zones) },
|
|
146
189
|
);
|
|
147
190
|
};
|
|
@@ -158,45 +201,150 @@ export const locale = <const L extends readonly string[]>(locales: L): Column<L[
|
|
|
158
201
|
(value) =>
|
|
159
202
|
typeof value === 'string' && allowed.has(value)
|
|
160
203
|
? value
|
|
161
|
-
: reject('bcp-47', `expected one of ${locales.join(' | ')},
|
|
204
|
+
: reject('bcp-47', `expected one of ${locales.join(' | ')}, ${got(value)}`),
|
|
162
205
|
{ values: locales, check: oneOf(locales) },
|
|
163
206
|
);
|
|
164
207
|
};
|
|
165
208
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
209
|
+
/**
|
|
210
|
+
* The column is `bigint` and the value type is a `number`, which is the one narrowing in this
|
|
211
|
+
* package that can lose information — so it is the one narrowing that refuses rather than rounds.
|
|
212
|
+
*
|
|
213
|
+
* `number` is not a compromise here: money is projected onto every wire this framework generates,
|
|
214
|
+
* and `JSON.stringify` throws on a bigint. What the wide column buys is the ability to *hold* a
|
|
215
|
+
* value written by something that is not this framework — a psql session, a backfill, another
|
|
216
|
+
* service — and the honest answer to reading one back is a coded refusal naming the row, not a
|
|
217
|
+
* `minor` that silently rounds and not a `bigint` that crashes the response three layers later.
|
|
218
|
+
* `@ultimat3/realtime` refuses the identical value for the identical reason (`pg-entity-row.ts`),
|
|
219
|
+
* so the two readers of one column agree.
|
|
220
|
+
*/
|
|
221
|
+
const parseMinor = (value: unknown): number => {
|
|
222
|
+
const minor =
|
|
223
|
+
typeof value === 'bigint' || (typeof value === 'string' && /^-?\d+$/.test(value))
|
|
224
|
+
? Number(value)
|
|
225
|
+
: value;
|
|
226
|
+
if (typeof minor !== 'number' || !Number.isFinite(minor)) {
|
|
227
|
+
return reject('money-minor-units', `expected integer minor units, ${got(value)}`);
|
|
228
|
+
}
|
|
229
|
+
if (!Number.isInteger(minor)) {
|
|
230
|
+
return reject(
|
|
231
|
+
'money-minor-units',
|
|
232
|
+
`got the float ${minor}; money is integer minor units — 12.34 EUR is 1234, not 12.34`,
|
|
233
|
+
);
|
|
176
234
|
}
|
|
177
|
-
if (
|
|
178
|
-
|
|
235
|
+
if (!Number.isSafeInteger(minor)) {
|
|
236
|
+
return reject(
|
|
237
|
+
'money-minor-units',
|
|
238
|
+
`${String(value)} is past ±2^53 and no JS number holds it exactly — money is minor units ` +
|
|
239
|
+
'inside that range; store the overflow in its own column or split the amount',
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return minor;
|
|
179
243
|
};
|
|
180
244
|
|
|
245
|
+
/**
|
|
246
|
+
* The bound is `@ultimat3/schema`'s, imported rather than restated — the same rule `parseScale`
|
|
247
|
+
* below follows for `isMoneyScale`. This column, `moneySchema`, the OpenAPI `pattern` and the
|
|
248
|
+
* CHECK at the bottom of this file are four projections of one declaration; each was individually
|
|
249
|
+
* correct and would have drifted silently, since only a psql session sees the disagreement.
|
|
250
|
+
*/
|
|
181
251
|
const parseCurrency = (value: unknown): string =>
|
|
182
|
-
|
|
252
|
+
isCurrencyCode(value)
|
|
183
253
|
? value
|
|
184
|
-
: reject('iso-4217', `expected a 3-letter ISO-4217 code,
|
|
254
|
+
: reject('iso-4217', `expected a 3-letter ISO-4217 code, ${got(value)}`);
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The decimal exponent `minor` counts in, when it is not the currency's own. `undefined` and `0`
|
|
258
|
+
* are DIFFERENT values — "the currency's natural minor unit" versus "whole units" — so the key is
|
|
259
|
+
* carried only when it was supplied, exactly as `@ultimat3/schema`'s `moneySchema` carries it.
|
|
260
|
+
* The legal range is `isMoneyScale`'s, imported rather than restated: one bound, one declaration.
|
|
261
|
+
*/
|
|
262
|
+
const parseScale = (value: unknown): number => {
|
|
263
|
+
const scale = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value;
|
|
264
|
+
return isMoneyScale(scale)
|
|
265
|
+
? scale
|
|
266
|
+
: reject(
|
|
267
|
+
'money-scale',
|
|
268
|
+
`expected a whole number of decimal places between 0 and ${MAX_MONEY_SCALE}, ${got(value)}`,
|
|
269
|
+
);
|
|
270
|
+
};
|
|
185
271
|
|
|
186
272
|
const parseMoney = (value: unknown): MoneyValue => {
|
|
187
273
|
if (typeof value !== 'object' || value === null) {
|
|
188
|
-
return reject('money', `expected { minor, currency },
|
|
274
|
+
return reject('money', `expected { minor, currency }, ${got(value)}`);
|
|
189
275
|
}
|
|
190
276
|
const input: Partial<MoneyInput> = value;
|
|
191
|
-
return {
|
|
277
|
+
return {
|
|
278
|
+
minor: parseMinor(input.minor),
|
|
279
|
+
currency: parseCurrency(input.currency),
|
|
280
|
+
...(input.scale === undefined || input.scale === null
|
|
281
|
+
? {}
|
|
282
|
+
: { scale: parseScale(input.scale) }),
|
|
283
|
+
};
|
|
192
284
|
};
|
|
193
285
|
|
|
194
286
|
/**
|
|
195
|
-
* One property,
|
|
196
|
-
* A single implied currency is a migration nobody wants to write
|
|
197
|
-
* rounding bug nobody wants to debug.
|
|
287
|
+
* One property, three physical columns: `<name>_minor bigint`, `<name>_currency char(3)` and
|
|
288
|
+
* `<name>_scale integer null`. A single implied currency is a migration nobody wants to write
|
|
289
|
+
* later, and a float is a rounding bug nobody wants to debug.
|
|
290
|
+
*
|
|
291
|
+
* The third column is not decoration: `scale` is what lets an amount name a sub-cent value, and
|
|
292
|
+
* the entity layer used to rebuild the row as `{ minor, currency }` — so
|
|
293
|
+
* `{ minor: 2, currency: 'USD', scale: 6 }` ($0.000002) was stored and read back as $0.02, a
|
|
294
|
+
* silent 10,000x reinterpretation of a value the type system, `t.money` and `@ultimat3/money` all
|
|
295
|
+
* carry. `null` in the column is "no explicit scale" and decodes to an ABSENT key, never to `0`.
|
|
198
296
|
*/
|
|
199
297
|
export const money = (): Column<MoneyValue> => column<MoneyValue>('money', parseMoney);
|
|
200
298
|
|
|
201
|
-
/**
|
|
202
|
-
|
|
299
|
+
/**
|
|
300
|
+
* Money is the one column whose write type is wider than its row type — `MoneyInput` takes a
|
|
301
|
+
* `bigint` so a minor unit read straight off a `bigint` column needs no conversion at the call
|
|
302
|
+
* site — so it is the one column where "the caller's value" and "the row's value" can differ.
|
|
303
|
+
* This is where they stop differing, and BOTH drivers call it: `bindValues` before a statement,
|
|
304
|
+
* `memoryRepo`'s `write` before it stores. A rule applied to one of them and not the other is
|
|
305
|
+
* exactly the drift the two-driver split exists to prevent — here it would mean an in-memory row
|
|
306
|
+
* holding a `bigint` that `JSON.stringify` refuses while the Postgres row holds a `number`.
|
|
307
|
+
*
|
|
308
|
+
* Every other kind is returned untouched: writes are asserted, not parsed, and money is the only
|
|
309
|
+
* kind that widens. A value already holding safe-integer minor units is left alone — that is the
|
|
310
|
+
* overwhelmingly common case and it costs one `typeof`-grade check and no allocation (axiom 6);
|
|
311
|
+
* everything else goes through `parseMinor`, so a `bigint` narrows and a float is refused with
|
|
312
|
+
* the same message it would get coming back from the database.
|
|
313
|
+
*/
|
|
314
|
+
export const narrowMoney = <Row>(columns: ColumnMap, row: Row): Row => {
|
|
315
|
+
let narrowed: Record<string, unknown> | undefined;
|
|
316
|
+
const record = row as Readonly<Record<string, unknown>>;
|
|
317
|
+
for (const [property, column] of Object.entries(columns)) {
|
|
318
|
+
if (column.$meta.kind !== 'money') continue;
|
|
319
|
+
const value = record[property] as Partial<MoneyInput> | null | undefined;
|
|
320
|
+
if (value === null || value === undefined || Number.isSafeInteger(value.minor)) continue;
|
|
321
|
+
// Spread rather than rebuild: `currency` is the column's to validate on read and Postgres's
|
|
322
|
+
// to CHECK on write, and narrowing a minor unit is not the place to start refusing one.
|
|
323
|
+
narrowed ??= { ...record };
|
|
324
|
+
narrowed[property] = { ...value, minor: parseMinor(value.minor) };
|
|
325
|
+
}
|
|
326
|
+
return (narrowed ?? row) as Row;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The CHECK that stops a psql session writing a currency the app would refuse — the app's own
|
|
331
|
+
* bound, projected into SQL rather than restated in it.
|
|
332
|
+
*
|
|
333
|
+
* SQL cannot call `isCurrencyCode`, so what crosses is `CURRENCY_CODE_PATTERN`, the pattern source
|
|
334
|
+
* that predicate is built from — the same move `scaleCheck` below already makes with
|
|
335
|
+
* `MAX_MONEY_SCALE`. It holds because the pattern is deliberately kept to the syntax ECMAScript
|
|
336
|
+
* and POSIX ERE spell identically (see its declaration); the one thing a TypeScript test cannot
|
|
337
|
+
* prove is that a real server reads it the same way, which is what
|
|
338
|
+
* `currency-check.live.test.ts` sends to Postgres. Quoting is not a concern and must not become
|
|
339
|
+
* one: this is a compile-time constant from tier 0, never a value.
|
|
340
|
+
*/
|
|
341
|
+
export const currencyCheck = (currencyColumn: string): string =>
|
|
342
|
+
`${currencyColumn} ~ '${CURRENCY_CODE_PATTERN}'`;
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The same for the scale column: `parseScale` refuses anything outside `0…MAX_MONEY_SCALE`, and a
|
|
346
|
+
* row written by a backfill or a psql session must not be able to hold a value the app would
|
|
347
|
+
* refuse to read back. `is null` is legal and is the ordinary case.
|
|
348
|
+
*/
|
|
349
|
+
export const scaleCheck = (scaleColumn: string): string =>
|
|
350
|
+
`${scaleColumn} is null or (${scaleColumn} >= 0 and ${scaleColumn} <= ${MAX_MONEY_SCALE})`;
|
package/src/count-by.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Single responsibility: what a grouped count is made of — which columns a count may be keyed by,
|
|
2
|
+
// how many groups one statement is allowed to answer with, and the order the map comes back in.
|
|
3
|
+
// Both drivers read those three rules from here, so a `countBy` against memory means exactly what
|
|
4
|
+
// a `countBy` against Postgres means; a rule added to one driver alone is the drift this file
|
|
5
|
+
// exists to prevent.
|
|
6
|
+
|
|
7
|
+
import type { EntityCore } from './entity';
|
|
8
|
+
import { EntityError } from './errors';
|
|
9
|
+
import type { AnyColumn, ColumnKind } from './types';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How many groups one call may answer with. A grouped count answers a page's worth of keys, or a
|
|
13
|
+
* column with a handful of values; past that it is a report, and a report is paged. The statement
|
|
14
|
+
* therefore asks for one group more than this and the extra one is *refused* rather than dropped —
|
|
15
|
+
* a map that silently lost its tail reads exactly like a complete one, and a caller recounting
|
|
16
|
+
* from it would write the wrong number to every row it missed.
|
|
17
|
+
*/
|
|
18
|
+
export const MAX_GROUPS = 1000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The kinds a group key can be. A `Map` compares keys by identity for anything that is not a
|
|
22
|
+
* primitive, so a `timestamptz` (a `Date`) or a `jsonb` (an object) would file every row under a
|
|
23
|
+
* key no caller can look up again — the result would be a map that only ever answers `undefined`.
|
|
24
|
+
* `money` is two physical columns, which is not one value to group by at all.
|
|
25
|
+
*/
|
|
26
|
+
const GROUPABLE: ReadonlySet<ColumnKind> = new Set<ColumnKind>([
|
|
27
|
+
'uuid',
|
|
28
|
+
'text',
|
|
29
|
+
'char',
|
|
30
|
+
'boolean',
|
|
31
|
+
'integer',
|
|
32
|
+
'bigint',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const groupableColumns = <Row>(entity: EntityCore<Row>): readonly string[] =>
|
|
36
|
+
Object.entries(entity.$columns)
|
|
37
|
+
.filter(([, column]) => GROUPABLE.has(column.$meta.kind))
|
|
38
|
+
.map(([property]) => property);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Not `invariantViolated`: its fix opens `x entity explain`, which describes invariants nobody
|
|
42
|
+
* wrote here. What repairs this is one edit to the call — a different column, named in the message
|
|
43
|
+
* because the entity is the only place the answer lives. Only when this entity offers no such
|
|
44
|
+
* column does the fix become a command, and then it is `x entities describe`, which prints the
|
|
45
|
+
* kinds: there is no call to suggest, since every column it declares would be refused the same way.
|
|
46
|
+
*/
|
|
47
|
+
const notGroupable = <Row>(
|
|
48
|
+
entity: EntityCore<Row>,
|
|
49
|
+
operation: string,
|
|
50
|
+
property: string,
|
|
51
|
+
reason: string,
|
|
52
|
+
): EntityError => {
|
|
53
|
+
const [first] = groupableColumns(entity);
|
|
54
|
+
return new EntityError({
|
|
55
|
+
code: 'X_INVARIANT_VIOLATED',
|
|
56
|
+
cause: `${entity.$name}.${operation}('${property}'): ${reason}`,
|
|
57
|
+
fix:
|
|
58
|
+
first === undefined
|
|
59
|
+
? `x entities describe ${entity.$name} --json # this entity declares no column a count can be keyed by`
|
|
60
|
+
: `${entity.$name}.${operation}('${first}') # group by one of: ${groupableColumns(entity).join(', ')}`,
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The bound, spelled as the call that stays under it. A grouped count of a foreign key is the
|
|
66
|
+
* point of this method, so the fix leads with the `in` predicate that bounds one — the shape a
|
|
67
|
+
* page-then-count loop collapses to.
|
|
68
|
+
*/
|
|
69
|
+
const tooManyGroups = <Row>(
|
|
70
|
+
entity: EntityCore<Row>,
|
|
71
|
+
operation: string,
|
|
72
|
+
property: string,
|
|
73
|
+
): EntityError =>
|
|
74
|
+
new EntityError({
|
|
75
|
+
code: 'X_INVARIANT_VIOLATED',
|
|
76
|
+
cause: `${entity.$name}.${operation}('${property}') matched more than ${MAX_GROUPS} distinct values — that column is a key, not a grouping`,
|
|
77
|
+
fix: `${entity.$name}.andWhere('${property}', 'in', <values>).${operation}('${property}') # bound the values first; a whole-table breakdown is a report, and a report is paged`,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The column a count may be keyed by, or the refusal. Called by both drivers before the statement
|
|
82
|
+
* exists, so an ungroupable column is the same error whichever one is installed.
|
|
83
|
+
*/
|
|
84
|
+
export const groupColumnOf = <Row>(
|
|
85
|
+
entity: EntityCore<Row>,
|
|
86
|
+
property: string,
|
|
87
|
+
operation: string,
|
|
88
|
+
): AnyColumn => {
|
|
89
|
+
const column = entity.$columns[property];
|
|
90
|
+
if (column === undefined) {
|
|
91
|
+
throw notGroupable(
|
|
92
|
+
entity,
|
|
93
|
+
operation,
|
|
94
|
+
property,
|
|
95
|
+
`no column "${property}" on ${entity.$name} — pick from: ${Object.keys(entity.$columns).join(', ')}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (!GROUPABLE.has(column.$meta.kind)) {
|
|
99
|
+
throw notGroupable(
|
|
100
|
+
entity,
|
|
101
|
+
operation,
|
|
102
|
+
property,
|
|
103
|
+
`a ${column.$meta.kind} column is not a key a map can be looked up by`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return column;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The value a group is filed under: re-parsed by the column that declared it, exactly as
|
|
111
|
+
* `decodeRow` re-parses a row's own value — `int8` arrives as a string and would otherwise key the
|
|
112
|
+
* map by text where the in-memory driver keys it by a `bigint`. An absent value is `null`, which
|
|
113
|
+
* is the one group SQL's `group by` puts every NULL row in.
|
|
114
|
+
*/
|
|
115
|
+
export const groupValue = (column: AnyColumn, value: unknown): unknown =>
|
|
116
|
+
value === null || value === undefined ? null : column.$parse(value);
|
|
117
|
+
|
|
118
|
+
/** Ties: numbers and bigints numerically, everything else by its text, `null` last. */
|
|
119
|
+
const byValue = (left: unknown, right: unknown): number => {
|
|
120
|
+
if (left === null) return right === null ? 0 : 1;
|
|
121
|
+
if (right === null) return -1;
|
|
122
|
+
if (typeof left === 'number' && typeof right === 'number') return left - right;
|
|
123
|
+
if (typeof left === 'bigint' && typeof right === 'bigint') {
|
|
124
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
125
|
+
}
|
|
126
|
+
const [a, b] = [String(left), String(right)];
|
|
127
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The map both drivers hand back: the biggest group first, ties by the value itself, `null` last.
|
|
132
|
+
* Ordered here rather than in the statement, because there is no order to inherit — a hash
|
|
133
|
+
* aggregate returns groups in whatever order it built them and a `Map` filled row by row returns
|
|
134
|
+
* them in insertion order, so the two drivers would disagree about a result they agree on.
|
|
135
|
+
* Sorting the groups (never the rows) costs nothing at this size and it is what makes
|
|
136
|
+
* "the largest bucket" readable off the front.
|
|
137
|
+
*/
|
|
138
|
+
export const countsFrom = <Row>(
|
|
139
|
+
entity: EntityCore<Row>,
|
|
140
|
+
property: string,
|
|
141
|
+
operation: string,
|
|
142
|
+
groups: readonly (readonly [unknown, number])[],
|
|
143
|
+
): ReadonlyMap<unknown, number> => {
|
|
144
|
+
if (groups.length > MAX_GROUPS) throw tooManyGroups(entity, operation, property);
|
|
145
|
+
return new Map(
|
|
146
|
+
[...groups].sort((left, right) => right[1] - left[1] || byValue(left[0], right[0])),
|
|
147
|
+
);
|
|
148
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Single responsibility: the one explicit way to read across tenants, and the capability that
|
|
2
|
+
// opens it. A scope with a written reason — never a boolean argument on a repository call, which
|
|
3
|
+
// reads exactly like forgetting the tenant, and never a config list of exempt entities (axiom 1):
|
|
4
|
+
// both put the argument somewhere other than the read it defends.
|
|
5
|
+
|
|
6
|
+
// `node:` because Bun exposes no native async-context primitive: the scope has to outlive every
|
|
7
|
+
// `await` inside it, and `AsyncLocalStorage` is the only thing that carries a value across them.
|
|
8
|
+
// A module-scope flag would be shared by two concurrent requests — one of them ordinary.
|
|
9
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
10
|
+
import { actorLabel, assert, hasScope, tryUseContext } from '@ultimat3/core';
|
|
11
|
+
import { crossTenantDenied } from './errors';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The capability an actor must carry to read across tenants. A scope, not a role: roles are the
|
|
15
|
+
* app's vocabulary and every app spells its administrator differently, while `scopes` is the
|
|
16
|
+
* closed list a policy already requires against — so an operator grants this the same way they
|
|
17
|
+
* grant `post:publish`, and `grep -r 'tenancy:cross'` finds every actor that holds it.
|
|
18
|
+
*/
|
|
19
|
+
export const CROSS_TENANT_SCOPE = 'tenancy:cross';
|
|
20
|
+
|
|
21
|
+
const storage = new AsyncLocalStorage<string>();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Run `fn` with the tenant guard lifted — every read and write it issues, at any depth and across
|
|
25
|
+
* every `await`, may span tenants. For the three cases that genuinely have no single tenant: an
|
|
26
|
+
* admin surface listing every org, background reconciliation, and support tooling.
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* // one sweep over every tenant's stale invites, nightly
|
|
30
|
+
* await crossTenant('nightly invite expiry runs for every org', async () => {
|
|
31
|
+
* for await (const batch of db.invites.where({ status: 'pending' }).inBatches(500)) …
|
|
32
|
+
* });
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* Three properties, none optional. **The capability is proven, always** — the actor in scope must
|
|
36
|
+
* carry `tenancy:cross`, here and again at every plan built inside, so an impersonated child
|
|
37
|
+
* context cannot inherit a permission its own actor never had. **Outside a request context there
|
|
38
|
+
* is no actor to prove it**, so a script asking for this mints one and says who it is, which is
|
|
39
|
+
* what makes a cross-tenant sweep auditable rather than ambient. **The reason is required and
|
|
40
|
+
* non-blank** because it *is* the mechanism: an escape with no argument is a pragma, and the next
|
|
41
|
+
* reader cannot tell a considered sweep from a forgotten tenant.
|
|
42
|
+
*/
|
|
43
|
+
export function crossTenant<T>(reason: string, fn: () => T): T {
|
|
44
|
+
assert(
|
|
45
|
+
reason.trim() !== '',
|
|
46
|
+
'crossTenant() was given a blank reason, so the tenant guard it lifts carries no argument',
|
|
47
|
+
"pass why the read spans tenants: crossTenant('nightly expiry sweeps every org', fn)",
|
|
48
|
+
);
|
|
49
|
+
assertCrossTenant(reason);
|
|
50
|
+
return storage.run(reason, fn);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The innermost enclosing reason, or `undefined` outside every scope — which is every query in an
|
|
55
|
+
* app that never calls `crossTenant`. Read by the tenant guard, and by nothing else.
|
|
56
|
+
*/
|
|
57
|
+
export const crossTenantReason = (): string | undefined => storage.getStore();
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The capability check itself, run at `crossTenant()` and again for every plan built inside it.
|
|
61
|
+
* Twice on purpose: `withChildContext({ actor })` swaps the actor without closing this scope, so a
|
|
62
|
+
* handler that impersonates a caller inside a sweep would otherwise keep reading across tenants on
|
|
63
|
+
* a permission that caller does not hold.
|
|
64
|
+
*/
|
|
65
|
+
export const assertCrossTenant = (reason: string): void => {
|
|
66
|
+
const actor = tryUseContext()?.actor;
|
|
67
|
+
if (actor !== undefined && hasScope(actor, CROSS_TENANT_SCOPE)) return;
|
|
68
|
+
throw crossTenantDenied({
|
|
69
|
+
reason,
|
|
70
|
+
actor:
|
|
71
|
+
actor === undefined
|
|
72
|
+
? 'no actor — the call is outside every request context'
|
|
73
|
+
: actorLabel(actor),
|
|
74
|
+
scope: CROSS_TENANT_SCOPE,
|
|
75
|
+
});
|
|
76
|
+
};
|
package/src/cursor.ts
CHANGED
|
@@ -12,7 +12,14 @@ import { invariantViolated } from './errors';
|
|
|
12
12
|
import type { QueryPlan } from './tenancy';
|
|
13
13
|
import type { AnyColumn, ColumnKind } from './types';
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The kind a money part is *revived* as, which is the kind the row property holds — not the
|
|
17
|
+
* physical column's. `<p>_minor` is a `bigint` column, but `MoneyValue.minor` is a `number`
|
|
18
|
+
* (`@ultimat3/schema` owns that declaration), and a cursor whose value came back a `bigint`
|
|
19
|
+
* would compare against a `number` property in the memory driver and mint a seek bind of the
|
|
20
|
+
* wrong type in the other. The narrowing itself is guarded once, where the row is decoded.
|
|
21
|
+
*/
|
|
22
|
+
const MONEY_PARTS: Readonly<Record<string, ColumnKind>> = { minor: 'integer', currency: 'char' };
|
|
16
23
|
|
|
17
24
|
/** Resolves `price.minor` as well as `title`; money is the one property with two parts. */
|
|
18
25
|
const partsOf = (path: string): { readonly property: string; readonly part?: string } => {
|
|
@@ -69,8 +76,15 @@ const serializeSortValue = (value: unknown): string => {
|
|
|
69
76
|
};
|
|
70
77
|
|
|
71
78
|
// No `money` case: `kindAt` resolves a money sort key to the kind of the part being ordered by
|
|
72
|
-
//
|
|
73
|
-
//
|
|
79
|
+
// and refuses the bare property, so the composite kind never reaches here. A case for it could
|
|
80
|
+
// only ever revive "[object Object]".
|
|
81
|
+
//
|
|
82
|
+
// The parts revive as `MONEY_PARTS` declares them — `minor` as an `integer` and `currency` as a
|
|
83
|
+
// `char` — and `minor` is deliberately NOT `bigint` even though the physical column is: the row
|
|
84
|
+
// property is a `number` (`@ultimat3/schema` owns that declaration), and a cursor reviving a
|
|
85
|
+
// `bigint` there would compare against a `number` property in the memory driver and mint a seek
|
|
86
|
+
// bind of the wrong type in the other. The comment here used to claim the opposite of the
|
|
87
|
+
// constant three lines above it.
|
|
74
88
|
const reviveSortValue = (kind: ColumnKind, text: string): unknown => {
|
|
75
89
|
switch (kind) {
|
|
76
90
|
case 'timestamptz':
|
package/src/database.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// writes a repository class per entity, and nobody can reach a table that is not in the set.
|
|
3
3
|
|
|
4
4
|
import type { EntityCore } from './entity';
|
|
5
|
+
import type { RelatedTables } from './preload';
|
|
5
6
|
import type { Table } from './query';
|
|
6
7
|
import { tableFor } from './query';
|
|
7
8
|
import type { Repo } from './repo';
|
|
@@ -16,6 +17,13 @@ export type Database<E extends EntitySet> = {
|
|
|
16
17
|
/** Where rows actually live. Postgres in production, memory in tests and before migrations. */
|
|
17
18
|
export interface Driver {
|
|
18
19
|
repo<Row>(entity: EntityCore<Row>): Repo<Row>;
|
|
20
|
+
/**
|
|
21
|
+
* TEST SEAM, optional on purpose. Empties everything this driver holds, so one test's rows are
|
|
22
|
+
* not the next test's fixtures. `memoryDriver()` implements it; `postgresDriver()` leaves it
|
|
23
|
+
* undefined, because the rows there are an app's and a framework that could truncate them from a
|
|
24
|
+
* `reset()` eventually would. A harness therefore asks and does not assume: `driver.reset?.()`.
|
|
25
|
+
*/
|
|
26
|
+
reset?(): void;
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
export interface DatabaseOptions {
|
|
@@ -28,6 +36,9 @@ export interface DatabaseOptions {
|
|
|
28
36
|
*/
|
|
29
37
|
export const memoryDriver = (): Driver => {
|
|
30
38
|
const repos = new Map<string, unknown>();
|
|
39
|
+
// Held separately from `repos` so the reset is a call on the repository the tables already
|
|
40
|
+
// resolved, never a replacement of it.
|
|
41
|
+
const resets: (() => void)[] = [];
|
|
31
42
|
return {
|
|
32
43
|
repo<Row>(entity: EntityCore<Row>): Repo<Row> {
|
|
33
44
|
const existing = repos.get(entity.$name);
|
|
@@ -36,14 +47,27 @@ export const memoryDriver = (): Driver => {
|
|
|
36
47
|
if (existing !== undefined) return existing as Repo<Row>;
|
|
37
48
|
const created = memoryRepo<Row>(entity);
|
|
38
49
|
repos.set(entity.$name, created);
|
|
50
|
+
resets.push(() => created.reset());
|
|
39
51
|
return created;
|
|
40
52
|
},
|
|
53
|
+
reset() {
|
|
54
|
+
for (const reset of resets) reset();
|
|
55
|
+
},
|
|
41
56
|
};
|
|
42
57
|
};
|
|
43
58
|
|
|
44
59
|
let shared: Driver | undefined;
|
|
45
60
|
|
|
46
|
-
|
|
61
|
+
/**
|
|
62
|
+
* The driver `database()` uses when a call names none — one per process, created on first use.
|
|
63
|
+
*
|
|
64
|
+
* Exported for ONE reason: a test harness needs the same object the app reads through, so it can
|
|
65
|
+
* seed it before a test and `reset?.()` it after. Without a handle on it, a preload could only
|
|
66
|
+
* build a driver of its own, and rows written into that one are invisible to every `database()`
|
|
67
|
+
* call the app already made. Application code names its driver in `database(entities, { driver })`
|
|
68
|
+
* or takes this one implicitly; it never asks for it by hand.
|
|
69
|
+
*/
|
|
70
|
+
export const defaultDriver = (): Driver => {
|
|
47
71
|
shared ??= memoryDriver();
|
|
48
72
|
return shared;
|
|
49
73
|
};
|
|
@@ -53,9 +77,18 @@ export const database = <E extends EntitySet>(
|
|
|
53
77
|
options: DatabaseOptions = {},
|
|
54
78
|
): Database<E> => {
|
|
55
79
|
const driver = options.driver ?? defaultDriver();
|
|
80
|
+
// Keyed by entity name, which is what a relation names — the object key is the caller's spelling
|
|
81
|
+
// of it. This handle is the whole of what a preload can reach: a table reads the entities its own
|
|
82
|
+
// `database()` call named, through the driver that call was given, so a preload against memory
|
|
83
|
+
// means what a preload against Postgres means.
|
|
84
|
+
const declared = new Map(Object.values(entities).map((entity) => [entity.$name, entity]));
|
|
85
|
+
const related: RelatedTables = (entityName) => {
|
|
86
|
+
const entity = declared.get(entityName);
|
|
87
|
+
return entity === undefined ? undefined : { entity, repo: driver.repo(entity) };
|
|
88
|
+
};
|
|
56
89
|
const tables: Record<string, unknown> = {};
|
|
57
90
|
for (const [key, entity] of Object.entries(entities)) {
|
|
58
|
-
tables[key] = tableFor(entity, driver.repo(entity));
|
|
91
|
+
tables[key] = tableFor(entity, driver.repo(entity), related);
|
|
59
92
|
}
|
|
60
93
|
// Built key by key from `entities`, so each table is the one `Database<E>` names.
|
|
61
94
|
return tables as Database<E>;
|