@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/tenancy.ts CHANGED
@@ -1,8 +1,16 @@
1
- // Multi-tenancy is a guard, not a convention. An entity with a tenant column can only be read
2
- // through a plan that carries an org predicate; building one without it throws
3
- // `X_TENANCY_UNSCOPED` at the seam instead of leaking another tenant's rows.
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 { EntityError, tenancyUnscoped } from './errors';
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 === undefined) return tenantColumnOf(columns);
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
- return declared;
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
- /** Adds the org predicate exactly once; calling it twice is not an error. */
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
- * Called by every repository operation. Runtime here, and a build-time check in `x verify`
107
- * that no query for a tenant-scoped entity is constructed without it.
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
- if (hasOrgPredicate(plan, tenantColumn)) return;
117
- throw tenancyUnscoped(entityName, operation);
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. */
@@ -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
- /** The single value a money column puts on the row. Two physical columns back it. */
32
- export interface MoneyValue {
33
- readonly minor: bigint;
34
- readonly currency: string;
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
- /** What a writer may hand a money column. An integer `number` widens; a float throws. */
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
- /** A uuid primary key is generated (v7) when omitted, which is why it narrows to `true`. */
86
- export interface UuidColumn<Optional extends boolean = false> extends Column<string, Optional> {
87
- primaryKey(): Column<string, true>;
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 except the ones carrying a default. */
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 {