@zmdb/schema 1.0.0-beta.1

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.
Files changed (68) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +30 -0
  3. package/dist/custom-types/index.d.ts +41 -0
  4. package/dist/custom-types/index.d.ts.map +1 -0
  5. package/dist/custom-types/index.js +32 -0
  6. package/dist/custom-types/index.js.map +1 -0
  7. package/dist/derive/index.d.ts +122 -0
  8. package/dist/derive/index.d.ts.map +1 -0
  9. package/dist/derive/index.js +13 -0
  10. package/dist/derive/index.js.map +1 -0
  11. package/dist/derive/query.d.ts +62 -0
  12. package/dist/derive/query.d.ts.map +1 -0
  13. package/dist/derive/query.js +18 -0
  14. package/dist/derive/query.js.map +1 -0
  15. package/dist/dto/index.d.ts +224 -0
  16. package/dist/dto/index.d.ts.map +1 -0
  17. package/dist/dto/index.js +118 -0
  18. package/dist/dto/index.js.map +1 -0
  19. package/dist/entity-modeling/index.d.ts +12 -0
  20. package/dist/entity-modeling/index.d.ts.map +1 -0
  21. package/dist/entity-modeling/index.js +28 -0
  22. package/dist/entity-modeling/index.js.map +1 -0
  23. package/dist/index.d.ts +151 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +84 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/ir/index.d.ts +374 -0
  28. package/dist/ir/index.d.ts.map +1 -0
  29. package/dist/ir/index.js +735 -0
  30. package/dist/ir/index.js.map +1 -0
  31. package/dist/ir/validation-shape.d.ts +46 -0
  32. package/dist/ir/validation-shape.d.ts.map +1 -0
  33. package/dist/ir/validation-shape.js +130 -0
  34. package/dist/ir/validation-shape.js.map +1 -0
  35. package/dist/ir/vocabulary.d.ts +54 -0
  36. package/dist/ir/vocabulary.d.ts.map +1 -0
  37. package/dist/ir/vocabulary.js +51 -0
  38. package/dist/ir/vocabulary.js.map +1 -0
  39. package/dist/naming/index.d.ts +26 -0
  40. package/dist/naming/index.d.ts.map +1 -0
  41. package/dist/naming/index.js +147 -0
  42. package/dist/naming/index.js.map +1 -0
  43. package/dist/openapi/index.d.ts +57 -0
  44. package/dist/openapi/index.d.ts.map +1 -0
  45. package/dist/openapi/index.js +98 -0
  46. package/dist/openapi/index.js.map +1 -0
  47. package/dist/relations/index.d.ts +23 -0
  48. package/dist/relations/index.d.ts.map +1 -0
  49. package/dist/relations/index.js +98 -0
  50. package/dist/relations/index.js.map +1 -0
  51. package/dist/tags/index.d.ts +261 -0
  52. package/dist/tags/index.d.ts.map +1 -0
  53. package/dist/tags/index.js +64 -0
  54. package/dist/tags/index.js.map +1 -0
  55. package/package.json +82 -0
  56. package/src/custom-types/index.ts +59 -0
  57. package/src/derive/index.ts +224 -0
  58. package/src/derive/query.ts +128 -0
  59. package/src/dto/index.ts +395 -0
  60. package/src/entity-modeling/index.ts +33 -0
  61. package/src/index.ts +263 -0
  62. package/src/ir/index.ts +1085 -0
  63. package/src/ir/validation-shape.ts +145 -0
  64. package/src/ir/vocabulary.ts +56 -0
  65. package/src/naming/index.ts +159 -0
  66. package/src/openapi/index.ts +133 -0
  67. package/src/relations/index.ts +134 -0
  68. package/src/tags/index.ts +284 -0
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "@zmdb/schema",
3
+ "version": "1.0.0-beta.1",
4
+ "description": "Schema DSL + compile-time type derivation (Entity/Create/Update/read DTOs), relations, OpenAPI, and custom types — the single source of truth for a zmdb data layer.",
5
+ "keywords": [
6
+ "dto",
7
+ "openapi",
8
+ "orm",
9
+ "schema",
10
+ "type-derivation",
11
+ "typescript",
12
+ "zmdb"
13
+ ],
14
+ "homepage": "https://github.com/ambasta/zmdb#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/ambasta/zmdb/issues"
17
+ },
18
+ "license": "GPL-3.0-or-later",
19
+ "author": "zmdb contributors",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/ambasta/zmdb.git",
23
+ "directory": "packages/schema"
24
+ },
25
+ "type": "module",
26
+ "sideEffects": false,
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js"
31
+ },
32
+ "./tags": {
33
+ "types": "./dist/tags/index.d.ts",
34
+ "import": "./dist/tags/index.js"
35
+ },
36
+ "./ir": {
37
+ "types": "./dist/ir/index.d.ts",
38
+ "import": "./dist/ir/index.js"
39
+ },
40
+ "./derive": {
41
+ "types": "./dist/derive/index.d.ts",
42
+ "import": "./dist/derive/index.js"
43
+ },
44
+ "./dto": {
45
+ "types": "./dist/dto/index.d.ts",
46
+ "import": "./dist/dto/index.js"
47
+ },
48
+ "./naming": {
49
+ "types": "./dist/naming/index.d.ts",
50
+ "import": "./dist/naming/index.js"
51
+ },
52
+ "./relations": {
53
+ "types": "./dist/relations/index.d.ts",
54
+ "import": "./dist/relations/index.js"
55
+ },
56
+ "./entity-modeling": {
57
+ "types": "./dist/entity-modeling/index.d.ts",
58
+ "import": "./dist/entity-modeling/index.js"
59
+ },
60
+ "./openapi": {
61
+ "types": "./dist/openapi/index.d.ts",
62
+ "import": "./dist/openapi/index.js"
63
+ },
64
+ "./custom-types": {
65
+ "types": "./dist/custom-types/index.d.ts",
66
+ "import": "./dist/custom-types/index.js"
67
+ }
68
+ },
69
+ "publishConfig": {
70
+ "access": "public",
71
+ "tag": "beta"
72
+ },
73
+ "scripts": {
74
+ "build": "node ../../scripts/build-package.mjs",
75
+ "test": "vitest run"
76
+ },
77
+ "engines": {
78
+ "node": ">=26"
79
+ },
80
+ "main": "./dist/index.js",
81
+ "types": "./dist/index.d.ts"
82
+ }
@@ -0,0 +1,59 @@
1
+ // Custom types & codecs — see ./SPEC.md.
2
+
3
+ import { type Codec } from '../ir/index.js';
4
+
5
+ /**
6
+ * A column type the library does not know, described by its owner.
7
+ *
8
+ * Three types, because a column has three (plan D3): `Wire` is what JSON carries,
9
+ * `TS` is what handler code holds, and `DB` is what the driver binds. A codec that
10
+ * named only two of them left the third to be guessed, and the guess was "the same
11
+ * as the app type" — which is how a `Money` instance ended up being handed to
12
+ * `JSON.stringify` and to a query parameter unchanged.
13
+ *
14
+ * All four functions are required. A codec whose `toWire` was optional would be a
15
+ * codec that sometimes converts, and the caller cannot tell which kind it has.
16
+ */
17
+ export interface CustomType<Wire, TS, DB = unknown> {
18
+ /** DDL type, e.g. `'jsonb'`. Dialect spelling is the emitter's business. */
19
+ readonly sqlType: string;
20
+ /** Serialise for the driver. */
21
+ readonly toDb: (value: TS) => DB;
22
+ /** Parse a driver row value. */
23
+ readonly fromDb: (raw: DB) => TS;
24
+ /** Serialise for a JSON response. */
25
+ readonly toWire: (value: TS) => Wire;
26
+ /** Parse a JSON request body value. */
27
+ readonly fromWire: (raw: Wire) => TS;
28
+ }
29
+
30
+ export function defineType<Wire, TS, DB>(def: CustomType<Wire, TS, DB>): CustomType<Wire, TS, DB> {
31
+ return Object.freeze({ ...def });
32
+ }
33
+ export function encodeValue<Wire, TS, DB>(type: CustomType<Wire, TS, DB>, value: TS): DB {
34
+ return type.toDb(value);
35
+ }
36
+ export function decodeValue<Wire, TS, DB>(type: CustomType<Wire, TS, DB>, raw: DB): TS {
37
+ return type.fromDb(raw);
38
+ }
39
+
40
+ /**
41
+ * Adapt a `CustomType` to the `Codec` the IR's wire crossing asks for.
42
+ *
43
+ * The IR speaks `unknown` on both sides because it is data, not generics; the
44
+ * conversion is one cast at this boundary rather than one at every registry literal.
45
+ * A `Codec<'Money'>` tag names the key this goes under:
46
+ *
47
+ * ```ts
48
+ * wireDecoder(Schema, 'create', { Money: wireCodec(MoneyType) })
49
+ * ```
50
+ */
51
+ export function wireCodec<Wire, TS, DB>(type: CustomType<Wire, TS, DB>): Codec {
52
+ // boundary: a registry is keyed by name, so the value arriving at either direction is only
53
+ // as typed as the declaration that named this codec. The validator is what proves it, and
54
+ // it runs before `decode` and after `encode`.
55
+ return {
56
+ decode: (wire: unknown) => type.fromWire(wire as Wire),
57
+ encode: (app: unknown) => type.toWire(app as TS),
58
+ };
59
+ }
@@ -0,0 +1,224 @@
1
+ // @zmdb/schema/derive: the DTO suite, derived from a tagged type.
2
+ //
3
+ // There is one `Entity`, one `CreateDTO`, one `UpdateDTO`, one `PrimaryKeyOf`, and they
4
+ // are these. `../index.ts` re-exports them under the same names it used to define
5
+ // schema-value twins under; the twins are deleted, not deprecated.
6
+ //
7
+ // Every derivation takes the declared type and nothing else. Nothing here tests
8
+ // `T extends { columns: ... }`, nothing dispatches on whether it was handed a value, and
9
+ // so nothing pays for that test per use. A caller who holds a schema value crosses back
10
+ // at a boundary — `TaggedSchema<T>` in a parameter position, `T` by inference — and the
11
+ // derivations never see the value at all.
12
+
13
+ import {
14
+ type AnyRelation,
15
+ type HasDefault,
16
+ type PrimaryKey,
17
+ type Sensitive,
18
+ type Serial,
19
+ type SoftDelete,
20
+ type Sql,
21
+ type Table,
22
+ type Unique,
23
+ type WireAs,
24
+ } from '../tags/index.js';
25
+
26
+ /**
27
+ * What every derivation takes: a type that could have been declared as a table.
28
+ *
29
+ * An alias for `Table<string>`, and the constraint is the point. `Table` is an
30
+ * all-optional weak type, so TypeScript's weak-type rule rejects anything with no property
31
+ * in common with it — which a generated schema *value* is. `Entity<typeof UserSchema>`, the
32
+ * spelling this design replaced, is therefore a compile error rather than the schema's own
33
+ * five properties dressed up as a row. Worth constraining for precisely because the wrong
34
+ * answer was structurally plausible: it had keys and it had types, so nothing downstream
35
+ * would have complained.
36
+ *
37
+ * An index-signature row still passes, because a string index can hold the slot — see
38
+ * `dto/index.ts`'s `UnknownRow`, the one corner of the query surface keyed by a table
39
+ * *name* instead of by a declaration.
40
+ */
41
+ export type DeclaredTable = Table<string>;
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Key filters.
45
+ // ---------------------------------------------------------------------------
46
+ //
47
+ // `NonNullable<T[K]>` rather than a bare `T[K]` is load-bearing. A nullable
48
+ // column with a default is declared `(string & HasDefault) | null`, and `null` is
49
+ // not assignable to a weak object type, so the union as a whole does not match
50
+ // `HasDefault`. Testing the non-nullable arm is what makes a nullable defaulted
51
+ // column optional on insert instead of required. `tagged-dto.type-test.ts` pins
52
+ // this down: it is exactly the sort of thing that silently returns `never`.
53
+ //
54
+ // `-?` strips optionality from the probe so an already-optional property is still
55
+ // examined under `exactOptionalPropertyTypes`.
56
+ //
57
+ // Symbol keys are filtered out everywhere below: entity-level tags (`Table`,
58
+ // `Fts`, `SoftDelete`, `ForeignKey`) arrive through `extends` and would
59
+ // otherwise show up in `keyof`.
60
+
61
+ export type KeysCarrying<T, Tag> = {
62
+ [K in keyof T]-?: NonNullable<T[K]> extends Tag ? (K extends string ? K : never) : never;
63
+ }[keyof T];
64
+
65
+ /** Columns the database generates. Omitted from `CreateDTO` outright. */
66
+ export type SerialKeys<T> = KeysCarrying<T, Serial>;
67
+ /** Columns with a database default. Optional on insert, not absent. */
68
+ export type DefaultKeys<T> = KeysCarrying<T, HasDefault>;
69
+ export type PrimaryKeyKeys<T> = KeysCarrying<T, PrimaryKey>;
70
+ export type SensitiveKeys<T> = KeysCarrying<T, Sensitive>;
71
+ export type UniqueKeys<T> = KeysCarrying<T, Unique>;
72
+ /** The entity-level soft-delete tag names one managed column. */
73
+ export type SoftDeleteKeys<T> = T extends SoftDelete<infer Column> ? Extract<Column, ColumnKeys<T>> : never;
74
+ /** Columns whose declared type admits `null`. Native, not a tag. */
75
+ export type NullableKeys<T> = { [K in keyof T]-?: null extends T[K] ? (K extends string ? K : never) : never }[keyof T];
76
+
77
+ /**
78
+ * Properties declared with a relation tag: a join target, not a column.
79
+ *
80
+ * These have to come out of `Entity<T>`, and therefore out of everything derived
81
+ * from it. A relation left in would be a column to `INSERT`, a column to `SELECT`
82
+ * and a JSON Schema property, none of which it is. `./query.ts` puts them back where
83
+ * they belong, in `Populated<T, K>`.
84
+ */
85
+ export type RelationKeys<T> = KeysCarrying<T, AnyRelation>;
86
+
87
+ /**
88
+ * Everything that is a column: a data key that is not a relation.
89
+ *
90
+ * Spelled as its own projection rather than as a subtraction. `Exclude<AllKeys<T>,
91
+ * RelationKeys<T>>` does not compile: for an unresolved `T` both operands normalise to
92
+ * the same deferred expression, so the subtraction yields `never`, and then
93
+ * `Pick<Entity<T>, DefaultKeys<T>>` is an error because nothing is a key of an entity
94
+ * with no keys.
95
+ */
96
+ export type ColumnKeys<T> = {
97
+ [K in keyof T]-?: NonNullable<T[K]> extends AnyRelation ? never : K extends string ? K : never;
98
+ }[keyof T];
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // The DTO suite.
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /**
105
+ * The selectable row: every column, required, sensitive columns included, tags
106
+ * preserved. Tags must survive here or the constraints would not survive `Omit`
107
+ * and `Partial` downstream.
108
+ *
109
+ * Relations are not columns and are not here. See `RelationKeys`.
110
+ */
111
+ export type Entity<T extends DeclaredTable> = { -readonly [K in ColumnKeys<T>]-?: T[K] };
112
+
113
+ /**
114
+ * A tag filter's keys, narrowed to the keys `Entity<T>` actually has.
115
+ *
116
+ * `SerialKeys<T>` and friends are subsets of `ColumnKeys<T>` by construction — a
117
+ * relation cannot carry `Serial` — but TypeScript will not take it on trust. Relating
118
+ * two of these projections for an unresolved `T` works only while the *target*'s
119
+ * template is unconditional, which `DataKeys<T>` was and `ColumnKeys<T>` is not. So the
120
+ * subset is stated as an intersection, which is assignable to either side by
121
+ * definition, rather than proved. Nothing changes for a concrete type.
122
+ */
123
+ type AsColumns<T extends DeclaredTable, K> = K & keyof Entity<T>;
124
+
125
+ /**
126
+ * Insert shape. `Serial` columns are **absent** — naming one is a compile error,
127
+ * because the database generates the value. `HasDefault` columns are **present
128
+ * and optional**, because supplying one is legitimate. That distinction is the
129
+ * whole reason the two tags are separate.
130
+ *
131
+ * A nullable column is optional for the same reason a defaulted one is: omitting it
132
+ * inserts `NULL`, which is exactly what passing `null` does, so demanding the key adds
133
+ * ceremony and no information. The published document has always said this — a nullable
134
+ * column has never appeared in a `create` document's `required` — and so did the
135
+ * repository's runtime check. Requiring `bio: null` in the type while the contract said
136
+ * it was optional meant a client that followed the contract wrote a payload the type
137
+ * rejected, which is the disagreement this phase exists to remove. It stays *present* and
138
+ * optional rather than absent, because passing `null` explicitly is legitimate.
139
+ */
140
+ type OptionalCreateKeys<T extends DeclaredTable> = Exclude<DefaultKeys<T> | NullableKeys<T>, SoftDeleteKeys<T>>;
141
+
142
+ export type CreateDTO<T extends DeclaredTable> = Omit<
143
+ Entity<T>,
144
+ SerialKeys<T> | DefaultKeys<T> | NullableKeys<T> | SoftDeleteKeys<T>
145
+ > &
146
+ Partial<Pick<Entity<T>, AsColumns<T, OptionalCreateKeys<T>>>>;
147
+
148
+ /** Patch shape: identity and framework-managed columns dropped, everything else optional. */
149
+ export type UpdateDTO<T extends DeclaredTable> = Partial<
150
+ Omit<Entity<T>, SerialKeys<T> | PrimaryKeyKeys<T> | SoftDeleteKeys<T>>
151
+ >;
152
+
153
+ /**
154
+ * What a read endpoint may return. `Sensitive` columns are removed from the
155
+ * type, so a leak is a compile error rather than a serializer's responsibility.
156
+ */
157
+ export type ReadDTO<T extends DeclaredTable> = Omit<Entity<T>, SensitiveKeys<T>>;
158
+
159
+ type IsUnion<T, U = T> = T extends unknown ? ([U] extends [T] ? false : true) : never;
160
+
161
+ /**
162
+ * The key value: a scalar for a single-column key, an object map for a composite
163
+ * one, `unknown` when the type declares no primary key.
164
+ *
165
+ * Named `PrimaryKeyOf` so the tag can be `PrimaryKey`, which is the name typed at
166
+ * every declaration site (plan D1).
167
+ */
168
+ export type PrimaryKeyOf<T extends DeclaredTable> = [PrimaryKeyKeys<T>] extends [never]
169
+ ? unknown
170
+ : IsUnion<PrimaryKeyKeys<T>> extends true
171
+ ? { [K in PrimaryKeyKeys<T>]: Entity<T>[AsColumns<T, K>] }
172
+ : Entity<T>[AsColumns<T, PrimaryKeyKeys<T>>];
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // The wire shape (plan D3).
176
+ // ---------------------------------------------------------------------------
177
+ //
178
+ // A column has three types: wire (what arrives over HTTP), app (what handler code
179
+ // sees) and db (what the dialect declares). `Entity<T>` is the app type. `Wire<T>`
180
+ // is what a JSON body actually contains, which for a timestamp is an ISO-8601
181
+ // string, never a `Date` — a `Date` cannot survive JSON. The web pipeline decodes
182
+ // wire → app once at the boundary so handlers keep seeing `Date`.
183
+ //
184
+ // A column whose type the library does not know says its own wire form with
185
+ // `WireAs<W>`, and that beats the SQL-type rules: a codec can put anything it likes on
186
+ // the wire, and only the declaration knows what.
187
+
188
+ // A column's nullability belongs to the column, not to the layer: a `timestamp | null`
189
+ // is a `string | null` on the wire. Factored out so each layer rule below is one line
190
+ // and the three cannot drift apart.
191
+ type OrNull<V, W> = null extends V ? W | null : W;
192
+
193
+ type WireValue<V> =
194
+ NonNullable<V> extends WireAs<infer W>
195
+ ? OrNull<V, W>
196
+ : NonNullable<V> extends Sql<'timestamp'>
197
+ ? OrNull<V, string>
198
+ : NonNullable<V> extends Sql<'bigint'>
199
+ ? OrNull<V, string>
200
+ : V;
201
+
202
+ /** The over-the-wire shape of an entity: JSON-representable throughout. */
203
+ export type Wire<T> = { -readonly [K in ColumnKeys<T>]-?: WireValue<T[K]> };
204
+
205
+ /** The over-the-wire shape of an insert payload. */
206
+ export type WireCreateDTO<T extends DeclaredTable> = { [K in keyof CreateDTO<T>]: WireValue<CreateDTO<T>[K]> };
207
+
208
+ // The read/query surface — `WhereDTO`, `OrderByDTO`, `PaginationDTO`, `Projection`,
209
+ // `GetDTO`, `ListDTO`, `Populated`, `JoinRow` — is in `./query.ts`, re-exported here
210
+ // so `@zmdb/schema/derive` is one import.
211
+ export type {
212
+ GetDTO,
213
+ GetOptions,
214
+ JoinRow,
215
+ ListDTO,
216
+ ListResult,
217
+ OrderByDTO,
218
+ PaginationDTO,
219
+ Populated,
220
+ PopulatedEntity,
221
+ Projection,
222
+ RelationPath,
223
+ WhereDTO,
224
+ } from './query.js';
@@ -0,0 +1,128 @@
1
+ // @zmdb/schema/derive/query — the relation-aware half of the read surface.
2
+ //
3
+ // This file used to restate the whole query DTO family, because there were two of them:
4
+ // `../dto/index.ts` was keyed by the schema value and these were keyed by the declared
5
+ // type. There is one family now — `WhereDTO`, `OrderByDTO`, `PaginationDTO`, `Projection`,
6
+ // `GetOptions`, `GetDTO` and `ListDTO` all take the declared type, and they live in
7
+ // `../dto/index.ts` alongside the operator vocabulary and the runtime folders that read
8
+ // them. Two copies of `WhereDTO` were two operator sets to keep in step; the copy is gone
9
+ // and the names below are re-exports, so importing from either path is the same type.
10
+ //
11
+ // What is genuinely *here* is the three shapes that need a relation: a populated row and
12
+ // the two spellings of a join row. Those read `RelationKeys<T>` and the relation tags off
13
+ // the declaration, which is something only a declared type can answer — a schema value
14
+ // carries no relations at all, which is why the version of `Populated` this replaced had
15
+ // to rebuild the cardinality out of a `RelationMeta` through six nested conditionals.
16
+
17
+ import { type DeclaredTable, type Entity, type RelationKeys } from './index.js';
18
+
19
+ export {
20
+ type GetDTO,
21
+ type GetOptions,
22
+ type ListDTO,
23
+ type ListResult,
24
+ type OrderByDTO,
25
+ type PaginationDTO,
26
+ type Projection,
27
+ type WhereDTO,
28
+ } from '../dto/index.js';
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Relations.
32
+ // ---------------------------------------------------------------------------
33
+ //
34
+ // `K & keyof T` throughout: `RelationKeys<T>` *is* a subset of `keyof T` by
35
+ // construction, but it is produced by a mapped-type projection and TypeScript does not
36
+ // carry that fact forward, so an unqualified `T[K]` does not compile.
37
+
38
+ /**
39
+ * What a populated relation holds: fetched rows, and what happens when nothing matched.
40
+ *
41
+ * Cardinality comes from the declaration and nothing else, which is the point: `comments?:
42
+ * Comment[] & OneToMany<…>` is an array because it was written as one, and `author?: User &
43
+ * ManyToOne<…>` is not. The schema-value version had to read the cardinality out of a
44
+ * `RelationMeta` and rebuild the array, through six nested conditional types named
45
+ * `RelationEntityFromDef` and `RelationCardinalityFromDef`, because a relation *value* does
46
+ * not carry the target's type. Both are deleted.
47
+ *
48
+ * Without a nested path, the target becomes `Entity<>`: its own relations are absent.
49
+ * A nested path adds only its requested descendants, using this same derivation.
50
+ * And a to-one gains `| null`, because a foreign key that matches nothing is a row the
51
+ * database can hold — `null` is what the repository attaches for it, and a type that said
52
+ * `User` there would be wrong for the case the query cannot rule out. A to-many needs no
53
+ * such arm: no match is the empty array.
54
+ */
55
+ type PopulatedValue<V, Paths extends string> =
56
+ NonNullable<V> extends readonly (infer E)[]
57
+ ? readonly ([Paths] extends [never] ? Entity<E & DeclaredTable> : PopulatedRow<E & DeclaredTable, Paths>)[]
58
+ :
59
+ | ([Paths] extends [never]
60
+ ? Entity<NonNullable<V> & DeclaredTable>
61
+ : PopulatedRow<NonNullable<V> & DeclaredTable, Paths>)
62
+ | null;
63
+
64
+ /** Validate a supplied path without expanding every path of a recursive entity graph. */
65
+ export type RelationPath<T extends DeclaredTable, Path extends string> = Path extends `${infer Head}.${infer Tail}`
66
+ ? Head extends RelationKeys<T>
67
+ ? `${Head}.${RelationPath<RelationTargetOf<T[Head & keyof T]>, Tail>}`
68
+ : never
69
+ : Path extends RelationKeys<T>
70
+ ? Path
71
+ : never;
72
+
73
+ type PathHead<Path extends string> = Path extends `${infer Head}.${string}` ? Head : Path;
74
+ type PathTail<Path extends string, Head extends string> = Path extends `${Head}.${infer Tail}` ? Tail : never;
75
+ type RootRelationPath<T> = (RelationKeys<T> & string) | `${RelationKeys<T> & string}.${string}`;
76
+
77
+ /**
78
+ * The row plus the relation paths named by `K`, populated.
79
+ *
80
+ * Dotted paths contribute their first segment to this row and pass their remaining
81
+ * segments to the child. Keeping direct keys in the mapped key set also lets a generic
82
+ * one-level relation loader index the result with its declared relation key.
83
+ */
84
+ type PopulatedRow<T extends DeclaredTable, K extends string> = Entity<T> & {
85
+ -readonly [P in (K | PathHead<K>) & keyof T]: PopulatedValue<T[P], PathTail<K, P & string>>;
86
+ };
87
+
88
+ export type PopulatedEntity<
89
+ T extends DeclaredTable,
90
+ K extends RootRelationPath<T> = RelationKeys<T> & string,
91
+ > = PopulatedRow<T, K> & ([Exclude<K, RelationPath<T, K>>] extends [never] ? unknown : never);
92
+
93
+ /** Alias kept because both names are in use across the repository and the docs. */
94
+ export type Populated<
95
+ T extends DeclaredTable,
96
+ K extends RootRelationPath<T> = RelationKeys<T> & string,
97
+ > = PopulatedEntity<T, K>;
98
+
99
+ /**
100
+ * The target of a relation: the element type for a to-many, the type itself for a to-one.
101
+ *
102
+ * `& DeclaredTable` on the way out rather than a constraint on the way in. A relation is
103
+ * declared as `Comment[] & OneToMany<…>`, so the element type is whatever the author wrote
104
+ * there and TypeScript cannot be told in advance that it is a table — but `Entity<>` wants
105
+ * to know, and intersecting is assignable by definition where proving is not.
106
+ */
107
+ type RelationTargetOf<V> = (NonNullable<V> extends readonly (infer E)[] ? E : NonNullable<V>) & DeclaredTable;
108
+
109
+ /**
110
+ * One row of a join: the base row's columns and the target's, flat.
111
+ *
112
+ * A `LEFT JOIN` can produce a row with no match, so the joined half is `Partial`. An
113
+ * `INNER JOIN` cannot, so it is not — and that asymmetry is the whole reason `Kind` is
114
+ * a parameter rather than always-partial.
115
+ *
116
+ * `../relations/index.ts` exports the same asymmetry as `JoinRow<Base, Joined, Kind>`, for
117
+ * the join that names its target directly instead of by a relation. Two shapes, because they
118
+ * take different arguments: that one is given both tables, this one is given a base table and
119
+ * the name of one of its relations. The conditional itself is two lines and duplicating it is
120
+ * cheaper than a shared helper neither would read more clearly.
121
+ */
122
+ export type JoinRow<
123
+ T extends DeclaredTable,
124
+ K extends RelationKeys<T>,
125
+ Kind extends 'inner' | 'left' = 'left',
126
+ > = Kind extends 'inner'
127
+ ? Entity<T> & Entity<RelationTargetOf<T[K & keyof T]>>
128
+ : Entity<T> & Partial<Entity<RelationTargetOf<T[K & keyof T]>>>;