@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/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 { Column, MoneyInput, MoneyValue, TimestampColumn, UuidColumn } from './types';
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, got ${String(value)}`);
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
- export const uuid = (): UuidColumn => ({
25
- ...makeColumn<string, false>({ ...BARE, kind: 'uuid' }, parseUuid, false),
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<string, true>(
72
+ makeColumn<T, true>(
30
73
  { ...BARE, kind: 'uuid', primaryKey: true, default: GENERATED_UUID },
31
- parseUuid,
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, got ${typeof value}`),
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, got ${String(value)}`),
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, got ${typeof value}`),
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, got ${String(value)}`);
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(' | ')}, got ${String(value)}`),
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, got ${String(value)}`);
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(' | ')}, got ${String(value)}`),
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(' | ')}, got ${String(value)}`),
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
- const parseMinor = (value: unknown): bigint => {
167
- if (typeof value === 'bigint') return value;
168
- if (typeof value === 'number') {
169
- if (!Number.isInteger(value)) {
170
- return reject(
171
- 'money-minor-units',
172
- `got the float ${value}; money is integer minor units12.34 EUR is 1234n, not 12.34`,
173
- );
174
- }
175
- return BigInt(value);
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 frameworka 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 (typeof value === 'string' && /^-?\d+$/.test(value)) return BigInt(value);
178
- return reject('money-minor-units', `expected integer minor units, got ${String(value)}`);
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
- typeof value === 'string' && /^[A-Z]{3}$/.test(value)
252
+ isCurrencyCode(value)
183
253
  ? value
184
- : reject('iso-4217', `expected a 3-letter ISO-4217 code, got ${String(value)}`);
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 }, got ${String(value)}`);
274
+ return reject('money', `expected { minor, currency }, ${got(value)}`);
189
275
  }
190
276
  const input: Partial<MoneyInput> = value;
191
- return { minor: parseMinor(input.minor), currency: parseCurrency(input.currency) };
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, two physical columns: `<name>_minor bigint` and `<name>_currency char(3)`.
196
- * A single implied currency is a migration nobody wants to write later, and a float is a
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
- /** The CHECK that stops a psql session writing a currency the app would refuse. */
202
- export const currencyCheck = (currencyColumn: string): string => `${currencyColumn} ~ '^[A-Z]{3}$'`;
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})`;
@@ -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
- const MONEY_PARTS: Readonly<Record<string, ColumnKind>> = { minor: 'bigint', currency: 'char' };
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
- // (`minor` is bigint, `currency` is char) and refuses the bare property, so the composite kind
73
- // never reaches here. A case for it could only ever revive "[object Object]".
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
- const defaultDriver = (): Driver => {
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>;