@ultimat3/entity 1.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -6,7 +6,15 @@
6
6
  // A column carries its TypeScript type in `$parse`, which is what lets the row type be derived
7
7
  // from the column set instead of being written a second time as a hand-maintained schema.
8
8
 
9
- /** Postgres types the blessed builders emit. `money` expands to `bigint` + `char(3)`. */
9
+ /**
10
+ * Postgres types the builders emit. `money` expands to `bigint` + `char(3)` (+ a nullable
11
+ * `integer` scale); `array` expands to its element's type with `[]` after it.
12
+ *
13
+ * The four beyond the blessed set exist for ONE reason: a schema Ultimate did not generate already
14
+ * has them. A table with a `numeric(18,8)` rate, a `date` a rate takes effect on, a `jsonb` payload
15
+ * and a `bytea` blob cannot be declared at all without them, and an entity that cannot be declared
16
+ * is a rewrite instead of an adoption.
17
+ */
10
18
  export type ColumnKind =
11
19
  | 'uuid'
12
20
  | 'text'
@@ -14,8 +22,12 @@ export type ColumnKind =
14
22
  | 'boolean'
15
23
  | 'integer'
16
24
  | 'bigint'
25
+ | 'numeric'
17
26
  | 'timestamptz'
27
+ | 'date'
18
28
  | 'jsonb'
29
+ | 'bytea'
30
+ | 'array'
19
31
  | 'money';
20
32
 
21
33
  export type ColumnDefault =
@@ -28,16 +40,51 @@ export interface ReferenceOptions {
28
40
  readonly onDelete?: OnDelete;
29
41
  }
30
42
 
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;
43
+ /**
44
+ * The single value a money column puts on the row. Two physical columns back it.
45
+ *
46
+ * An **alias** of `@ultimat3/schema`'s declaration, which is also what `@ultimat3/money`'s `Money`
47
+ * is — so a row this package decodes IS a `Money`, assignable to `add()`, `formatMoney()` and
48
+ * `<Money>` without a cast. It used to be a third, structurally different interface whose `minor`
49
+ * was a `bigint`, and that was a live defect rather than a stylistic one: `JSON.stringify` throws
50
+ * on a bigint, so returning a row with a money column from an action crashed the response, and
51
+ * `t.money` — the schema node that becomes the OpenAPI contract — rejected the framework's own row.
52
+ */
53
+ import type { MoneyValue } from '@ultimat3/schema';
54
+
55
+ export type { MoneyValue };
56
+
57
+ /**
58
+ * What a writer may hand a money column. An integer `number` is the value type; a `bigint` is
59
+ * accepted so a minor unit read straight off a `bigint` column (hand-written SQL, a backfill)
60
+ * needs no conversion at the call site. A float throws, and so does a `bigint` past
61
+ * `Number.MAX_SAFE_INTEGER` — see `parseMinor` in `columns.ts`.
62
+ */
63
+ /**
64
+ * The physical columns behind a money property, when they are not `<name>_minor`,
65
+ * `<name>_currency` and `<name>_scale`. Named per part and merged over those defaults, so a table
66
+ * that renamed one column does not have to restate the other two.
67
+ */
68
+ export interface MoneyColumnNames {
69
+ readonly minor?: string;
70
+ readonly currency?: string;
71
+ /**
72
+ * `null` says this table has NO scale column — the ordinary shape of a money column written
73
+ * before scale existed. Every amount in it is then at the currency's own minor unit, which is
74
+ * what an absent scale already means, so nothing is lost but the ability to store a sub-cent one.
75
+ */
76
+ readonly scale?: string | null;
35
77
  }
36
78
 
37
- /** What a writer may hand a money column. An integer `number` widens; a float throws. */
38
79
  export interface MoneyInput {
39
80
  readonly minor: bigint | number;
40
81
  readonly currency: string;
82
+ /**
83
+ * Carried through the write, never invented: `undefined` means the currency's own minor unit and
84
+ * `0` means whole units, and the two must not collapse. `null` is accepted because that is what
85
+ * the `<name>_scale` column holds for an amount that declared none.
86
+ */
87
+ readonly scale?: number | null;
41
88
  }
42
89
 
43
90
  /**
@@ -52,7 +99,20 @@ export interface ColumnMeta {
52
99
  readonly index: boolean;
53
100
  /** Presence of a tenant column is what turns tenancy on. See `tenancy.ts`. */
54
101
  readonly tenant: boolean;
102
+ /**
103
+ * The physical column, when it is not `snake(property)`. The whole of what makes an existing
104
+ * table addressable: `githubLogin` on a row, `gh_login` in every statement, decided once here
105
+ * and read through `columnName()` by every projection.
106
+ */
107
+ readonly name?: string;
55
108
  readonly length?: number;
109
+ /** `numeric(precision, scale)` — both, or neither. A bare `numeric` is unbounded on purpose. */
110
+ readonly precision?: number;
111
+ readonly numericScale?: number;
112
+ /** The element column of an `array`, which carries the element's own kind and `$parse`. */
113
+ readonly element?: AnyColumn;
114
+ /** Where money's three physical columns live, when the table already named them. */
115
+ readonly parts?: MoneyColumnNames;
56
116
  readonly values?: readonly string[];
57
117
  readonly default?: ColumnDefault;
58
118
  readonly onUpdate?: ColumnDefault;
@@ -80,16 +140,32 @@ export interface Column<T, Optional extends boolean = false> {
80
140
  tenant(): Column<T, Optional>;
81
141
  references(target: () => AnyColumn, options?: ReferenceOptions): Column<T, Optional>;
82
142
  default(value: T): Column<T, true>;
143
+ /**
144
+ * The physical column name, when the table does not spell it `snake(property)`. Name it LAST in
145
+ * a chain: this link returns the general column, so a builder's own methods (`defaultNow()`,
146
+ * a uuid key's narrowed `primaryKey()`) are declared before it — `uuid()` and `timestamp()`
147
+ * override it to keep theirs, and nothing else has any.
148
+ */
149
+ column(name: string): Column<T, Optional>;
83
150
  }
84
151
 
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>;
152
+ /**
153
+ * A uuid primary key is generated (v7) when omitted, which is why it narrows to `true`.
154
+ *
155
+ * `T` is the declared id type: `uuid<PostId>()` carries the brand from here to the row, the
156
+ * insert and every repository signature, so a `PostId` cannot be passed where a `UserId` is
157
+ * wanted. It defaults to `string`, so an unbranded declaration reads exactly as it did.
158
+ */
159
+ export interface UuidColumn<T extends string = string, Optional extends boolean = false>
160
+ extends Column<T, Optional> {
161
+ primaryKey(): Column<T, true>;
162
+ column(name: string): UuidColumn<T, Optional>;
88
163
  }
89
164
 
90
165
  export interface TimestampColumn<Optional extends boolean = false> extends Column<Date, Optional> {
91
166
  defaultNow(): TimestampColumn<true>;
92
167
  onUpdateNow(): TimestampColumn<Optional>;
168
+ column(name: string): TimestampColumn<Optional>;
93
169
  }
94
170
 
95
171
  export type AnyColumn = Column<unknown, boolean>;
@@ -98,6 +174,18 @@ export type ColumnMap = Readonly<Record<string, AnyColumn>>;
98
174
 
99
175
  export type TypeOf<C> = C extends Column<infer T, boolean> ? T : never;
100
176
 
177
+ /**
178
+ * How a row is addressed: the type its own `id` column declared, or `string` when the entity is
179
+ * keyed by something else (a composite key, or an unbranded uuid).
180
+ *
181
+ * This is where a brand used to die. `RowOf` and `Insertable` carry it through the derivation
182
+ * without help, but `Repo.findById(id: string)` and `Table.update(id: string, …)` erased it at
183
+ * the last hop — so `posts.update(someUserId, …)` type-checked and Postgres returned nothing.
184
+ * `IdOf<Row>` collapses to `string` for every unbranded entity, so nothing that compiled before
185
+ * stops compiling.
186
+ */
187
+ export type IdOf<Row> = Row extends { readonly id: infer I extends string } ? I : string;
188
+
101
189
  /** The row type a column set describes. This derivation is why the package exists. */
102
190
  export type RowOf<C extends ColumnMap> = {
103
191
  readonly [K in keyof C]: TypeOf<C[K]>;
@@ -107,14 +195,28 @@ type DefaultedKeys<C extends ColumnMap> = {
107
195
  [K in keyof C]-?: C[K]['$optional'] extends true ? K : never;
108
196
  }[keyof C];
109
197
 
198
+ /**
199
+ * A `.nullable()` column is omissible too, and that is not a convenience — it is the difference
200
+ * between declaring a fact and restating an absence. `nullable()` widens the type to `T | null`
201
+ * without setting `$optional`, so every insert had to spell out `avatarKey: null, deletedAt: null`
202
+ * for columns whose whole meaning is "there may be nothing here", and SQL was going to write NULL
203
+ * either way. That is boilerplate the declaration already contains.
204
+ *
205
+ * Omitting it and passing `null` stay equivalent, deliberately: a caller building a row
206
+ * programmatically should not have to strip keys to avoid a type error.
207
+ */
208
+ type NullableKeys<C extends ColumnMap> = {
209
+ [K in keyof C]-?: null extends TypeOf<C[K]> ? K : never;
210
+ }[keyof C];
211
+
110
212
  /** Money is the one column whose write shape is wider than its row shape. */
111
213
  type InputOf<T> = T extends MoneyValue ? MoneyInput : T;
112
214
 
113
- /** What an insert must supply: every column except the ones carrying a default. */
215
+ /** What an insert must supply: every column that is neither defaulted nor nullable. */
114
216
  export type Insertable<C extends ColumnMap> = {
115
- readonly [K in Exclude<keyof C, DefaultedKeys<C>>]: InputOf<TypeOf<C[K]>>;
217
+ readonly [K in Exclude<keyof C, DefaultedKeys<C> | NullableKeys<C>>]: InputOf<TypeOf<C[K]>>;
116
218
  } & {
117
- readonly [K in DefaultedKeys<C>]?: InputOf<TypeOf<C[K]>>;
219
+ readonly [K in DefaultedKeys<C> | NullableKeys<C>]?: InputOf<TypeOf<C[K]>>;
118
220
  };
119
221
 
120
222
  export interface IndexDef {
package/src/view.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // already describe. Values are validated by the entity's own column parsers; an unknown key is a
4
4
  // declaration-time failure, not a surprise on the first request.
5
5
 
6
- import type { StandardSchemaV1 } from '@ultimat3/schema';
6
+ import { describeValue, type StandardSchemaV1 } from '@ultimat3/schema';
7
7
  import { invariantViolated } from './errors';
8
8
  import type { AnyColumn, ColumnMap } from './types';
9
9
 
@@ -50,7 +50,13 @@ export const viewFor = <Row, K extends keyof Row & string>(
50
50
 
51
51
  const parse = (value: unknown): Pick<Row, K> => {
52
52
  if (typeof value !== 'object' || value === null) {
53
- throw invariantViolated(entityName, 'view', `expected an object, got ${String(value)}`);
53
+ // Shape, never content — the same renderer `columns.ts` uses, for the same reason: a
54
+ // view issue is folded into `X_BODY_INVALID` and reaches the caller and the log line.
55
+ throw invariantViolated(
56
+ entityName,
57
+ 'view',
58
+ `expected an object, got ${describeValue(value)}`,
59
+ );
54
60
  }
55
61
  const input = value as Readonly<Record<string, unknown>>;
56
62
  const projected = {} as Record<K, unknown>;