@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/tenancy.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
// Multi-tenancy is a guard, not a convention. An entity with a tenant column
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Multi-tenancy is a guard, not a convention. An entity with a tenant column is read AND written
|
|
2
|
+
// under the acting actor's tenant — derived from the ambient context, never taken from an argument
|
|
3
|
+
// — and a plan or a row that names a different one is refused rather than carried out.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { tryUseContext } from '@ultimat3/core';
|
|
6
|
+
import { assertCrossTenant, crossTenantReason } from './cross-tenant';
|
|
7
|
+
import {
|
|
8
|
+
EntityError,
|
|
9
|
+
tenancyActorMismatch,
|
|
10
|
+
tenancyActorOrgRequired,
|
|
11
|
+
tenancyRowMismatch,
|
|
12
|
+
tenancyUnscoped,
|
|
13
|
+
} from './errors';
|
|
6
14
|
import type { ColumnMap } from './types';
|
|
7
15
|
|
|
8
16
|
export type Operator =
|
|
@@ -67,8 +75,7 @@ export const resolveTenantColumn = (
|
|
|
67
75
|
columns: ColumnMap,
|
|
68
76
|
declared: string | undefined,
|
|
69
77
|
): string | null => {
|
|
70
|
-
if (declared
|
|
71
|
-
if (!Object.hasOwn(columns, declared)) {
|
|
78
|
+
if (declared !== undefined && !Object.hasOwn(columns, declared)) {
|
|
72
79
|
const available = Object.keys(columns).join(', ');
|
|
73
80
|
// Not `invariantViolated`: its fix points at `x entity explain`, which describes invariants
|
|
74
81
|
// the author never wrote. What repairs this is one edit to the declaration, so the error
|
|
@@ -79,7 +86,24 @@ export const resolveTenantColumn = (
|
|
|
79
86
|
fix: `set tenant to one of ${available} in entity('${entityName}'), or remove the tenant key — inference then takes the .tenant() column, else one named ${ORG_COLUMN}`,
|
|
80
87
|
});
|
|
81
88
|
}
|
|
82
|
-
|
|
89
|
+
const property = declared ?? tenantColumnOf(columns);
|
|
90
|
+
if (property === null) return null;
|
|
91
|
+
// A NULLABLE tenant column is refused here, at the declaration, and this is the same guard as
|
|
92
|
+
// the one above rather than an extra rule: `assertRowTenant` returns early on a row that names
|
|
93
|
+
// no tenant — "left alone, and the column's NOT NULL answers it" — so on a nullable column the
|
|
94
|
+
// delegation has nothing to delegate to. The row lands with a null tenant, and a null is
|
|
95
|
+
// matched by no `org_id = $1`: it is invisible to every tenant-scoped read, so it never appears
|
|
96
|
+
// in an export, never goes in an offboarding sweep, and sits in the table owned by nobody.
|
|
97
|
+
// A tenant that may be absent is a table that is only sometimes multi-tenant, which is not a
|
|
98
|
+
// shape this layer can enforce — so it is refused where the author can see it.
|
|
99
|
+
if (columns[property]?.$meta.notNull === false) {
|
|
100
|
+
throw new EntityError({
|
|
101
|
+
code: 'X_INVARIANT_VIOLATED',
|
|
102
|
+
cause: `${entityName}.${property} is the tenant column and is nullable — a row written with no ${property} is matched by no tenant-scoped query, so it belongs to nobody and no sweep can ever find it`,
|
|
103
|
+
fix: `drop .nullable() from ${property} in entity('${entityName}'), then x db gen "backfill ${entityName} ${property}" — a row with no tenant needs one before the column can refuse it`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return property;
|
|
83
107
|
};
|
|
84
108
|
|
|
85
109
|
export const emptyPlan = (entity: string, limit = 50): QueryPlan => ({
|
|
@@ -89,10 +113,19 @@ export const emptyPlan = (entity: string, limit = 50): QueryPlan => ({
|
|
|
89
113
|
limit,
|
|
90
114
|
});
|
|
91
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Whether the plan mentions the tenant column at all — never whether it mentions the right VALUE,
|
|
118
|
+
* which is why this is not the guard. `scopedPlan` compares against the actor; this answers the
|
|
119
|
+
* narrower question the derivation asks before it appends a predicate that already exists.
|
|
120
|
+
*/
|
|
92
121
|
export const hasOrgPredicate = (plan: QueryPlan, column: string = ORG_COLUMN): boolean =>
|
|
93
122
|
plan.where.some((predicate) => predicate.column === column);
|
|
94
123
|
|
|
95
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* Adds the org predicate exactly once; calling it twice is not an error. The explicit form of what
|
|
126
|
+
* `scopedPlan` does from the actor — and inside a request it must name that same tenant, or the
|
|
127
|
+
* plan is refused as `X_TENANCY_ACTOR_MISMATCH`. It adds a predicate; it never authorises one.
|
|
128
|
+
*/
|
|
96
129
|
export const orgScoped = (
|
|
97
130
|
plan: QueryPlan,
|
|
98
131
|
orgId: string,
|
|
@@ -103,8 +136,101 @@ export const orgScoped = (
|
|
|
103
136
|
: { ...plan, where: [...plan.where, { column, op: 'eq', value: orgId }] };
|
|
104
137
|
|
|
105
138
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
139
|
+
* The tenant every plan for a scoped entity runs under: the acting actor's own, or `undefined`
|
|
140
|
+
* when there is no request context to take one from.
|
|
141
|
+
*
|
|
142
|
+
* An actor that carries no tenant is refused rather than allowed to name one — anonymous is the
|
|
143
|
+
* case that must not read a tenant table by asking nicely, and a service actor minted without an
|
|
144
|
+
* org is a boundary that forgot to resolve it. `crossTenant()` is the way to mean it on purpose.
|
|
145
|
+
*
|
|
146
|
+
* No context at all is a different situation and not a caller-reachable one: every entry point in
|
|
147
|
+
* the framework runs its handler inside `runWithContext`, so this is a script, a boot path or a
|
|
148
|
+
* test harness, with no identity to check a value against. Those callers still have to name the
|
|
149
|
+
* tenant themselves — `verifyScope` refuses an unscoped plan exactly as it always did.
|
|
150
|
+
*/
|
|
151
|
+
const actorTenant = (entityName: string, operation: string): string | undefined => {
|
|
152
|
+
const ctx = tryUseContext();
|
|
153
|
+
if (ctx === undefined) return undefined;
|
|
154
|
+
const { actor } = ctx;
|
|
155
|
+
if (actor.orgId === undefined) {
|
|
156
|
+
throw tenancyActorOrgRequired({
|
|
157
|
+
entityName,
|
|
158
|
+
operation,
|
|
159
|
+
actorId: actor.id,
|
|
160
|
+
actorKind: actor.kind,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return actor.orgId;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Every predicate on the tenant column has to be `eq` the actor's own tenant. Every one, and `eq`
|
|
168
|
+
* only: `where('orgId', 'in', [mine, theirs])` names a tenant that is not the actor's just as
|
|
169
|
+
* plainly as `where('orgId', 'eq', theirs)` does, and a plan carrying both predicates is answered
|
|
170
|
+
* by the narrower of the two — so checking "one of them matches" would pass a plan whose rows come
|
|
171
|
+
* from a set the actor never proved they own.
|
|
172
|
+
*/
|
|
173
|
+
const verifyScope = (
|
|
174
|
+
entityName: string,
|
|
175
|
+
tenantColumn: string,
|
|
176
|
+
operation: string,
|
|
177
|
+
plan: QueryPlan,
|
|
178
|
+
actorOrg: string | undefined,
|
|
179
|
+
): void => {
|
|
180
|
+
const named = plan.where.filter((predicate) => predicate.column === tenantColumn);
|
|
181
|
+
// `actorOrg` goes in so the refusal states which of the two situations this is: `scopedPlan`
|
|
182
|
+
// never reaches here with an actor (it derives first), but `assertScoped` verifies plans it did
|
|
183
|
+
// not build, and telling that caller "no actor carried a tenant" would be false.
|
|
184
|
+
if (named.length === 0) throw tenancyUnscoped(entityName, operation, actorOrg);
|
|
185
|
+
if (actorOrg === undefined) return;
|
|
186
|
+
for (const predicate of named) {
|
|
187
|
+
if (predicate.op !== 'eq' || predicate.value !== actorOrg) {
|
|
188
|
+
throw tenancyActorMismatch({ entityName, operation, named: predicate.value, actorOrg });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The plan a tenant-scoped operation actually runs, with the actor's tenant applied. Called by
|
|
195
|
+
* every repository operation through `readPlan`, so both drivers and every read, write and count
|
|
196
|
+
* pass through this one derivation.
|
|
197
|
+
*
|
|
198
|
+
* Runtime only. There is no build-time tenancy step in `x verify` — its 17 steps check none — and
|
|
199
|
+
* there cannot usefully be one: the tenant is a request-time value, so a compiler could only prove
|
|
200
|
+
* that some argument was passed, which is exactly the thing that was never a guarantee. That is
|
|
201
|
+
* why this is the seam every plan is built through rather than a lint.
|
|
202
|
+
*/
|
|
203
|
+
export const scopedPlan = (
|
|
204
|
+
entityName: string,
|
|
205
|
+
tenantColumn: string | null,
|
|
206
|
+
operation: string,
|
|
207
|
+
plan: QueryPlan,
|
|
208
|
+
): QueryPlan => {
|
|
209
|
+
if (tenantColumn === null) return plan;
|
|
210
|
+
const crossing = crossTenantReason();
|
|
211
|
+
// Re-proved per plan, not trusted from the scope's own entry: `withChildContext({ actor })`
|
|
212
|
+
// swaps the actor without closing the scope.
|
|
213
|
+
if (crossing !== undefined) {
|
|
214
|
+
assertCrossTenant(crossing);
|
|
215
|
+
return plan;
|
|
216
|
+
}
|
|
217
|
+
const actorOrg = actorTenant(entityName, operation);
|
|
218
|
+
// Derived only when the caller named nothing: a predicate that is already there is checked
|
|
219
|
+
// rather than joined by a second one, so a disagreement is refused instead of being answered by
|
|
220
|
+
// whichever of the two the driver applies first.
|
|
221
|
+
const scoped =
|
|
222
|
+
actorOrg !== undefined && !hasOrgPredicate(plan, tenantColumn)
|
|
223
|
+
? orgScoped(plan, actorOrg, tenantColumn)
|
|
224
|
+
: plan;
|
|
225
|
+
verifyScope(entityName, tenantColumn, operation, scoped, actorOrg);
|
|
226
|
+
return scoped;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The same guard, verifying a plan that is already built — for a caller holding one this layer did
|
|
231
|
+
* not construct. It cannot derive (there is nowhere to put the predicate), so a plan that names no
|
|
232
|
+
* tenant is `X_TENANCY_UNSCOPED` even where the actor carries one; `scopedPlan` is the path that
|
|
233
|
+
* fills it in.
|
|
108
234
|
*/
|
|
109
235
|
export const assertScoped = (
|
|
110
236
|
entityName: string,
|
|
@@ -113,8 +239,64 @@ export const assertScoped = (
|
|
|
113
239
|
plan: QueryPlan,
|
|
114
240
|
): void => {
|
|
115
241
|
if (tenantColumn === null) return;
|
|
116
|
-
|
|
117
|
-
|
|
242
|
+
const crossing = crossTenantReason();
|
|
243
|
+
if (crossing !== undefined) {
|
|
244
|
+
assertCrossTenant(crossing);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
verifyScope(entityName, tenantColumn, operation, plan, actorTenant(entityName, operation));
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The tenant a row or a patch names, or `undefined` for one that names none. `undefined` is read
|
|
252
|
+
* as "not named" rather than as a value: it is what `namedColumns` already drops from a filter,
|
|
253
|
+
* and a row whose tenant column is genuinely missing is refused one step later by the column's own
|
|
254
|
+
* `NOT NULL` — by the declaration, which is where that rule belongs.
|
|
255
|
+
*/
|
|
256
|
+
const tenantValueOf = (values: unknown, column: string): unknown => {
|
|
257
|
+
if (typeof values !== 'object' || values === null || !Object.hasOwn(values, column)) {
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
return (values as Record<string, unknown>)[column];
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The write half of the guard: a row or a patch may name the acting actor's tenant, or none, and
|
|
265
|
+
* nothing else. Called wherever a driver is about to write values — `insert`, `insertAll`,
|
|
266
|
+
* `upsertAll`, `update`, `updateWhere` — because those build no read plan, so `scopedPlan` never
|
|
267
|
+
* sees them and an `orgId` in a row literal would otherwise be a tenant the caller chose.
|
|
268
|
+
*
|
|
269
|
+
* **Refuse, never stamp.** A row that names no tenant is left alone rather than filled in from the
|
|
270
|
+
* actor. Stamping is the ergonomic half and it is deliberately not here: `namedProperties` decides
|
|
271
|
+
* an `upsertAll`'s column list by `Object.hasOwn`, so a stamped column would change which columns
|
|
272
|
+
* the statement writes, silence the uneven-batch refusal that exists because `excluded.<col>` is a
|
|
273
|
+
* default and not "leave it alone", and — where the conflict target includes the tenant column —
|
|
274
|
+
* let ambient state decide which stored row a collision lands on. A write that creates data from
|
|
275
|
+
* the ambient context is a bigger decision than this guard, and it is not needed for the security
|
|
276
|
+
* property: a wrong tenant is refused either way.
|
|
277
|
+
*/
|
|
278
|
+
export const assertRowTenant = (
|
|
279
|
+
entityName: string,
|
|
280
|
+
tenantColumn: string | null,
|
|
281
|
+
operation: string,
|
|
282
|
+
values: unknown,
|
|
283
|
+
): void => {
|
|
284
|
+
if (tenantColumn === null) return;
|
|
285
|
+
// Before the "names no tenant" shortcut, exactly as `scopedPlan` proves it before deriving: an
|
|
286
|
+
// insert builds no plan, so this is the only place a write inside somebody else's sweep re-proves
|
|
287
|
+
// the capability, and a row that names nothing is still a row written under that scope.
|
|
288
|
+
const crossing = crossTenantReason();
|
|
289
|
+
if (crossing !== undefined) {
|
|
290
|
+
assertCrossTenant(crossing);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const named = tenantValueOf(values, tenantColumn);
|
|
294
|
+
if (named === undefined) return;
|
|
295
|
+
const actorOrg = actorTenant(entityName, operation);
|
|
296
|
+
// No request context: no actor to check the value against, and the same fallback the read path
|
|
297
|
+
// takes — a script, a seed or a migration writes the tenant it names.
|
|
298
|
+
if (actorOrg === undefined || named === actorOrg) return;
|
|
299
|
+
throw tenancyRowMismatch({ entityName, operation, column: tenantColumn, named, actorOrg });
|
|
118
300
|
};
|
|
119
301
|
|
|
120
302
|
/** Debug and `x db explain` rendering. Values stay out: a plan is safe to log. */
|
package/src/type-pins.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// Compile-time pins for the two type positions this package has already regressed in. Source, not
|
|
2
|
+
// a `.test.ts`, on purpose: `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b` never reads a
|
|
3
|
+
// test file and a type-level assertion written there can never fail. Everything below is erased —
|
|
4
|
+
// the module emits nothing — and a regression is a build error, which is the only kind of
|
|
5
|
+
// enforcement this repo counts (axiom 3).
|
|
6
|
+
|
|
7
|
+
import type { Id } from '@ultimat3/core';
|
|
8
|
+
import type { MoneyValue as SchemaMoneyValue } from '@ultimat3/schema';
|
|
9
|
+
import type { uuid } from './columns';
|
|
10
|
+
import type { EntitySet } from './database';
|
|
11
|
+
import type { Entity, EntityCore, EntityInit } from './entity';
|
|
12
|
+
import type { ColumnExpr, InvariantColumns } from './expr';
|
|
13
|
+
import type { Invariant, InvariantDef } from './invariants';
|
|
14
|
+
import type { Table } from './query';
|
|
15
|
+
import type { Repo } from './repo';
|
|
16
|
+
import type { AnyColumn, IdOf, Insertable, MoneyInput, MoneyValue, RowOf } from './types';
|
|
17
|
+
|
|
18
|
+
/** Fails to compile when `T` is anything but `true`. The whole mechanism. */
|
|
19
|
+
type Assert<T extends true> = T;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Shaped like a declared column set; only its keys and its derived row take part. A type alias,
|
|
23
|
+
* not an interface: only an alias gets the implicit index signature `ColumnMap` asks for.
|
|
24
|
+
*/
|
|
25
|
+
type PinColumns = {
|
|
26
|
+
readonly title: AnyColumn;
|
|
27
|
+
readonly price: AnyColumn;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type PinRow = RowOf<PinColumns>;
|
|
31
|
+
|
|
32
|
+
/** Ambient: a type query needs a value to name, and an ambient declaration emits nothing. */
|
|
33
|
+
declare const pinned: InvariantColumns<PinColumns>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The defect: `InvariantColumns` was `{ readonly [column: string]: ColumnExpr }`, so under
|
|
37
|
+
* `noUncheckedIndexedAccess` every `c.title` was `ColumnExpr | undefined` and every generated
|
|
38
|
+
* entity needed a `!`. Written as a property access rather than an indexed-access type, because
|
|
39
|
+
* that is the position the flag widens.
|
|
40
|
+
*/
|
|
41
|
+
export type PinColumnIsNotOptional = Assert<undefined extends typeof pinned.title ? false : true>;
|
|
42
|
+
|
|
43
|
+
export type PinColumnIsAColumnExpr = Assert<
|
|
44
|
+
[typeof pinned.title] extends [ColumnExpr] ? true : false
|
|
45
|
+
>;
|
|
46
|
+
|
|
47
|
+
/** An index signature would make every string a key, so a typo would type-check. */
|
|
48
|
+
export type PinUnknownColumnIsNotAKey = Assert<
|
|
49
|
+
'titel' extends keyof InvariantColumns<PinColumns> ? false : true
|
|
50
|
+
>;
|
|
51
|
+
|
|
52
|
+
/** `unique()` and `satisfies()` name columns as strings, so they need the same protection. */
|
|
53
|
+
export type PinHelpersTakeDeclaredColumns = Assert<
|
|
54
|
+
readonly 'titel'[] extends Parameters<InvariantColumns<PinColumns>['unique']>[0] ? false : true
|
|
55
|
+
>;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `invariants` is one callback over the whole list, never an array of `(c) => …` builders: a
|
|
59
|
+
* per-element builder is a call TypeScript checks before `C` is fixed, so `C` fell back to its
|
|
60
|
+
* constraint and the mapped type above never reached the author.
|
|
61
|
+
*/
|
|
62
|
+
export type PinInvariantsIsOneCallback = Assert<
|
|
63
|
+
EntityInit<PinColumns>['invariants'] extends
|
|
64
|
+
| ((columns: InvariantColumns<PinColumns>) => readonly InvariantDef[])
|
|
65
|
+
| undefined
|
|
66
|
+
? true
|
|
67
|
+
: false
|
|
68
|
+
>;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `Invariant<T>.holds` is a method, not a `readonly holds: (row: T) => boolean` property. A
|
|
72
|
+
* function-typed property is checked contravariantly, which made `Invariant<PinRow>` unassignable
|
|
73
|
+
* to `Invariant<unknown>`, `Entity<PinRow, C>` unassignable to `EntityCore`, and so every
|
|
74
|
+
* `database({ … })` call degrade to `Table<unknown>` — one position, 36 cascading errors.
|
|
75
|
+
*/
|
|
76
|
+
export type PinInvariantIsBivariant = Assert<
|
|
77
|
+
[Invariant<PinRow>] extends [Invariant<unknown>] ? true : false
|
|
78
|
+
>;
|
|
79
|
+
|
|
80
|
+
export type PinEntityIsAnEntityCore = Assert<
|
|
81
|
+
[Entity<PinRow, PinColumns>] extends [EntityCore] ? true : false
|
|
82
|
+
>;
|
|
83
|
+
|
|
84
|
+
export type PinEntityMapIsAnEntitySet = Assert<
|
|
85
|
+
[{ readonly post: Entity<PinRow, PinColumns> }] extends [EntitySet] ? true : false
|
|
86
|
+
>;
|
|
87
|
+
|
|
88
|
+
// --- Insertable: a nullable column is omissible ------------------------------
|
|
89
|
+
// `nullable()` widens the type to `T | null` without setting `$optional`, so before this pin every
|
|
90
|
+
// insert had to spell out `avatarKey: null, deletedAt: null` — restating an absence the column
|
|
91
|
+
// declaration already carries, for values SQL was going to write as NULL either way. The demo's
|
|
92
|
+
// seed could not compile without that padding, which is precisely the boilerplate this framework
|
|
93
|
+
// exists to delete.
|
|
94
|
+
|
|
95
|
+
declare const nullableColumn: import('./types').Column<string | null, false>;
|
|
96
|
+
declare const requiredColumn: import('./types').Column<string, false>;
|
|
97
|
+
|
|
98
|
+
type InsertPinColumns = {
|
|
99
|
+
readonly required: typeof requiredColumn;
|
|
100
|
+
readonly optionalByNull: typeof nullableColumn;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
type InsertPin = import('./types').Insertable<InsertPinColumns>;
|
|
104
|
+
|
|
105
|
+
/** The nullable column may be omitted entirely… */
|
|
106
|
+
type _NullableIsOmissible = Assert<{ required: 'x' } extends InsertPin ? true : false>;
|
|
107
|
+
|
|
108
|
+
/** …and passing `null` explicitly stays legal, so a programmatic caller need not strip keys. */
|
|
109
|
+
type _NullableStillAccepted = Assert<
|
|
110
|
+
{ required: 'x'; optionalByNull: null } extends InsertPin ? true : false
|
|
111
|
+
>;
|
|
112
|
+
|
|
113
|
+
/** A non-nullable column with no default is still required — the pin must not over-relax. */
|
|
114
|
+
type _RequiredStaysRequired = Assert<{ optionalByNull: null } extends InsertPin ? false : true>;
|
|
115
|
+
|
|
116
|
+
// --- The bulk write path keeps every narrowing the single-row one has ---------
|
|
117
|
+
// `insertAll`/`upsertAll` are `insert` in bulk, and a bulk signature is exactly where one quietly
|
|
118
|
+
// widens: `readonly Row[]` instead of `readonly Insertable<C>[]` would make every batch spell out
|
|
119
|
+
// the defaults `insert` fills for one row, and a `readonly string[]` conflict target would push a
|
|
120
|
+
// typo the single-row path never had to the runtime guard.
|
|
121
|
+
|
|
122
|
+
type InsertPinTable = Table<RowOf<InsertPinColumns>, InsertPinColumns>;
|
|
123
|
+
type ConflictTarget = Parameters<InsertPinTable['upsertAll']>[1]['onConflict'];
|
|
124
|
+
|
|
125
|
+
/** A batch is `Insertable`, so a nullable column stays omissible a hundred rows at a time. */
|
|
126
|
+
type _InsertAllTakesInsertables = Assert<
|
|
127
|
+
readonly { required: 'x' }[] extends Parameters<InsertPinTable['insertAll']>[0] ? true : false
|
|
128
|
+
>;
|
|
129
|
+
|
|
130
|
+
/** What comes back is the stored row, never the insertable the caller handed in. */
|
|
131
|
+
type _InsertAllResolvesWithRows = Assert<
|
|
132
|
+
[Awaited<ReturnType<InsertPinTable['insertAll']>>] extends [readonly RowOf<InsertPinColumns>[]]
|
|
133
|
+
? true
|
|
134
|
+
: false
|
|
135
|
+
>;
|
|
136
|
+
|
|
137
|
+
type _ConflictTargetTakesADeclaredColumn = Assert<
|
|
138
|
+
readonly 'required'[] extends ConflictTarget ? true : false
|
|
139
|
+
>;
|
|
140
|
+
|
|
141
|
+
/** The narrowing that matters: a misspelled conflict target is a compile error, not a rejection. */
|
|
142
|
+
type _ConflictTargetRejectsATypo = Assert<
|
|
143
|
+
readonly 'requiredd'[] extends ConflictTarget ? false : true
|
|
144
|
+
>;
|
|
145
|
+
|
|
146
|
+
// --- A branded id survives the whole type chain ------------------------------
|
|
147
|
+
// `uuid<PostId>()` is where the brand is declared, and every hop after it has to carry it. The
|
|
148
|
+
// derivation (`TypeOf`, `RowOf`, `Insertable`) always did; the BUILDER hard-coded `Column<string>`
|
|
149
|
+
// so there was nothing to carry, and `Repo`/`Table` then took `id: string`, which is where the
|
|
150
|
+
// last of it went. Both halves are pinned, because fixing either one alone still lets
|
|
151
|
+
// `posts.update(someUserId, …)` compile.
|
|
152
|
+
|
|
153
|
+
type PostId = Id<'post'>;
|
|
154
|
+
type UserId = Id<'user'>;
|
|
155
|
+
|
|
156
|
+
/** The builder's own output, not a hand-written `Column<PostId, true>`. */
|
|
157
|
+
type BrandedKey = ReturnType<ReturnType<typeof uuid<PostId>>['primaryKey']>;
|
|
158
|
+
|
|
159
|
+
type BrandColumns = {
|
|
160
|
+
readonly id: BrandedKey;
|
|
161
|
+
readonly authorId: ReturnType<typeof uuid<UserId>>;
|
|
162
|
+
readonly title: typeof requiredColumn;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
type BrandRow = RowOf<BrandColumns>;
|
|
166
|
+
|
|
167
|
+
type _BrandSurvivesTheRow = Assert<[BrandRow['id']] extends [PostId] ? true : false>;
|
|
168
|
+
|
|
169
|
+
/** The half that matters: a plain string is no longer good enough to be a post id. */
|
|
170
|
+
type _RowIdIsNotAPlainString = Assert<[string] extends [BrandRow['id']] ? false : true>;
|
|
171
|
+
|
|
172
|
+
/** Two entities' ids do not mix, which is the whole reason to declare one. */
|
|
173
|
+
type _BrandsDoNotMix = Assert<[BrandRow['authorId']] extends [PostId] ? false : true>;
|
|
174
|
+
|
|
175
|
+
/** The write path too — an insert that names the wrong entity's id is a compile error. */
|
|
176
|
+
type _BrandSurvivesTheInsert = Assert<
|
|
177
|
+
[Insertable<BrandColumns>['authorId']] extends [UserId] ? true : false
|
|
178
|
+
>;
|
|
179
|
+
|
|
180
|
+
type _InsertRejectsAnotherBrand = Assert<
|
|
181
|
+
[PostId] extends [Insertable<BrandColumns>['authorId']] ? false : true
|
|
182
|
+
>;
|
|
183
|
+
|
|
184
|
+
/** `Repo` was the last hop that erased it: `findById(id: string)` accepted any entity's id. */
|
|
185
|
+
type _FindByIdTakesTheBrand = Assert<
|
|
186
|
+
[Parameters<Repo<BrandRow>['findById']>[0]] extends [PostId] ? true : false
|
|
187
|
+
>;
|
|
188
|
+
|
|
189
|
+
type _FindByIdRejectsAnotherBrand = Assert<
|
|
190
|
+
[UserId] extends [Parameters<Repo<BrandRow>['findById']>[0]] ? false : true
|
|
191
|
+
>;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* `UpsertArgs<T>.onConflict` is `readonly (keyof T & string)[]`, which is `readonly never[]` at
|
|
195
|
+
* `T = unknown` — so a typed repository must still satisfy the row-agnostic one `RelatedTable.repo`
|
|
196
|
+
* and the generated admin are written against, or narrowing the target breaks the preload seam.
|
|
197
|
+
*/
|
|
198
|
+
type _TypedRepoIsARowAgnosticRepo = Assert<[Repo<BrandRow>] extends [Repo<unknown>] ? true : false>;
|
|
199
|
+
|
|
200
|
+
type _TableUpdateTakesTheBrand = Assert<
|
|
201
|
+
[Parameters<Table<BrandRow>['update']>[0]] extends [PostId] ? true : false
|
|
202
|
+
>;
|
|
203
|
+
|
|
204
|
+
type _TableDeleteRejectsAnotherBrand = Assert<
|
|
205
|
+
[UserId] extends [Parameters<Table<BrandRow>['delete']>[0]] ? false : true
|
|
206
|
+
>;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* …and an unbranded entity is addressed exactly as it was. `IdOf` collapsing to `string` for
|
|
210
|
+
* every row that declared no brand is what makes this additive rather than a major version.
|
|
211
|
+
*/
|
|
212
|
+
type _UnbrandedIdStaysAString = Assert<
|
|
213
|
+
[string] extends [IdOf<{ readonly id: string }>] ? true : false
|
|
214
|
+
>;
|
|
215
|
+
|
|
216
|
+
// --- The batch iteration stays consumable both ways --------------------------
|
|
217
|
+
// `inBatches()` is the one read that hands back a resource instead of a value, and both ways of
|
|
218
|
+
// consuming it are language features rather than methods a call site would obviously miss:
|
|
219
|
+
// `for await` needs `[Symbol.asyncIterator]`, `await using` needs `[Symbol.asyncDispose]`. Losing
|
|
220
|
+
// either is a silent regression — every existing call keeps compiling, and only the loop that was
|
|
221
|
+
// supposed to stop reading stops stopping.
|
|
222
|
+
|
|
223
|
+
type BatchPin = ReturnType<Table<BrandRow>['inBatches']>;
|
|
224
|
+
|
|
225
|
+
type _BatchIterates = Assert<
|
|
226
|
+
[BatchPin] extends [AsyncIterable<readonly BrandRow[]>] ? true : false
|
|
227
|
+
>;
|
|
228
|
+
|
|
229
|
+
type _BatchDisposes = Assert<[BatchPin] extends [AsyncDisposable] ? true : false>;
|
|
230
|
+
|
|
231
|
+
/** Batches, never rows: yielding one row at a time is the loop this call exists to replace. */
|
|
232
|
+
type _BatchYieldsBatches = Assert<[BatchPin] extends [AsyncIterable<BrandRow>] ? false : true>;
|
|
233
|
+
|
|
234
|
+
type _RowAgnosticIdStaysAString = Assert<[string] extends [IdOf<unknown>] ? true : false>;
|
|
235
|
+
|
|
236
|
+
// --- Money is one declaration, and the wide half is the write half -----------
|
|
237
|
+
// `MoneyValue` was a third structural restatement of `Money` whose `minor` was a `bigint`, so a
|
|
238
|
+
// row this package decoded satisfied neither `t.money` nor `JSON.stringify` — the shape the whole
|
|
239
|
+
// framework passes around was not the shape its own driver produced. It is now an alias of
|
|
240
|
+
// `@ultimat3/schema`'s declaration, which is also what `@ultimat3/money`'s `Money` is; these pins
|
|
241
|
+
// are what stops the next edit from re-declaring it here and re-opening the same gap.
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Identity, not mutual assignability: `extends` ignores `readonly`, so the weaker test would pass
|
|
245
|
+
* against a mutable restatement — which is exactly the drift being pinned against.
|
|
246
|
+
*/
|
|
247
|
+
type Identical<X, Y> =
|
|
248
|
+
(<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false;
|
|
249
|
+
|
|
250
|
+
type _MoneyValueIsSchemasDeclaration = Assert<Identical<MoneyValue, SchemaMoneyValue>>;
|
|
251
|
+
|
|
252
|
+
/** The value type is a `number`. A `bigint` here is the regression, not a widening. */
|
|
253
|
+
type _MoneyMinorIsANumber = Assert<[MoneyValue['minor']] extends [number] ? true : false>;
|
|
254
|
+
|
|
255
|
+
// The shape is pinned as independent properties rather than as one literal snapshot of the whole
|
|
256
|
+
// interface. The snapshot said the same thing, but every additive change had to be hand-edited
|
|
257
|
+
// past it — and a pin the next reader learns to hand-edit reflexively has stopped being a check.
|
|
258
|
+
// Only the key set moves when a field is added, which is the one place that decision belongs.
|
|
259
|
+
|
|
260
|
+
/** No field but these three, ever: a fourth is a shape nobody declared. */
|
|
261
|
+
type _MoneyHasNoOtherField = Assert<
|
|
262
|
+
[keyof MoneyValue] extends ['minor' | 'currency' | 'scale'] ? true : false
|
|
263
|
+
>;
|
|
264
|
+
|
|
265
|
+
/** …and none of the three may go — the pin must not pass by the type shrinking instead. */
|
|
266
|
+
type _MoneyHasEveryField = Assert<
|
|
267
|
+
['minor' | 'currency' | 'scale'] extends [keyof MoneyValue] ? true : false
|
|
268
|
+
>;
|
|
269
|
+
|
|
270
|
+
// Immutable, enforced, field by field: a mutable `minor` is a rounding bug with a place to hide.
|
|
271
|
+
// `Pick` carries `readonly` and optionality through, so each of these is exact about one field
|
|
272
|
+
// and says nothing about the others.
|
|
273
|
+
|
|
274
|
+
type _MoneyMinorIsAReadonlyNumber = Assert<
|
|
275
|
+
Identical<Pick<MoneyValue, 'minor'>, { readonly minor: number }>
|
|
276
|
+
>;
|
|
277
|
+
|
|
278
|
+
type _MoneyCurrencyIsAReadonlyString = Assert<
|
|
279
|
+
Identical<Pick<MoneyValue, 'currency'>, { readonly currency: string }>
|
|
280
|
+
>;
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* `scale` is the decimal exponent `minor` counts in — `{ minor: 2, currency: 'USD', scale: 6 }` is
|
|
284
|
+
* $0.000002. Optional, and pinned optional, because a cents-only `Money` could not name a
|
|
285
|
+
* sub-cent amount at all: the AI cost path rounded a $0.00016 call up to a whole cent, 62x, and
|
|
286
|
+
* the alternative to this field was a second money type.
|
|
287
|
+
*/
|
|
288
|
+
type _MoneyScaleIsAReadonlyOptionalNumber = Assert<
|
|
289
|
+
Identical<Pick<MoneyValue, 'scale'>, { readonly scale?: number }>
|
|
290
|
+
>;
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The additive half, and the pin that decides the semver: a value carrying no scale is still a
|
|
294
|
+
* `MoneyValue`, meaning the currency's own minor unit. Every amount already stored, serialized
|
|
295
|
+
* and asserted against in every app is that shape — so the day this fails, the change that made
|
|
296
|
+
* it fail is a breaking one and needs a major, not a fix here.
|
|
297
|
+
*/
|
|
298
|
+
type _MoneyWithoutAScaleIsStillMoney = Assert<
|
|
299
|
+
{ readonly minor: number; readonly currency: string } extends MoneyValue ? true : false
|
|
300
|
+
>;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* A writer may still hand a `bigint` — that is the additive half, and it is what lets a minor unit
|
|
304
|
+
* read straight off a `bigint` column reach an insert without a conversion at the call site.
|
|
305
|
+
*/
|
|
306
|
+
type _MoneyInputTakesABigInt = Assert<
|
|
307
|
+
[{ readonly minor: bigint; readonly currency: string }] extends [MoneyInput] ? true : false
|
|
308
|
+
>;
|
|
309
|
+
|
|
310
|
+
/** And a row value is always a legal input: read a row, write it back. */
|
|
311
|
+
type _MoneyValueIsMoneyInput = Assert<[MoneyValue] extends [MoneyInput] ? true : false>;
|
package/src/types.ts
CHANGED
|
@@ -28,16 +28,35 @@ export interface ReferenceOptions {
|
|
|
28
28
|
readonly onDelete?: OnDelete;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The single value a money column puts on the row. Two physical columns back it.
|
|
33
|
+
*
|
|
34
|
+
* An **alias** of `@ultimat3/schema`'s declaration, which is also what `@ultimat3/money`'s `Money`
|
|
35
|
+
* is — so a row this package decodes IS a `Money`, assignable to `add()`, `formatMoney()` and
|
|
36
|
+
* `<Money>` without a cast. It used to be a third, structurally different interface whose `minor`
|
|
37
|
+
* was a `bigint`, and that was a live defect rather than a stylistic one: `JSON.stringify` throws
|
|
38
|
+
* on a bigint, so returning a row with a money column from an action crashed the response, and
|
|
39
|
+
* `t.money` — the schema node that becomes the OpenAPI contract — rejected the framework's own row.
|
|
40
|
+
*/
|
|
41
|
+
import type { MoneyValue } from '@ultimat3/schema';
|
|
36
42
|
|
|
37
|
-
|
|
43
|
+
export type { MoneyValue };
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What a writer may hand a money column. An integer `number` is the value type; a `bigint` is
|
|
47
|
+
* accepted so a minor unit read straight off a `bigint` column (hand-written SQL, a backfill)
|
|
48
|
+
* needs no conversion at the call site. A float throws, and so does a `bigint` past
|
|
49
|
+
* `Number.MAX_SAFE_INTEGER` — see `parseMinor` in `columns.ts`.
|
|
50
|
+
*/
|
|
38
51
|
export interface MoneyInput {
|
|
39
52
|
readonly minor: bigint | number;
|
|
40
53
|
readonly currency: string;
|
|
54
|
+
/**
|
|
55
|
+
* Carried through the write, never invented: `undefined` means the currency's own minor unit and
|
|
56
|
+
* `0` means whole units, and the two must not collapse. `null` is accepted because that is what
|
|
57
|
+
* the `<name>_scale` column holds for an amount that declared none.
|
|
58
|
+
*/
|
|
59
|
+
readonly scale?: number | null;
|
|
41
60
|
}
|
|
42
61
|
|
|
43
62
|
/**
|
|
@@ -82,9 +101,16 @@ export interface Column<T, Optional extends boolean = false> {
|
|
|
82
101
|
default(value: T): Column<T, true>;
|
|
83
102
|
}
|
|
84
103
|
|
|
85
|
-
/**
|
|
86
|
-
|
|
87
|
-
|
|
104
|
+
/**
|
|
105
|
+
* A uuid primary key is generated (v7) when omitted, which is why it narrows to `true`.
|
|
106
|
+
*
|
|
107
|
+
* `T` is the declared id type: `uuid<PostId>()` carries the brand from here to the row, the
|
|
108
|
+
* insert and every repository signature, so a `PostId` cannot be passed where a `UserId` is
|
|
109
|
+
* wanted. It defaults to `string`, so an unbranded declaration reads exactly as it did.
|
|
110
|
+
*/
|
|
111
|
+
export interface UuidColumn<T extends string = string, Optional extends boolean = false>
|
|
112
|
+
extends Column<T, Optional> {
|
|
113
|
+
primaryKey(): Column<T, true>;
|
|
88
114
|
}
|
|
89
115
|
|
|
90
116
|
export interface TimestampColumn<Optional extends boolean = false> extends Column<Date, Optional> {
|
|
@@ -98,6 +124,18 @@ export type ColumnMap = Readonly<Record<string, AnyColumn>>;
|
|
|
98
124
|
|
|
99
125
|
export type TypeOf<C> = C extends Column<infer T, boolean> ? T : never;
|
|
100
126
|
|
|
127
|
+
/**
|
|
128
|
+
* How a row is addressed: the type its own `id` column declared, or `string` when the entity is
|
|
129
|
+
* keyed by something else (a composite key, or an unbranded uuid).
|
|
130
|
+
*
|
|
131
|
+
* This is where a brand used to die. `RowOf` and `Insertable` carry it through the derivation
|
|
132
|
+
* without help, but `Repo.findById(id: string)` and `Table.update(id: string, …)` erased it at
|
|
133
|
+
* the last hop — so `posts.update(someUserId, …)` type-checked and Postgres returned nothing.
|
|
134
|
+
* `IdOf<Row>` collapses to `string` for every unbranded entity, so nothing that compiled before
|
|
135
|
+
* stops compiling.
|
|
136
|
+
*/
|
|
137
|
+
export type IdOf<Row> = Row extends { readonly id: infer I extends string } ? I : string;
|
|
138
|
+
|
|
101
139
|
/** The row type a column set describes. This derivation is why the package exists. */
|
|
102
140
|
export type RowOf<C extends ColumnMap> = {
|
|
103
141
|
readonly [K in keyof C]: TypeOf<C[K]>;
|
|
@@ -107,14 +145,28 @@ type DefaultedKeys<C extends ColumnMap> = {
|
|
|
107
145
|
[K in keyof C]-?: C[K]['$optional'] extends true ? K : never;
|
|
108
146
|
}[keyof C];
|
|
109
147
|
|
|
148
|
+
/**
|
|
149
|
+
* A `.nullable()` column is omissible too, and that is not a convenience — it is the difference
|
|
150
|
+
* between declaring a fact and restating an absence. `nullable()` widens the type to `T | null`
|
|
151
|
+
* without setting `$optional`, so every insert had to spell out `avatarKey: null, deletedAt: null`
|
|
152
|
+
* for columns whose whole meaning is "there may be nothing here", and SQL was going to write NULL
|
|
153
|
+
* either way. That is boilerplate the declaration already contains.
|
|
154
|
+
*
|
|
155
|
+
* Omitting it and passing `null` stay equivalent, deliberately: a caller building a row
|
|
156
|
+
* programmatically should not have to strip keys to avoid a type error.
|
|
157
|
+
*/
|
|
158
|
+
type NullableKeys<C extends ColumnMap> = {
|
|
159
|
+
[K in keyof C]-?: null extends TypeOf<C[K]> ? K : never;
|
|
160
|
+
}[keyof C];
|
|
161
|
+
|
|
110
162
|
/** Money is the one column whose write shape is wider than its row shape. */
|
|
111
163
|
type InputOf<T> = T extends MoneyValue ? MoneyInput : T;
|
|
112
164
|
|
|
113
|
-
/** What an insert must supply: every column
|
|
165
|
+
/** What an insert must supply: every column that is neither defaulted nor nullable. */
|
|
114
166
|
export type Insertable<C extends ColumnMap> = {
|
|
115
|
-
readonly [K in Exclude<keyof C, DefaultedKeys<C>>]: InputOf<TypeOf<C[K]>>;
|
|
167
|
+
readonly [K in Exclude<keyof C, DefaultedKeys<C> | NullableKeys<C>>]: InputOf<TypeOf<C[K]>>;
|
|
116
168
|
} & {
|
|
117
|
-
readonly [K in DefaultedKeys<C>]?: InputOf<TypeOf<C[K]>>;
|
|
169
|
+
readonly [K in DefaultedKeys<C> | NullableKeys<C>]?: InputOf<TypeOf<C[K]>>;
|
|
118
170
|
};
|
|
119
171
|
|
|
120
172
|
export interface IndexDef {
|