@ultimat3/entity 1.2.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +694 -0
- package/README.md +467 -16
- package/package.json +6 -4
- package/src/batch-read.ts +134 -0
- package/src/batch.ts +125 -0
- package/src/bulk-write.ts +285 -0
- package/src/coalesce.ts +189 -0
- package/src/column.ts +91 -2
- package/src/columns-data.ts +205 -0
- package/src/columns.ts +228 -38
- package/src/count-by.ts +148 -0
- package/src/cross-tenant.ts +76 -0
- package/src/cursor.ts +17 -3
- package/src/database.ts +35 -2
- package/src/describe.ts +121 -39
- package/src/entity.ts +65 -20
- package/src/errors.ts +346 -5
- package/src/expr.ts +65 -15
- package/src/index.ts +72 -7
- package/src/invariants.ts +56 -14
- package/src/jit-preload.ts +216 -0
- package/src/n-plus-one.ts +122 -0
- package/src/pg-driver.ts +282 -35
- package/src/pg-row.ts +87 -16
- package/src/pg-sql.ts +156 -13
- package/src/plan.ts +130 -20
- package/src/preload.ts +184 -0
- package/src/query.ts +231 -27
- package/src/registry.ts +63 -5
- package/src/relations.ts +212 -0
- package/src/repo.ts +226 -12
- package/src/seed.ts +288 -19
- package/src/tenancy.ts +194 -12
- package/src/type-pins.ts +311 -0
- package/src/types.ts +114 -12
- package/src/view.ts +8 -2
package/src/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
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
/**
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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>;
|