@metaobjectsdev/codegen-ts 1.0.5-rc.1 → 1.0.5-rc.3
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/dist/generators/api-model.d.ts.map +1 -1
- package/dist/generators/api-model.js +11 -10
- package/dist/generators/api-model.js.map +1 -1
- package/dist/generators/queries-file.d.ts.map +1 -1
- package/dist/generators/queries-file.js +7 -2
- package/dist/generators/queries-file.js.map +1 -1
- package/dist/generators/routes-file-hono.d.ts +2 -1
- package/dist/generators/routes-file-hono.d.ts.map +1 -1
- package/dist/generators/routes-file-hono.js +4 -3
- package/dist/generators/routes-file-hono.js.map +1 -1
- package/dist/generators/routes-file.d.ts +2 -1
- package/dist/generators/routes-file.d.ts.map +1 -1
- package/dist/generators/routes-file.js +4 -3
- package/dist/generators/routes-file.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/relation-resolver.d.ts +6 -1
- package/dist/relation-resolver.d.ts.map +1 -1
- package/dist/relation-resolver.js +80 -8
- package/dist/relation-resolver.js.map +1 -1
- package/dist/runner.js +1 -1
- package/dist/runner.js.map +1 -1
- package/dist/templates/drizzle-schema.d.ts.map +1 -1
- package/dist/templates/drizzle-schema.js +23 -4
- package/dist/templates/drizzle-schema.js.map +1 -1
- package/dist/templates/entity-ui-descriptor.d.ts +7 -9
- package/dist/templates/entity-ui-descriptor.d.ts.map +1 -1
- package/dist/templates/entity-ui-descriptor.js +9 -16
- package/dist/templates/entity-ui-descriptor.js.map +1 -1
- package/dist/templates/relations-block.d.ts.map +1 -1
- package/dist/templates/relations-block.js +13 -6
- package/dist/templates/relations-block.js.map +1 -1
- package/dist/templates/routes-file.d.ts.map +1 -1
- package/dist/templates/routes-file.js +80 -12
- package/dist/templates/routes-file.js.map +1 -1
- package/dist/templates/zod-validators.d.ts +29 -9
- package/dist/templates/zod-validators.d.ts.map +1 -1
- package/dist/templates/zod-validators.js +65 -16
- package/dist/templates/zod-validators.js.map +1 -1
- package/package.json +6 -6
- package/src/generators/api-model.ts +11 -10
- package/src/generators/queries-file.ts +7 -2
- package/src/generators/routes-file-hono.ts +4 -3
- package/src/generators/routes-file.ts +4 -3
- package/src/index.ts +1 -1
- package/src/reference/queries.ts +4 -2
- package/src/reference/routes-hono.ts +2 -2
- package/src/reference/routes.ts +4 -3
- package/src/relation-resolver.ts +90 -7
- package/src/runner.ts +1 -1
- package/src/templates/drizzle-schema.ts +21 -4
- package/src/templates/entity-ui-descriptor.ts +9 -16
- package/src/templates/relations-block.ts +19 -11
- package/src/templates/routes-file.ts +123 -20
- package/src/templates/zod-validators.ts +66 -17
|
@@ -118,8 +118,7 @@ import { responseShape } from "../templates/find-inbound.js";
|
|
|
118
118
|
import { isTphSubtype } from "../templates/zod-validators.js";
|
|
119
119
|
import { isTphDiscriminatorBase } from "../templates/tph-discriminator.js";
|
|
120
120
|
import { isCallableEntity } from "../templates/callable-file.js";
|
|
121
|
-
import {
|
|
122
|
-
import { servedPath } from "../api-surface.js";
|
|
121
|
+
import { servedPath, servesReadApi } from "../api-surface.js";
|
|
123
122
|
import { isProjection } from "../projection/projection-detector.js";
|
|
124
123
|
import { buildPkMap } from "../pk-resolver.js";
|
|
125
124
|
import { buildRelationMap, type RelationEntry, type RelationMap } from "../relation-resolver.js";
|
|
@@ -356,8 +355,9 @@ function stripTs(path: string): string {
|
|
|
356
355
|
// ---------------------------------------------------------------------------
|
|
357
356
|
|
|
358
357
|
/** Mirror of the queries generator's filter (queries-file.ts `skipNonQueryable`
|
|
359
|
-
* = `
|
|
360
|
-
* any source-backed, non-TPH-subtype object:
|
|
358
|
+
* = `servesReadApi(e) && !isTphSubtype(e)`, #248 R2). A queryable object is
|
|
359
|
+
* any source-backed, non-abstract, non-TPH-subtype object:
|
|
360
|
+
* • An abstract object has no table of its own, only a type-only shape.
|
|
361
361
|
* • An object with no declared/inherited source.rdb (of ANY kind) isn't
|
|
362
362
|
* backed by any store → the queries/routes/validation generators emit no
|
|
363
363
|
* CRUD for it. Value objects are subsumed here: value purity (ADR-0028)
|
|
@@ -370,7 +370,7 @@ function stripTs(path: string): string {
|
|
|
370
370
|
* per-subtype polymorphic helpers + subpaths are a documented deferral — see
|
|
371
371
|
* the module header.) */
|
|
372
372
|
function isQueryable(obj: MetaObject): boolean {
|
|
373
|
-
return
|
|
373
|
+
return servesReadApi(obj) && !isTphSubtype(obj);
|
|
374
374
|
}
|
|
375
375
|
|
|
376
376
|
function buildEntityUnit(
|
|
@@ -405,7 +405,7 @@ function buildEntityUnit(
|
|
|
405
405
|
symbols.push(...dataAccessSymbols(obj, ctx, root, layout));
|
|
406
406
|
symbols.push(...validationSymbols(obj, entityMod));
|
|
407
407
|
// REST needs no gate of its own: the routes generator's built-in filter is
|
|
408
|
-
// `
|
|
408
|
+
// `servesReadApi && !isTphSubtype` — exactly isQueryable — so every queryable
|
|
409
409
|
// object gets routes. (Its `filter` option can narrow that further; this builder
|
|
410
410
|
// reads the model, not the wired generator set, and cannot see it. See the module
|
|
411
411
|
// header.)
|
|
@@ -415,10 +415,11 @@ function buildEntityUnit(
|
|
|
415
415
|
if (includeHono) symbols.push(...restHonoSymbols(obj, layout, apiPrefix));
|
|
416
416
|
}
|
|
417
417
|
|
|
418
|
-
// --- relation: the drizzle relations() export, when the resolver
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
418
|
+
// --- relation: the drizzle relations() export, when the resolver files entries
|
|
419
|
+
// under this entity (1:N belongs-to + inverse many, M:N @through). The map
|
|
420
|
+
// keys by the entity whose module renders the block — the discriminator
|
|
421
|
+
// base, for a TPH subtype; an abstract entity emits a value-object module
|
|
422
|
+
// and never carries one. Independent of isQueryable. ---
|
|
422
423
|
const relationSym = relationSymbol(obj, entityMod, relationMap);
|
|
423
424
|
if (relationSym !== undefined) symbols.push(relationSym);
|
|
424
425
|
|
|
@@ -2,7 +2,7 @@ import type { MetaObject } from "@metaobjectsdev/metadata";
|
|
|
2
2
|
import { perEntity, type Generator, type GeneratorFactory } from "../generator.js";
|
|
3
3
|
import { renderQueriesFile } from "../templates/queries-file.js";
|
|
4
4
|
import { isTphSubtype } from "../templates/zod-validators.js";
|
|
5
|
-
import {
|
|
5
|
+
import { servesReadApi } from "../api-surface.js";
|
|
6
6
|
import { formatTs } from "../format.js";
|
|
7
7
|
import { entityOutputPath } from "../import-path.js";
|
|
8
8
|
import { effectivePackage } from "../docs-paths.js";
|
|
@@ -25,7 +25,12 @@ export interface QueriesFileOpts {
|
|
|
25
25
|
// FR-017 Tier 2: TPH subtypes are ALSO skipped — they emit no standalone
|
|
26
26
|
// queries file. Their per-subtype CRUD helpers live in the discriminator
|
|
27
27
|
// base's queries file (which targets the single shared table).
|
|
28
|
-
|
|
28
|
+
//
|
|
29
|
+
// Abstract objects are skipped as well (`servesReadApi`): an abstract level inherits its
|
|
30
|
+
// base's source but has no table of its own — only a type-only shape — so its queries
|
|
31
|
+
// module imported a table and schemas that do not exist. The hooks tier already gated on
|
|
32
|
+
// this predicate; the queries and routes tiers now agree with it.
|
|
33
|
+
const skipNonQueryable = (e: MetaObject): boolean => servesReadApi(e) && !isTphSubtype(e);
|
|
29
34
|
|
|
30
35
|
export const queriesFile = function queriesFile(opts?: QueriesFileOpts): Generator {
|
|
31
36
|
const userFilter = opts?.filter;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { MetaObject } from "@metaobjectsdev/metadata";
|
|
2
2
|
import { perEntity, type Generator, type GeneratorFactory } from "../generator.js";
|
|
3
3
|
import { renderRoutesFileHono } from "../templates/routes-file-hono.js";
|
|
4
|
-
import {
|
|
4
|
+
import { servesReadApi } from "../api-surface.js";
|
|
5
5
|
import { formatTs } from "../format.js";
|
|
6
6
|
import { entityOutputPath } from "../import-path.js";
|
|
7
7
|
import { isTphSubtype } from "../templates/zod-validators.js";
|
|
@@ -41,7 +41,8 @@ export interface RoutesFileHonoOpts {
|
|
|
41
41
|
* them (ERR_UNKNOWN_ATTR).
|
|
42
42
|
*
|
|
43
43
|
* #248 R2: an object with no declared/inherited source.rdb (of ANY kind) isn't
|
|
44
|
-
* backed by any store — gated by `
|
|
44
|
+
* backed by any store — gated by `servesReadApi`, which also excludes abstract objects
|
|
45
|
+
* (no table of their own to mount) (does NOT add TPH handling;
|
|
45
46
|
* that gap is pre-existing and out of scope here).
|
|
46
47
|
*/
|
|
47
48
|
export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts): Generator {
|
|
@@ -51,7 +52,7 @@ export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts)
|
|
|
51
52
|
// written out twice a later edit to one silently makes an entity either stop emitting
|
|
52
53
|
// without being named as held back, or get warned about while still emitting.
|
|
53
54
|
const passesOtherGates = (e: MetaObject): boolean =>
|
|
54
|
-
|
|
55
|
+
servesReadApi(e) && userFilter(e);
|
|
55
56
|
const generator: Generator = {
|
|
56
57
|
name: "routes-file-hono",
|
|
57
58
|
// Marks this as the Hono routes generator so the runner can aggregate
|
|
@@ -2,7 +2,7 @@ import type { MetaObject } from "@metaobjectsdev/metadata";
|
|
|
2
2
|
import { perEntity, type Generator, type GeneratorFactory } from "../generator.js";
|
|
3
3
|
import { renderRoutesFile } from "../templates/routes-file.js";
|
|
4
4
|
import { isTphSubtype } from "../templates/zod-validators.js";
|
|
5
|
-
import {
|
|
5
|
+
import { servesReadApi } from "../api-surface.js";
|
|
6
6
|
import { formatTs } from "../format.js";
|
|
7
7
|
import { entityOutputPath } from "../import-path.js";
|
|
8
8
|
import { resolveExpose, type ExposeOption } from "../routes-expose.js";
|
|
@@ -33,7 +33,8 @@ export interface RoutesFileOpts {
|
|
|
33
33
|
*
|
|
34
34
|
* #248 R2: an object with no declared/inherited source.rdb (of ANY kind) isn't
|
|
35
35
|
* backed by any store — routes against it would import Drizzle table/allowlist
|
|
36
|
-
* exports the entity-file generator never emits. Gated by `
|
|
36
|
+
* exports the entity-file generator never emits. Gated by `servesReadApi`, which also
|
|
37
|
+
* excludes abstract objects: an abstract level has no table of its own to mount.
|
|
37
38
|
*
|
|
38
39
|
* FR-017 Tier 2: TPH subtypes get no standalone routes file — their per-subtype
|
|
39
40
|
* route set lives in the discriminator base's routes file.
|
|
@@ -44,7 +45,7 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator
|
|
|
44
45
|
name: "routes-file",
|
|
45
46
|
// Always set: AND-composes the built-in gates with the optional user filter.
|
|
46
47
|
filter: (e: MetaObject) =>
|
|
47
|
-
|
|
48
|
+
servesReadApi(e) && !isTphSubtype(e) && userFilter(e),
|
|
48
49
|
generate: perEntity(async (entity, ctx) => {
|
|
49
50
|
if (!ctx.renderContext) {
|
|
50
51
|
throw new Error("routes-file: renderContext is required (provided by runGen)");
|
package/src/index.ts
CHANGED
|
@@ -138,7 +138,7 @@ export { renderEntityMetaFile, entityMetaFileName, entityMetaSpecifier } from ".
|
|
|
138
138
|
// react) to dispatch polymorphic/per-subtype emission and skip subtype files.
|
|
139
139
|
export { isTphDiscriminatorBase, tphConcreteSubtypes, collectTphSubtypeFields, tphPlan, tphRouteSegment } from "./templates/tph-discriminator.js";
|
|
140
140
|
export type { TphPlan, TphSubtypePlan } from "./templates/tph-discriminator.js";
|
|
141
|
-
export { isTphSubtype, tphDiscriminatorBase, tphDiscriminatorPin } from "./templates/zod-validators.js";
|
|
141
|
+
export { isTphSubtype, tphDiscriminatorBase, tphDiscriminatorPin, tphStorageName, tphStorageObject } from "./templates/zod-validators.js";
|
|
142
142
|
|
|
143
143
|
// The ONE sortability rule. It builds the server-side `<Entity>SortAllowlist` and the
|
|
144
144
|
// client-side sort union, and it is public so a UI-tier generator (a data-grid column
|
package/src/reference/queries.ts
CHANGED
|
@@ -50,7 +50,7 @@ import {
|
|
|
50
50
|
isProjection,
|
|
51
51
|
isWriteThrough,
|
|
52
52
|
isTphSubtype,
|
|
53
|
-
|
|
53
|
+
servesReadApi,
|
|
54
54
|
hasAutoSetFields,
|
|
55
55
|
renderQueriesFile, // engine composer — used for the delegated variants
|
|
56
56
|
formatTs,
|
|
@@ -146,7 +146,9 @@ export interface QueriesFileOpts {
|
|
|
146
146
|
// loadable value ever has hasAnyRdbSource === true). TPH subtypes emit no
|
|
147
147
|
// standalone queries file either — their per-subtype CRUD helpers live in the
|
|
148
148
|
// discriminator base's queries file (which targets the single shared table).
|
|
149
|
-
|
|
149
|
+
// Abstract objects are skipped too (`servesReadApi`): an abstract level has no table of
|
|
150
|
+
// its own, only a type-only shape, so a queries module for it cannot compile.
|
|
151
|
+
const skipNonQueryable = (e: MetaObject): boolean => servesReadApi(e) && !isTphSubtype(e);
|
|
150
152
|
|
|
151
153
|
export const queriesFile = function queriesFile(opts?: QueriesFileOpts): Generator {
|
|
152
154
|
const userFilter = opts?.filter;
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
resolveExpose,
|
|
36
36
|
type ExposeOption,
|
|
37
37
|
isTphSubtype,
|
|
38
|
-
|
|
38
|
+
servesReadApi,
|
|
39
39
|
formatTs,
|
|
40
40
|
entityOutputPath,
|
|
41
41
|
effectivePackage,
|
|
@@ -64,7 +64,7 @@ export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts)
|
|
|
64
64
|
// without being named as held back, or get warned about while still emitting.
|
|
65
65
|
// (Same shape as tanstack's grid generator, which factors it the same way.)
|
|
66
66
|
const passesOtherGates = (e: MetaObject): boolean =>
|
|
67
|
-
|
|
67
|
+
servesReadApi(e) && userFilter(e);
|
|
68
68
|
const generator: Generator = {
|
|
69
69
|
name: "routes-file-hono",
|
|
70
70
|
// Marks this as the Hono routes generator so the runner can aggregate
|
package/src/reference/routes.ts
CHANGED
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
resolveExpose,
|
|
44
44
|
type ExposeOption,
|
|
45
45
|
isTphSubtype,
|
|
46
|
-
|
|
46
|
+
servesReadApi,
|
|
47
47
|
formatTs,
|
|
48
48
|
entityOutputPath,
|
|
49
49
|
effectivePackage,
|
|
@@ -72,9 +72,10 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator
|
|
|
72
72
|
// base's); AND-composed with your filter.
|
|
73
73
|
// #248 R2: an object with no declared/inherited source.rdb (of ANY kind) isn't
|
|
74
74
|
// backed by any store — routes against it would import Drizzle table/allowlist
|
|
75
|
-
// exports the entity file never emits. Gated by
|
|
75
|
+
// exports the entity file never emits. Gated by servesReadApi, which also skips
|
|
76
|
+
// abstract objects: an abstract level has no table of its own to mount.
|
|
76
77
|
filter: (e: MetaObject) =>
|
|
77
|
-
|
|
78
|
+
servesReadApi(e) && !isTphSubtype(e) && userFilter(e),
|
|
78
79
|
generate: perEntity(async (entity, ctx) => {
|
|
79
80
|
if (!ctx.renderContext) {
|
|
80
81
|
throw new Error("routes-file: renderContext is required (provided by runGen)");
|
package/src/relation-resolver.ts
CHANGED
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
stripPackage,
|
|
19
19
|
} from "@metaobjectsdev/metadata";
|
|
20
20
|
import { variableNameFromEntity } from "./naming.js";
|
|
21
|
+
import { hasWritableRdbSource } from "./source-detect.js";
|
|
22
|
+
import { tphStorageObject } from "./templates/zod-validators.js";
|
|
21
23
|
import { isProjection } from "./projection/projection-detector.js";
|
|
22
24
|
|
|
23
25
|
export interface RelationEntry {
|
|
@@ -55,8 +57,16 @@ export type RelationMap = Map<string, RelationEntry[]>;
|
|
|
55
57
|
/**
|
|
56
58
|
* Walk all entities, collect relationship children, and also register inverse
|
|
57
59
|
* many() sides on the target entity.
|
|
60
|
+
*
|
|
61
|
+
* `onWarn`, when supplied, receives one line per M:N relationship whose junction FKs
|
|
62
|
+
* could not be derived: the entry is skipped (a route that mounts nothing is an
|
|
63
|
+
* ABSENCE, not an error), and the warning is the only thing that says so. The runner
|
|
64
|
+
* passes its warnings channel; callers without one keep the silent skip.
|
|
58
65
|
*/
|
|
59
|
-
export function buildRelationMap(
|
|
66
|
+
export function buildRelationMap(
|
|
67
|
+
root: MetaRoot,
|
|
68
|
+
onWarn?: (msg: string) => void,
|
|
69
|
+
): RelationMap {
|
|
60
70
|
const result: RelationMap = new Map();
|
|
61
71
|
|
|
62
72
|
const ensure = (name: string): RelationEntry[] => {
|
|
@@ -64,12 +74,71 @@ export function buildRelationMap(root: MetaRoot): RelationMap {
|
|
|
64
74
|
return result.get(name)!;
|
|
65
75
|
};
|
|
66
76
|
|
|
77
|
+
// Files a CARDINALITY-ONE entry under the entity whose module renders the
|
|
78
|
+
// relations() block. A TPH subtype has no module of its own — it is folded into
|
|
79
|
+
// the discriminator base's single table, and the block renders on the BASE's.
|
|
80
|
+
// So an entry must be filed under the entity that actually renders it, or it is
|
|
81
|
+
// silently never emitted. `tphStorageName` is the seam for exactly this (its own
|
|
82
|
+
// doc says "for the name-keyed relation map"); the TARGET side of relations-block
|
|
83
|
+
// already resolves through it — only the SOURCE side was keyed raw.
|
|
84
|
+
//
|
|
85
|
+
// Because `obj.relationships()` RESOLVES, a base-declared relationship is reached
|
|
86
|
+
// again through every subtype and would now land on the same key repeatedly, so an
|
|
87
|
+
// entry structurally identical to one already filed is skipped. A name that collides
|
|
88
|
+
// with a DIFFERENT shape is a real conflict the base's single block cannot express:
|
|
89
|
+
// it is reported through `onWarn` rather than silently overwritten, because a
|
|
90
|
+
// navigation that quietly resolves to another subtype's target is the worse failure.
|
|
91
|
+
// True when a module renders a relations() block for this storage object — the
|
|
92
|
+
// question the map's contract asks, not either cause of its answer. Both
|
|
93
|
+
// non-abstractness and a writable source.rdb are required: the entity file
|
|
94
|
+
// routes an object failing EITHER to renderValueObjectFile, which emits no
|
|
95
|
+
// block. The oracle's tableBackedObjects walk makes the same exclusion. Filing
|
|
96
|
+
// an entry without it would only document, in `meta docs`/api-model, a
|
|
97
|
+
// `<Entity>Relations` export that no module emits.
|
|
98
|
+
const rendersRelationsBlock = (storing: MetaObject): boolean =>
|
|
99
|
+
!storing.isAbstract && hasWritableRdbSource(storing);
|
|
100
|
+
|
|
101
|
+
const push = (obj: MetaObject, entry: RelationEntry): void => {
|
|
102
|
+
const storing = tphStorageObject(obj);
|
|
103
|
+
if (!rendersRelationsBlock(storing)) return;
|
|
104
|
+
const key = storing.name;
|
|
105
|
+
const entries = ensure(key);
|
|
106
|
+
const clash = entries.find((e) => e.name === entry.name);
|
|
107
|
+
if (clash !== undefined) {
|
|
108
|
+
const same =
|
|
109
|
+
clash.cardinality === entry.cardinality &&
|
|
110
|
+
clash.targetEntity === entry.targetEntity &&
|
|
111
|
+
clash.fkField === entry.fkField;
|
|
112
|
+
if (!same) {
|
|
113
|
+
onWarn?.(
|
|
114
|
+
`relationship "${entry.name}" on entity "${obj.name}" gets no relations() entry: ` +
|
|
115
|
+
`"${key}" already carries a different "${entry.name}" ` +
|
|
116
|
+
`(-> ${clash.targetEntity} via ${clash.fkField}), and a single-table hierarchy ` +
|
|
117
|
+
`renders ONE relations() block on the base, which cannot hold both. ` +
|
|
118
|
+
`Rename one of them.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
entries.push(entry);
|
|
124
|
+
};
|
|
125
|
+
|
|
67
126
|
for (const obj of root.objects()) {
|
|
68
127
|
// Projections (source.dbView) are view-backed; they never emit a relations()
|
|
69
128
|
// block, and their inherited belongs-to relationships would otherwise register
|
|
70
129
|
// a spurious inverse-many on the target entity.
|
|
71
130
|
if (isProjection(obj)) continue;
|
|
72
131
|
|
|
132
|
+
// An ABSTRACT level's own declarations do not file. Its FK column reaches the
|
|
133
|
+
// base's single table only through a CONCRETE @discriminatorValue descendant
|
|
134
|
+
// (collectTphSubtypeFields folds effective fields per concrete subtype), and
|
|
135
|
+
// that descendant's RESOLVING relationships() walk reaches this same
|
|
136
|
+
// relationship and files the identical entry — the dedupe collapses the
|
|
137
|
+
// copies. With no concrete descendant there is no folded column and no rows to
|
|
138
|
+
// navigate, so absence is the honest output: an entry would make the base's
|
|
139
|
+
// relations() block name a column the table does not have.
|
|
140
|
+
if (obj.isAbstract) continue;
|
|
141
|
+
|
|
73
142
|
for (const child of obj.relationships()) {
|
|
74
143
|
// ADR-0039: resolving — a relationship may inherit @cardinality via extends.
|
|
75
144
|
const cardinality = child.attr(RELATIONSHIP_ATTR_CARDINALITY) as string | undefined;
|
|
@@ -79,7 +148,12 @@ export function buildRelationMap(root: MetaRoot): RelationMap {
|
|
|
79
148
|
// many(junction) navigation on the source.
|
|
80
149
|
// ADR-0039: resolving — @through may be inherited via extends.
|
|
81
150
|
if (cardinality === CARDINALITY_MANY && child.attr(RELATIONSHIP_ATTR_THROUGH) !== undefined) {
|
|
82
|
-
const m2m = buildM2mEntry(obj, child as MetaRelationship, root);
|
|
151
|
+
const m2m = buildM2mEntry(obj, child as MetaRelationship, root, onWarn);
|
|
152
|
+
// NOT re-keyed to the storage base: the ROUTES tier reads this map to mount
|
|
153
|
+
// an M:N under EACH concrete subtype's segment, so it needs the per-subtype
|
|
154
|
+
// entries. Collapsing them onto the base key silently reduces four mounts to
|
|
155
|
+
// one. Only the cardinality-one path below is re-keyed, because that is the
|
|
156
|
+
// one the relations() block renders on the base's module.
|
|
83
157
|
if (m2m) ensure(obj.name).push(m2m);
|
|
84
158
|
continue;
|
|
85
159
|
}
|
|
@@ -106,7 +180,7 @@ export function buildRelationMap(root: MetaRoot): RelationMap {
|
|
|
106
180
|
const fkField = matching.fields[0];
|
|
107
181
|
if (!fkField) continue;
|
|
108
182
|
|
|
109
|
-
|
|
183
|
+
push(obj, {
|
|
110
184
|
name: child.name,
|
|
111
185
|
cardinality: "one",
|
|
112
186
|
targetEntity,
|
|
@@ -131,7 +205,6 @@ export function buildRelationMap(root: MetaRoot): RelationMap {
|
|
|
131
205
|
for (const junctionName of collectJunctionNames(root)) {
|
|
132
206
|
const junction = root.findObject(junctionName);
|
|
133
207
|
if (!junction) continue;
|
|
134
|
-
const entries = ensure(junctionName);
|
|
135
208
|
for (const ref of junction.referenceIdentities()) {
|
|
136
209
|
const targetRaw = ref.targetEntity;
|
|
137
210
|
const fkField = ref.fields[0];
|
|
@@ -143,7 +216,7 @@ export function buildRelationMap(root: MetaRoot): RelationMap {
|
|
|
143
216
|
const refName = ref.name && ref.name.length > 0
|
|
144
217
|
? ref.name
|
|
145
218
|
: variableNameFromEntity(targetEntity);
|
|
146
|
-
|
|
219
|
+
push(junction, {
|
|
147
220
|
name: refName,
|
|
148
221
|
cardinality: "one",
|
|
149
222
|
targetEntity,
|
|
@@ -174,12 +247,16 @@ function collectJunctionNames(root: MetaRoot): Set<string> {
|
|
|
174
247
|
* Build the source-side M:N navigation entry: derive the junction FK fields from
|
|
175
248
|
* the junction's two identity.reference children (the SSOT), handling hetero /
|
|
176
249
|
* directed-self-join / symmetric. Returns null (skips the entry) if derivation
|
|
177
|
-
* fails
|
|
250
|
+
* fails, reporting the derivation's own reason through `onWarn` when supplied —
|
|
251
|
+
* the loader's rules never check subject pairing, so a model can load clean and
|
|
252
|
+
* still carry a junction this pass cannot pair (e.g. one whose identity.reference
|
|
253
|
+
* names a concrete subtype of the declaring entity).
|
|
178
254
|
*/
|
|
179
255
|
function buildM2mEntry(
|
|
180
256
|
source: MetaObject,
|
|
181
257
|
rel: MetaRelationship,
|
|
182
258
|
root: MetaRoot,
|
|
259
|
+
onWarn?: (msg: string) => void,
|
|
183
260
|
): RelationEntry | null {
|
|
184
261
|
// ADR-0039: resolving — @objectRef/@through may be inherited via extends.
|
|
185
262
|
const targetRaw = rel.attr(RELATIONSHIP_ATTR_OBJECT_REF) as string | undefined;
|
|
@@ -188,7 +265,13 @@ function buildM2mEntry(
|
|
|
188
265
|
let fields;
|
|
189
266
|
try {
|
|
190
267
|
fields = deriveM2MFields(rel, source, root);
|
|
191
|
-
} catch {
|
|
268
|
+
} catch (err) {
|
|
269
|
+
onWarn?.(
|
|
270
|
+
`M:N relationship "${rel.name}" on entity "${source.name}" gets no traversal route: its ` +
|
|
271
|
+
`@through junction "${stripPackage(throughRaw)}" could not be paired — ` +
|
|
272
|
+
`${err instanceof Error ? err.message : String(err)}. The model loads, so the run ` +
|
|
273
|
+
`continues, but the endpoint is absent (a 404).`,
|
|
274
|
+
);
|
|
192
275
|
return null;
|
|
193
276
|
}
|
|
194
277
|
return {
|
package/src/runner.ts
CHANGED
|
@@ -486,7 +486,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
|
|
|
486
486
|
|
|
487
487
|
// 3. Build shared render state once.
|
|
488
488
|
const pkMap = buildPkMap(root);
|
|
489
|
-
const relationMap = buildRelationMap(root);
|
|
489
|
+
const relationMap = buildRelationMap(root, (m) => warnings.push(m));
|
|
490
490
|
// ADR-0044/#228 — the ENTITY-tier collision domain is the run's EMITTED
|
|
491
491
|
// `object.value` SET (NOT any per-payload closure): value-object module
|
|
492
492
|
// filenames + `packageOf` are per-run/global, so the emitted-name map is built
|
|
@@ -25,7 +25,8 @@ import { resolveTableSchema } from "@metaobjectsdev/metadata";
|
|
|
25
25
|
import { renderEnumIntCustomType } from "./enum-int-codec.js";
|
|
26
26
|
import { renderRelationsBlock } from "./relations-block.js";
|
|
27
27
|
import { renderDocsFor } from "./jsdoc.js";
|
|
28
|
-
import { collectTphSubtypeFields } from "./tph-discriminator.js";
|
|
28
|
+
import { collectTphSubtypeFields, tphConcreteSubtypes } from "./tph-discriminator.js";
|
|
29
|
+
import { tphStorageObject } from "./zod-validators.js";
|
|
29
30
|
import { effectivePackage } from "../docs-paths.js";
|
|
30
31
|
|
|
31
32
|
/**
|
|
@@ -411,7 +412,19 @@ interface FkInfo {
|
|
|
411
412
|
/** Pre-pass: map fkFieldName → FkInfo for this entity's effective (own + inherited) identity.reference children. */
|
|
412
413
|
function buildFkMapForEntity(obj: MetaObject, ctx: RenderContext): Map<string, FkInfo> {
|
|
413
414
|
const result = new Map<string, FkInfo>();
|
|
414
|
-
|
|
415
|
+
// FR-017 TPH: a discriminator base's table carries every concrete subtype's folded
|
|
416
|
+
// columns (collectTphSubtypeFields), so it carries their FKs too — otherwise a reference
|
|
417
|
+
// declared on a subtype, or on an abstract level between it and the base, lands as a
|
|
418
|
+
// plain column. Same effective, not-already-on-the-base rule as the column fold.
|
|
419
|
+
// A copy: referenceIdentities() is the node's cached array, and loaded metadata is read-only.
|
|
420
|
+
const refs = [...obj.referenceIdentities()];
|
|
421
|
+
const baseRefNames = new Set(refs.map((r) => r.name));
|
|
422
|
+
for (const sub of tphConcreteSubtypes(obj, ctx.loadedRoot)) {
|
|
423
|
+
for (const ref of sub.referenceIdentities()) {
|
|
424
|
+
if (!baseRefNames.has(ref.name)) refs.push(ref);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
for (const ref of refs) {
|
|
415
428
|
// @enforce: false → logical-only reference. Skip the .references() emission;
|
|
416
429
|
// the column stays plain. Drizzle's relations() block (driven by
|
|
417
430
|
// relation-resolver) still includes the relationship for query navigation.
|
|
@@ -419,6 +432,8 @@ function buildFkMapForEntity(obj: MetaObject, ctx: RenderContext): Map<string, F
|
|
|
419
432
|
const fkFieldNames = ref.fields;
|
|
420
433
|
if (fkFieldNames.length === 0) continue;
|
|
421
434
|
const fkField = fkFieldNames[0]!;
|
|
435
|
+
// Two subtypes declaring the same reference fold to one column with one FK.
|
|
436
|
+
if (result.has(fkField)) continue;
|
|
422
437
|
const targetName = ref.targetEntity;
|
|
423
438
|
if (!targetName) continue;
|
|
424
439
|
// @references may be authored bare OR package-qualified, and the loader can
|
|
@@ -427,10 +442,12 @@ function buildFkMapForEntity(obj: MetaObject, ctx: RenderContext): Map<string, F
|
|
|
427
442
|
// relation-resolver, which already does this.
|
|
428
443
|
const targetObj = ctx.loadedRoot.findObject(stripPackage(targetName));
|
|
429
444
|
if (!targetObj) continue;
|
|
445
|
+
// A TPH subtype has no table const of its own; its rows live in the base's table.
|
|
446
|
+
const storage = tphStorageObject(targetObj);
|
|
430
447
|
const targetPkField = ref.resolvedTargetPkField(ctx.loadedRoot) ?? "id";
|
|
431
448
|
result.set(fkField, {
|
|
432
|
-
targetVarName: ctx.collectionName(
|
|
433
|
-
targetEntityName:
|
|
449
|
+
targetVarName: ctx.collectionName(storage.name),
|
|
450
|
+
targetEntityName: storage.name,
|
|
434
451
|
targetPkField,
|
|
435
452
|
});
|
|
436
453
|
}
|
|
@@ -53,7 +53,6 @@ import {
|
|
|
53
53
|
import { inferViewKind, currencyMetaFor, labelFor, humanize, valueObjectFor } from "./field-meta.js";
|
|
54
54
|
import { VIEW_CONTEXT_FORM } from "../view-context.js";
|
|
55
55
|
import { enumValues } from "../enum-meta.js";
|
|
56
|
-
import { isProjection } from "../projection/projection-detector.js";
|
|
57
56
|
// `restPath` lives HERE rather than in api-surface.ts, which is where it used to sit and
|
|
58
57
|
// which now re-exports it. The descriptor is what emits `$path`, so the composition has to
|
|
59
58
|
// be reachable from this module; importing it back from `api-surface.js` would be a cycle,
|
|
@@ -116,30 +115,24 @@ export interface EntityUiDescriptor {
|
|
|
116
115
|
* An object's OWN pluralized resource path — the one derivation of that spelling, and the
|
|
117
116
|
* input {@link restPath} composes an address from. It is NOT `$path` on its own.
|
|
118
117
|
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* `agent/ui.md` and `api/AGENT-API.md` both came to print `/order_summaries` for a
|
|
126
|
-
* projection served at `/order-summaries`.
|
|
118
|
+
* ONE rule for entities and projections alike: snake_case the name, then pluralize.
|
|
119
|
+
* A projection used to be KEBAB-cased here while an entity was SNAKE-cased, composed in
|
|
120
|
+
* the opposite ORDER, and that split was grandfathered rather than designed — the only
|
|
121
|
+
* reason recorded for it was that the projection const had always emitted it. It is
|
|
122
|
+
* collapsed, so `OrderSummary` is `/order_summaries` whichever it is. That renamed every
|
|
123
|
+
* multi-word projection's collection URL; see CHANGELOG.
|
|
127
124
|
*
|
|
128
125
|
* "Subscriber" → "/subscribers"
|
|
129
126
|
* "WorkoutEvent" → "/workout_events"
|
|
130
|
-
* "ProgramSummary" → "/
|
|
127
|
+
* "ProgramSummary" → "/program_summaries" (projection — same rule)
|
|
131
128
|
*
|
|
132
129
|
* A TPH SUBTYPE is not addressed by this path — it is mounted under its base — so this is
|
|
133
130
|
* an INPUT to {@link restPath}, not the answer. `$path` carries `restPath`; call this only
|
|
134
131
|
* when you specifically want an object's own pluralized name, never to build an address.
|
|
135
132
|
*/
|
|
136
133
|
export function resourcePath(entity: MetaData): string {
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
// pluralize is what the entity const has. Neither may be "tidied" into the other.
|
|
140
|
-
return isProjection(entity)
|
|
141
|
-
? `/${toSnakeCase(pluralize(entity.name)).replace(/_/g, "-")}`
|
|
142
|
-
: `/${pluralize(toSnakeCase(entity.name))}`;
|
|
134
|
+
// One rule, every object kind and every port: snake_case, then pluralize.
|
|
135
|
+
return `/${pluralize(toSnakeCase(entity.name))}`;
|
|
143
136
|
}
|
|
144
137
|
|
|
145
138
|
/**
|
|
@@ -8,6 +8,7 @@ import { type RenderContext } from "../render-context.js";
|
|
|
8
8
|
import { crossEntitySpecifier } from "../import-path.js";
|
|
9
9
|
import type { RelationEntry } from "../relation-resolver.js";
|
|
10
10
|
import { effectivePackage } from "../docs-paths.js";
|
|
11
|
+
import { tphStorageName } from "./zod-validators.js";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Render the relations() block for one entity.
|
|
@@ -31,7 +32,7 @@ export function renderRelationsBlock(entity: MetaObject, ctx: RenderContext): Co
|
|
|
31
32
|
|
|
32
33
|
const thisEntityPackage = effectivePackage(entity);
|
|
33
34
|
const lines: Code[] = entries.map((entry) =>
|
|
34
|
-
renderRelationEntry(entry, ctx, varName, thisEntityPackage),
|
|
35
|
+
renderRelationEntry(entry, ctx, entity.name, varName, thisEntityPackage),
|
|
35
36
|
);
|
|
36
37
|
|
|
37
38
|
return code`export const ${relationsVarName} = ${relationsFn}(${varName}, (${params}) => ({
|
|
@@ -43,6 +44,7 @@ ${joinCode(lines, { on: ",\n", trim: false })}
|
|
|
43
44
|
function renderRelationEntry(
|
|
44
45
|
entry: RelationEntry,
|
|
45
46
|
ctx: RenderContext,
|
|
47
|
+
thisEntityName: string,
|
|
46
48
|
thisVarName: string,
|
|
47
49
|
thisEntityPackage: string | undefined,
|
|
48
50
|
): Code {
|
|
@@ -63,18 +65,24 @@ function renderRelationEntry(
|
|
|
63
65
|
return code` ${entry.name}: many(${junctionVarSym})`;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
//
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
68
|
+
// Bind to the table that stores the target's rows: a TPH subtype's module has no
|
|
69
|
+
// table const, so a navigation onto `Carrier` binds `parties` from `Party`.
|
|
70
|
+
const targetTableEntity = tphStorageName(entry.targetEntity, ctx.loadedRoot);
|
|
71
|
+
// A navigation onto this entity's OWN table (`Order.parent`, or a TPH base onto one of
|
|
72
|
+
// its subtypes) names the local const: importing it from this very module clashes with
|
|
73
|
+
// its declaration (TS2440). Otherwise imp() tracks and emits the cross-entity import.
|
|
74
|
+
const targetVarSym = targetTableEntity === thisEntityName
|
|
75
|
+
? thisVarName
|
|
76
|
+
: imp(`${ctx.collectionName(targetTableEntity)}@${crossEntitySpecifier(
|
|
77
|
+
ctx.outputLayout,
|
|
78
|
+
thisEntityPackage,
|
|
79
|
+
ctx.packageOf.get(targetTableEntity),
|
|
80
|
+
targetTableEntity,
|
|
81
|
+
ctx.extStyle,
|
|
82
|
+
)}`);
|
|
75
83
|
|
|
76
84
|
if (entry.cardinality === CARDINALITY_ONE) {
|
|
77
|
-
const pkInfo = ctx.pkMap.get(
|
|
85
|
+
const pkInfo = ctx.pkMap.get(targetTableEntity);
|
|
78
86
|
const targetPkField = pkInfo?.fieldName ?? "id";
|
|
79
87
|
return code` ${entry.name}: one(${targetVarSym}, { fields: [${thisVarName}.${entry.fkField ?? "id"}], references: [${targetVarSym}.${targetPkField}] })`;
|
|
80
88
|
}
|