@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/columns.ts
CHANGED
|
@@ -3,14 +3,54 @@
|
|
|
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 {
|
|
6
|
+
import {
|
|
7
|
+
CURRENCY_CODE_PATTERN,
|
|
8
|
+
describeValue,
|
|
9
|
+
isCurrencyCode,
|
|
10
|
+
isMoneyScale,
|
|
11
|
+
MAX_MONEY_SCALE,
|
|
12
|
+
} from '@ultimat3/schema';
|
|
13
|
+
import {
|
|
14
|
+
assertColumnName,
|
|
15
|
+
BARE,
|
|
16
|
+
column,
|
|
17
|
+
GENERATED_UUID,
|
|
18
|
+
makeColumn,
|
|
19
|
+
makeTimestamp,
|
|
20
|
+
} from './column';
|
|
7
21
|
import { invariantViolated } from './errors';
|
|
8
|
-
import type {
|
|
22
|
+
import type {
|
|
23
|
+
Column,
|
|
24
|
+
ColumnMap,
|
|
25
|
+
ColumnMeta,
|
|
26
|
+
MoneyColumnNames,
|
|
27
|
+
MoneyInput,
|
|
28
|
+
MoneyValue,
|
|
29
|
+
TimestampColumn,
|
|
30
|
+
UuidColumn,
|
|
31
|
+
} from './types';
|
|
9
32
|
|
|
10
33
|
const reject = (rule: string, detail: string): never => {
|
|
11
34
|
throw invariantViolated('column', rule, detail);
|
|
12
35
|
};
|
|
13
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The rejected value, rendered as its SHAPE and never its content — `@ultimat3/schema`'s
|
|
39
|
+
* `describeValue`, the same renderer every builtin validator fails through, so a column and a
|
|
40
|
+
* schema describe one bad value the same way.
|
|
41
|
+
*
|
|
42
|
+
* WHY it is not `String(value)`: a column rejection is not a private diagnostic. It becomes
|
|
43
|
+
* `X_INVARIANT_VIOLATED`'s `cause` and a `$view` issue, which `@ultimat3/http` returns to the
|
|
44
|
+
* caller AND writes into the log line — and core's logger redacts by KEY, so a value baked into a
|
|
45
|
+
* message has no key left to redact. `text()` on a password field wrote the mistyped password to
|
|
46
|
+
* the central log index in cleartext and into the user's own network tab; a `uuid()` holding an
|
|
47
|
+
* API key surrogate does the same. A column is the worse half of that pair, because the value can
|
|
48
|
+
* arrive from the DATABASE — so the leak is not bounded by what someone just typed.
|
|
49
|
+
*
|
|
50
|
+
* `got` stays `got` and the "expected …" half is untouched: only what follows it changes.
|
|
51
|
+
*/
|
|
52
|
+
const got = (value: unknown): string => `got ${describeValue(value)}`;
|
|
53
|
+
|
|
14
54
|
/** uuid v7: time-ordered, so a primary key index stays append-friendly. */
|
|
15
55
|
export const newId = (): string => uuidV7();
|
|
16
56
|
|
|
@@ -19,18 +59,36 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
19
59
|
const parseUuid = (value: unknown): string =>
|
|
20
60
|
typeof value === 'string' && UUID.test(value)
|
|
21
61
|
? value
|
|
22
|
-
: reject('format', `expected a uuid,
|
|
62
|
+
: reject('format', `expected a uuid, ${got(value)}`);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The one place a brand is applied. A brand is a compile-time tag with no runtime witness, so
|
|
66
|
+
* there is nothing here to check that `parseUuid` has not already checked — same shape as core's
|
|
67
|
+
* `parseId`, and the reason `uuid<PostId>()` needs no cast at any call site afterwards.
|
|
68
|
+
*/
|
|
69
|
+
const parseBrandedUuid = <T extends string>(value: unknown): T => parseUuid(value) as T;
|
|
23
70
|
|
|
24
|
-
|
|
25
|
-
|
|
71
|
+
/**
|
|
72
|
+
* `uuid()` for a plain id, `uuid<PostId>()` to declare the brand ONCE. The brand then rides the
|
|
73
|
+
* derivation — row, insert, `findById`, `update`, `delete` — so mixing two entities' ids is a
|
|
74
|
+
* compile error instead of a query that silently matches nothing.
|
|
75
|
+
*/
|
|
76
|
+
export const uuid = <T extends string = string>(): UuidColumn<T> =>
|
|
77
|
+
uuidWith({ ...BARE, kind: 'uuid' });
|
|
78
|
+
|
|
79
|
+
const uuidWith = <T extends string>(meta: ColumnMeta): UuidColumn<T> => ({
|
|
80
|
+
...makeColumn<T, false>(meta, parseBrandedUuid, false),
|
|
26
81
|
// Narrower than the generic chain: a uuid key is generated when omitted, so it is the one
|
|
27
82
|
// primary key an insert may leave out.
|
|
28
83
|
primaryKey: () =>
|
|
29
|
-
makeColumn<
|
|
30
|
-
{ ...
|
|
31
|
-
|
|
84
|
+
makeColumn<T, true>(
|
|
85
|
+
{ ...meta, primaryKey: true, default: GENERATED_UUID },
|
|
86
|
+
parseBrandedUuid,
|
|
32
87
|
true,
|
|
33
88
|
),
|
|
89
|
+
// Overridden for the same reason `timestamp()`'s is: `uuid().column('user_id').primaryKey()`
|
|
90
|
+
// must still be the KEY form, which is optional on insert, rather than the general one.
|
|
91
|
+
column: (name) => uuidWith<T>({ ...meta, name: assertColumnName(name) }),
|
|
34
92
|
});
|
|
35
93
|
|
|
36
94
|
export interface TextOptions {
|
|
@@ -42,7 +100,7 @@ export const text = (options: TextOptions = {}): Column<string> =>
|
|
|
42
100
|
column<string>(
|
|
43
101
|
'text',
|
|
44
102
|
(value) =>
|
|
45
|
-
typeof value === 'string' ? value : reject('type', `expected a string,
|
|
103
|
+
typeof value === 'string' ? value : reject('type', `expected a string, ${got(value)}`),
|
|
46
104
|
options.max === undefined
|
|
47
105
|
? {}
|
|
48
106
|
: { length: options.max, check: (name) => `char_length(${name}) <= ${options.max}` },
|
|
@@ -52,12 +110,12 @@ export const integer = (): Column<number> =>
|
|
|
52
110
|
column<number>('integer', (value) =>
|
|
53
111
|
typeof value === 'number' && Number.isSafeInteger(value)
|
|
54
112
|
? value
|
|
55
|
-
: reject('type', `expected a safe integer,
|
|
113
|
+
: reject('type', `expected a safe integer, ${got(value)}`),
|
|
56
114
|
);
|
|
57
115
|
|
|
58
116
|
export const boolean = (): Column<boolean> =>
|
|
59
117
|
column<boolean>('boolean', (value) =>
|
|
60
|
-
typeof value === 'boolean' ? value : reject('type', `expected a boolean,
|
|
118
|
+
typeof value === 'boolean' ? value : reject('type', `expected a boolean, ${got(value)}`),
|
|
61
119
|
);
|
|
62
120
|
|
|
63
121
|
const parseInstant = (value: unknown): Date => {
|
|
@@ -66,7 +124,7 @@ const parseInstant = (value: unknown): Date => {
|
|
|
66
124
|
const parsed = new Date(value);
|
|
67
125
|
if (!Number.isNaN(parsed.getTime())) return parsed;
|
|
68
126
|
}
|
|
69
|
-
return reject('format', `expected a UTC instant,
|
|
127
|
+
return reject('format', `expected a UTC instant, ${got(value)}`);
|
|
70
128
|
};
|
|
71
129
|
|
|
72
130
|
/** Always `timestamptz`. UTC storage is not a per-table decision. */
|
|
@@ -92,7 +150,7 @@ export const enumerated = <const V extends readonly string[]>(values: V): Column
|
|
|
92
150
|
(value) =>
|
|
93
151
|
typeof value === 'string' && allowed.has(value)
|
|
94
152
|
? value
|
|
95
|
-
: reject('enum', `expected one of ${values.join(' | ')},
|
|
153
|
+
: reject('enum', `expected one of ${values.join(' | ')}, ${got(value)}`),
|
|
96
154
|
{ values, check: oneOf(values) },
|
|
97
155
|
);
|
|
98
156
|
};
|
|
@@ -113,7 +171,7 @@ export const url = (): Column<string> =>
|
|
|
113
171
|
// fall through to the shared rejection so the error names the rule
|
|
114
172
|
}
|
|
115
173
|
}
|
|
116
|
-
return reject('format', `expected an absolute http(s) URL,
|
|
174
|
+
return reject('format', `expected an absolute http(s) URL, ${got(value)}`);
|
|
117
175
|
},
|
|
118
176
|
{ check: (name) => `${name} ~ '^https?://'` },
|
|
119
177
|
);
|
|
@@ -141,7 +199,7 @@ export const tz = <const Z extends readonly string[]>(zones: Z): Column<Z[number
|
|
|
141
199
|
(value) =>
|
|
142
200
|
typeof value === 'string' && allowed.has(value)
|
|
143
201
|
? value
|
|
144
|
-
: reject('iana-tz', `expected one of ${zones.join(' | ')},
|
|
202
|
+
: reject('iana-tz', `expected one of ${zones.join(' | ')}, ${got(value)}`),
|
|
145
203
|
{ values: zones, check: oneOf(zones) },
|
|
146
204
|
);
|
|
147
205
|
};
|
|
@@ -158,45 +216,177 @@ export const locale = <const L extends readonly string[]>(locales: L): Column<L[
|
|
|
158
216
|
(value) =>
|
|
159
217
|
typeof value === 'string' && allowed.has(value)
|
|
160
218
|
? value
|
|
161
|
-
: reject('bcp-47', `expected one of ${locales.join(' | ')},
|
|
219
|
+
: reject('bcp-47', `expected one of ${locales.join(' | ')}, ${got(value)}`),
|
|
162
220
|
{ values: locales, check: oneOf(locales) },
|
|
163
221
|
);
|
|
164
222
|
};
|
|
165
223
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
224
|
+
/**
|
|
225
|
+
* The column is `bigint` and the value type is a `number`, which is the one narrowing in this
|
|
226
|
+
* package that can lose information — so it is the one narrowing that refuses rather than rounds.
|
|
227
|
+
*
|
|
228
|
+
* `number` is not a compromise here: money is projected onto every wire this framework generates,
|
|
229
|
+
* and `JSON.stringify` throws on a bigint. What the wide column buys is the ability to *hold* a
|
|
230
|
+
* value written by something that is not this framework — a psql session, a backfill, another
|
|
231
|
+
* service — and the honest answer to reading one back is a coded refusal naming the row, not a
|
|
232
|
+
* `minor` that silently rounds and not a `bigint` that crashes the response three layers later.
|
|
233
|
+
* `@ultimat3/realtime` refuses the identical value for the identical reason (`pg-entity-row.ts`),
|
|
234
|
+
* so the two readers of one column agree.
|
|
235
|
+
*/
|
|
236
|
+
const parseMinor = (value: unknown): number => {
|
|
237
|
+
const minor =
|
|
238
|
+
typeof value === 'bigint' || (typeof value === 'string' && /^-?\d+$/.test(value))
|
|
239
|
+
? Number(value)
|
|
240
|
+
: value;
|
|
241
|
+
if (typeof minor !== 'number' || !Number.isFinite(minor)) {
|
|
242
|
+
return reject('money-minor-units', `expected integer minor units, ${got(value)}`);
|
|
243
|
+
}
|
|
244
|
+
if (!Number.isInteger(minor)) {
|
|
245
|
+
return reject(
|
|
246
|
+
'money-minor-units',
|
|
247
|
+
`got the float ${minor}; money is integer minor units — 12.34 EUR is 1234, not 12.34`,
|
|
248
|
+
);
|
|
176
249
|
}
|
|
177
|
-
if (
|
|
178
|
-
|
|
250
|
+
if (!Number.isSafeInteger(minor)) {
|
|
251
|
+
return reject(
|
|
252
|
+
'money-minor-units',
|
|
253
|
+
`${String(value)} is past ±2^53 and no JS number holds it exactly — money is minor units ` +
|
|
254
|
+
'inside that range; store the overflow in its own column or split the amount',
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return minor;
|
|
179
258
|
};
|
|
180
259
|
|
|
260
|
+
/**
|
|
261
|
+
* The bound is `@ultimat3/schema`'s, imported rather than restated — the same rule `parseScale`
|
|
262
|
+
* below follows for `isMoneyScale`. This column, `moneySchema`, the OpenAPI `pattern` and the
|
|
263
|
+
* CHECK at the bottom of this file are four projections of one declaration; each was individually
|
|
264
|
+
* correct and would have drifted silently, since only a psql session sees the disagreement.
|
|
265
|
+
*/
|
|
181
266
|
const parseCurrency = (value: unknown): string =>
|
|
182
|
-
|
|
267
|
+
isCurrencyCode(value)
|
|
183
268
|
? value
|
|
184
|
-
: reject('iso-4217', `expected a 3-letter ISO-4217 code,
|
|
269
|
+
: reject('iso-4217', `expected a 3-letter ISO-4217 code, ${got(value)}`);
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The decimal exponent `minor` counts in, when it is not the currency's own. `undefined` and `0`
|
|
273
|
+
* are DIFFERENT values — "the currency's natural minor unit" versus "whole units" — so the key is
|
|
274
|
+
* carried only when it was supplied, exactly as `@ultimat3/schema`'s `moneySchema` carries it.
|
|
275
|
+
* The legal range is `isMoneyScale`'s, imported rather than restated: one bound, one declaration.
|
|
276
|
+
*/
|
|
277
|
+
const parseScale = (value: unknown): number => {
|
|
278
|
+
const scale = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value;
|
|
279
|
+
return isMoneyScale(scale)
|
|
280
|
+
? scale
|
|
281
|
+
: reject(
|
|
282
|
+
'money-scale',
|
|
283
|
+
`expected a whole number of decimal places between 0 and ${MAX_MONEY_SCALE}, ${got(value)}`,
|
|
284
|
+
);
|
|
285
|
+
};
|
|
185
286
|
|
|
186
287
|
const parseMoney = (value: unknown): MoneyValue => {
|
|
187
288
|
if (typeof value !== 'object' || value === null) {
|
|
188
|
-
return reject('money', `expected { minor, currency },
|
|
289
|
+
return reject('money', `expected { minor, currency }, ${got(value)}`);
|
|
189
290
|
}
|
|
190
291
|
const input: Partial<MoneyInput> = value;
|
|
191
|
-
return {
|
|
292
|
+
return {
|
|
293
|
+
minor: parseMinor(input.minor),
|
|
294
|
+
currency: parseCurrency(input.currency),
|
|
295
|
+
...(input.scale === undefined || input.scale === null
|
|
296
|
+
? {}
|
|
297
|
+
: { scale: parseScale(input.scale) }),
|
|
298
|
+
};
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* One property, three physical columns: `<name>_minor bigint`, `<name>_currency char(3)` and
|
|
303
|
+
* `<name>_scale integer null`. A single implied currency is a migration nobody wants to write
|
|
304
|
+
* later, and a float is a rounding bug nobody wants to debug.
|
|
305
|
+
*
|
|
306
|
+
* The third column is not decoration: `scale` is what lets an amount name a sub-cent value, and
|
|
307
|
+
* the entity layer used to rebuild the row as `{ minor, currency }` — so
|
|
308
|
+
* `{ minor: 2, currency: 'USD', scale: 6 }` ($0.000002) was stored and read back as $0.02, a
|
|
309
|
+
* silent 10,000x reinterpretation of a value the type system, `t.money` and `@ultimat3/money` all
|
|
310
|
+
* carry. `null` in the column is "no explicit scale" and decodes to an ABSENT key, never to `0`.
|
|
311
|
+
*/
|
|
312
|
+
export interface MoneyOptions {
|
|
313
|
+
/**
|
|
314
|
+
* Where the three columns already live, for a table this framework did not create. Named per
|
|
315
|
+
* part and merged over `<name>_minor` / `<name>_currency` / `<name>_scale`, so a legacy
|
|
316
|
+
* `amount_cents` beside a `currency` is two words rather than three.
|
|
317
|
+
*
|
|
318
|
+
* `scale: null` says the table has no scale column at all. What that costs is stated where the
|
|
319
|
+
* option is declared (`MoneyColumnNames`): every amount is then at the currency's own minor
|
|
320
|
+
* unit. What it does NOT cost is correctness — an absent scale already means exactly that.
|
|
321
|
+
*/
|
|
322
|
+
readonly columns?: MoneyColumnNames;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export const money = (options: MoneyOptions = {}): Column<MoneyValue> =>
|
|
326
|
+
column<MoneyValue>(
|
|
327
|
+
'money',
|
|
328
|
+
parseMoney,
|
|
329
|
+
options.columns === undefined ? {} : { parts: checkedParts(options.columns) },
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
/** Every part named goes through the same identifier rule a `.column()` does. */
|
|
333
|
+
const checkedParts = (columns: MoneyColumnNames): MoneyColumnNames => ({
|
|
334
|
+
...(columns.minor === undefined ? {} : { minor: assertColumnName(columns.minor) }),
|
|
335
|
+
...(columns.currency === undefined ? {} : { currency: assertColumnName(columns.currency) }),
|
|
336
|
+
...(columns.scale === undefined || columns.scale === null
|
|
337
|
+
? { ...(columns.scale === null ? { scale: null } : {}) }
|
|
338
|
+
: { scale: assertColumnName(columns.scale) }),
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Money is the one column whose write type is wider than its row type — `MoneyInput` takes a
|
|
343
|
+
* `bigint` so a minor unit read straight off a `bigint` column needs no conversion at the call
|
|
344
|
+
* site — so it is the one column where "the caller's value" and "the row's value" can differ.
|
|
345
|
+
* This is where they stop differing, and BOTH drivers call it: `bindValues` before a statement,
|
|
346
|
+
* `memoryRepo`'s `write` before it stores. A rule applied to one of them and not the other is
|
|
347
|
+
* exactly the drift the two-driver split exists to prevent — here it would mean an in-memory row
|
|
348
|
+
* holding a `bigint` that `JSON.stringify` refuses while the Postgres row holds a `number`.
|
|
349
|
+
*
|
|
350
|
+
* Every other kind is returned untouched: writes are asserted, not parsed, and money is the only
|
|
351
|
+
* kind that widens. A value already holding safe-integer minor units is left alone — that is the
|
|
352
|
+
* overwhelmingly common case and it costs one `typeof`-grade check and no allocation (axiom 6);
|
|
353
|
+
* everything else goes through `parseMinor`, so a `bigint` narrows and a float is refused with
|
|
354
|
+
* the same message it would get coming back from the database.
|
|
355
|
+
*/
|
|
356
|
+
export const narrowMoney = <Row>(columns: ColumnMap, row: Row): Row => {
|
|
357
|
+
let narrowed: Record<string, unknown> | undefined;
|
|
358
|
+
const record = row as Readonly<Record<string, unknown>>;
|
|
359
|
+
for (const [property, column] of Object.entries(columns)) {
|
|
360
|
+
if (column.$meta.kind !== 'money') continue;
|
|
361
|
+
const value = record[property] as Partial<MoneyInput> | null | undefined;
|
|
362
|
+
if (value === null || value === undefined || Number.isSafeInteger(value.minor)) continue;
|
|
363
|
+
// Spread rather than rebuild: `currency` is the column's to validate on read and Postgres's
|
|
364
|
+
// to CHECK on write, and narrowing a minor unit is not the place to start refusing one.
|
|
365
|
+
narrowed ??= { ...record };
|
|
366
|
+
narrowed[property] = { ...value, minor: parseMinor(value.minor) };
|
|
367
|
+
}
|
|
368
|
+
return (narrowed ?? row) as Row;
|
|
192
369
|
};
|
|
193
370
|
|
|
194
371
|
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
372
|
+
* The CHECK that stops a psql session writing a currency the app would refuse — the app's own
|
|
373
|
+
* bound, projected into SQL rather than restated in it.
|
|
374
|
+
*
|
|
375
|
+
* SQL cannot call `isCurrencyCode`, so what crosses is `CURRENCY_CODE_PATTERN`, the pattern source
|
|
376
|
+
* that predicate is built from — the same move `scaleCheck` below already makes with
|
|
377
|
+
* `MAX_MONEY_SCALE`. It holds because the pattern is deliberately kept to the syntax ECMAScript
|
|
378
|
+
* and POSIX ERE spell identically (see its declaration); the one thing a TypeScript test cannot
|
|
379
|
+
* prove is that a real server reads it the same way, which is what
|
|
380
|
+
* `currency-check.live.test.ts` sends to Postgres. Quoting is not a concern and must not become
|
|
381
|
+
* one: this is a compile-time constant from tier 0, never a value.
|
|
198
382
|
*/
|
|
199
|
-
export const
|
|
383
|
+
export const currencyCheck = (currencyColumn: string): string =>
|
|
384
|
+
`${currencyColumn} ~ '${CURRENCY_CODE_PATTERN}'`;
|
|
200
385
|
|
|
201
|
-
/**
|
|
202
|
-
|
|
386
|
+
/**
|
|
387
|
+
* The same for the scale column: `parseScale` refuses anything outside `0…MAX_MONEY_SCALE`, and a
|
|
388
|
+
* row written by a backfill or a psql session must not be able to hold a value the app would
|
|
389
|
+
* refuse to read back. `is null` is legal and is the ordinary case.
|
|
390
|
+
*/
|
|
391
|
+
export const scaleCheck = (scaleColumn: string): string =>
|
|
392
|
+
`${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':
|