@metaobjectsdev/codegen-ts-tanstack 0.24.4 → 0.25.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.
Files changed (40) hide show
  1. package/README.md +9 -0
  2. package/dist/index.d.ts +6 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +13 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/reference-templates.d.ts +5 -0
  7. package/dist/reference-templates.d.ts.map +1 -0
  8. package/dist/reference-templates.js +12 -0
  9. package/dist/reference-templates.js.map +1 -0
  10. package/dist/tanstack-grid-hook.d.ts +28 -2
  11. package/dist/tanstack-grid-hook.d.ts.map +1 -1
  12. package/dist/tanstack-grid-hook.js +35 -18
  13. package/dist/tanstack-grid-hook.js.map +1 -1
  14. package/dist/tanstack-grid.d.ts +27 -3
  15. package/dist/tanstack-grid.d.ts.map +1 -1
  16. package/dist/tanstack-grid.js +20 -12
  17. package/dist/tanstack-grid.js.map +1 -1
  18. package/dist/tanstack-query.d.ts +6 -2
  19. package/dist/tanstack-query.d.ts.map +1 -1
  20. package/dist/tanstack-query.js +25 -10
  21. package/dist/tanstack-query.js.map +1 -1
  22. package/dist/templates/columns-file.d.ts.map +1 -1
  23. package/dist/templates/columns-file.js +40 -9
  24. package/dist/templates/columns-file.js.map +1 -1
  25. package/dist/templates/grid-hook-file.js +1 -1
  26. package/dist/templates/grid-hook-file.js.map +1 -1
  27. package/dist/templates/hooks-file.js +16 -16
  28. package/dist/templates/hooks-file.js.map +1 -1
  29. package/package.json +3 -3
  30. package/src/index.ts +19 -0
  31. package/src/reference/grid-hook.ts +154 -0
  32. package/src/reference/grid.ts +117 -0
  33. package/src/reference/hooks.ts +112 -0
  34. package/src/reference-templates.ts +16 -0
  35. package/src/tanstack-grid-hook.ts +61 -18
  36. package/src/tanstack-grid.ts +42 -12
  37. package/src/tanstack-query.ts +34 -10
  38. package/src/templates/columns-file.ts +39 -6
  39. package/src/templates/grid-hook-file.ts +1 -1
  40. package/src/templates/hooks-file.ts +16 -16
@@ -1,10 +1,29 @@
1
1
  import type { MetaObject } from "@metaobjectsdev/metadata";
2
- import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath, entityMetaFileName, renderEntityMetaFile, servesReadApi, isTphSubtype, CODEGEN_ATTR_EMIT_TANSTACK, CODEGEN_ATTR_EMIT_GRID } from "@metaobjectsdev/codegen-ts";
2
+ import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath, entityMetaFileName, renderEntityMetaFile, servesReadApi, isTphSubtype,
3
+ withClientDirective, namesRef, namesConstArg,
4
+ } from "@metaobjectsdev/codegen-ts";
3
5
  import { hasDataGridLayout, warnMissingDataGridLayout } from "./data-grid-gate.js";
4
6
  import { renderGridHookFile } from "./templates/grid-hook-file.js";
5
7
 
6
8
  export interface TanstackGridHookOpts {
7
9
  filter?: (entity: MetaObject) => boolean;
10
+ /**
11
+ * Opt a TPH subtype IN to its own per-subtype grid. Default `() => false`: a
12
+ * discriminator base emits ONE polymorphic grid and its subtypes emit none, which is
13
+ * the single-source-of-truth arrangement almost every TPH model wants.
14
+ *
15
+ * This is an OPTION rather than a `filter` clause because it WIDENS. A `filter` is
16
+ * ANDed with the built-in gates, so it can only ever narrow — an opt-in is not
17
+ * expressible through it. (It is not a metadata attribute either: `@emitGrid` was
18
+ * never registered vocabulary, so authoring it failed `meta verify`.)
19
+ *
20
+ * Pass the SAME predicate to `tanstackGrid()` and `tanstackGridHook()`. If they
21
+ * disagree you reproduce #287 exactly: the hook emits a `<Sub>.grid.ts` whose sibling
22
+ * `<Sub>.columns.tsx` is never emitted — a dangling `use<Sub>DefaultGrid()`, and an
23
+ * outright TS2307 when the inherited layout carries an `@filter` preset, since the hook
24
+ * then imports `<sub>DefaultFilter` from the missing columns module.
25
+ */
26
+ tphSubtypeGrids?: (entity: MetaObject) => boolean;
8
27
  target?: string;
9
28
  }
10
29
 
@@ -12,30 +31,40 @@ export interface TanstackGridHookOpts {
12
31
  * Per-entity generator that emits <Entity>.grid.ts — one
13
32
  * use<Entity><Grid>Grid() hook per layout[dataGrid] declared on the entity.
14
33
  *
15
- * Per-entity opt-out via @emitTanstack: false. Per-entity opt-IN: presence of
16
- * at least one dataGrid layout on the object (mirrors tanstackGrid).
34
+ * Per-entity opt-IN: presence of at least one dataGrid layout on the object
35
+ * (mirrors tanstackGrid).
36
+ *
37
+ * A TPH subtype additionally needs `tphSubtypeGrids` — pass the SAME predicate to
38
+ * tanstackGrid(), or this generator emits a `<Sub>.grid.ts` with no `<Sub>.columns.tsx`
39
+ * beside it.
40
+ *
41
+ * Decide per generator what you consume: wire only the generators whose output you
42
+ * actually import, and narrow this one with its `filter` option. There is no `@emit*`
43
+ * metadata attribute — those were never registered vocabulary, so `meta verify` rejects
44
+ * them (ERR_UNKNOWN_ATTR).
17
45
  */
18
46
  export const tanstackGridHook = function tanstackGridHook(opts?: TanstackGridHookOpts): Generator {
19
47
  const userFilter = opts?.filter ?? (() => true);
48
+ // Default OFF — byte-identical to the behaviour every project that never declared a
49
+ // per-subtype grid already had.
50
+ const tphSubtypeGrids = opts?.tphSubtypeGrids ?? (() => false);
20
51
  // Every gate EXCEPT the dataGrid-layout opt-in: the framework instance-artifact
21
- // guard (skips abstract types), the metadata opt-out, and the user filter. Split
22
- // out so the discoverability note can name exactly the entities the LAYOUT gate
23
- // alone held back (#287).
52
+ // guard (skips abstract types) and the user filter. Split out so the discoverability
53
+ // note can name exactly the entities the LAYOUT gate alone held back (#287).
24
54
  //
25
- // The TPH clause must MATCH tanstackGrid's exactly. A TPH subtype inherits its base's
55
+ // The TPH clause must MATCH tanstackGrid's exactly, which means the SAME
56
+ // `tphSubtypeGrids` predicate must be passed to both. A TPH subtype inherits its base's
26
57
  // dataGrid layout via extends, so `hasDataGridLayout` is true for it — but tanstackGrid
27
- // deliberately emits no per-subtype columns without an own `@emitGrid: true` (the base's
28
- // polymorphic grid is the single source of truth). Without the same clause here, a TPH
29
- // subtype got a `<Sub>.grid.ts` whose sibling `<Sub>.columns.tsx` is never emitted:
30
- // a dangling `use<Sub>DefaultGrid()` with nothing to pair it with, and an outright
31
- // TS2307 when the inherited layout carries an `@filter` preset (the hook then imports
32
- // `<sub>DefaultFilter` from the missing columns module).
58
+ // deliberately emits no per-subtype columns unless `tphSubtypeGrids` opts it in (the
59
+ // base's polymorphic grid is the single source of truth). Where the two predicates
60
+ // disagree, a TPH subtype gets a `<Sub>.grid.ts` whose sibling `<Sub>.columns.tsx` is
61
+ // never emitted: a dangling `use<Sub>DefaultGrid()` with nothing to pair it with, and an
62
+ // outright TS2307 when the inherited layout carries an `@filter` preset (the hook then
63
+ // imports `<sub>DefaultFilter` from the missing columns module).
33
64
  const passesOtherGates = (e: MetaObject): boolean =>
34
65
  servesReadApi(e)
35
- // ADR-0039: resolving — a concrete entity may inherit its @emit* opt-out flag via extends.
36
- && e.attr(CODEGEN_ATTR_EMIT_TANSTACK) !== false
37
66
  && userFilter(e)
38
- && (!isTphSubtype(e) || e.attr(CODEGEN_ATTR_EMIT_GRID) === true);
67
+ && (!isTphSubtype(e) || tphSubtypeGrids(e));
39
68
  const emit = perEntity(async (entity: MetaObject, ctx) => {
40
69
  if (!ctx.renderContext) {
41
70
  throw new Error("tanstack-grid-hook: renderContext is required (provided by runGen)");
@@ -45,17 +74,31 @@ export const tanstackGridHook = function tanstackGridHook(opts?: TanstackGridHoo
45
74
  // the entity generator is scaffold-and-own (ADR-0034), so it cannot be changed
46
75
  // from the package. Emissions are byte-identical between generators, which the
47
76
  // runner collapses (#266).
77
+ //
78
+ // §A6/§B2 — same `namesRef` pair every other §A6 site builds, scoped to THIS
79
+ // generator's own render context (see tanstack-query.ts's matching comment for why
80
+ // `.meta.ts` needs its own target-scoped check rather than reusing the entity
81
+ // module's).
82
+ const rc = ctx.renderContext;
83
+ const metaNames = namesRef(entity, rc);
48
84
  return [{
49
85
  path: entityOutputPath(ctx.renderContext.outputLayout, entity.package,
50
86
  entityMetaFileName(entity.name)),
51
- content: await formatTs(renderEntityMetaFile(entity, ctx.renderContext.apiPrefix)),
87
+ content: await formatTs(renderEntityMetaFile(
88
+ entity,
89
+ ctx.renderContext.apiPrefix,
90
+ namesConstArg(metaNames),
91
+ )),
52
92
  }, {
53
93
  path: entityOutputPath(
54
94
  ctx.renderContext.outputLayout,
55
95
  entity.package,
56
96
  `${entity.name}.grid.ts`,
57
97
  ),
58
- content: await formatTs(renderGridHookFile(entity, ctx.renderContext)),
98
+ content: withClientDirective(
99
+ await formatTs(renderGridHookFile(entity, ctx.renderContext)),
100
+ ctx.renderContext.clientDirective,
101
+ ),
59
102
  }];
60
103
  });
61
104
  const generator: Generator = {
@@ -1,41 +1,71 @@
1
1
  import type { MetaObject } from "@metaobjectsdev/metadata";
2
- import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath, servesReadApi, isTphSubtype, CODEGEN_ATTR_EMIT_TANSTACK, CODEGEN_ATTR_EMIT_GRID } from "@metaobjectsdev/codegen-ts";
2
+ import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath, servesReadApi, isTphSubtype,
3
+ withClientDirective,
4
+ } from "@metaobjectsdev/codegen-ts";
3
5
  import { hasDataGridLayout, warnMissingDataGridLayout } from "./data-grid-gate.js";
4
6
  import { renderColumnsFile } from "./templates/columns-file.js";
5
7
 
6
8
  export interface TanstackGridOpts {
7
9
  filter?: (entity: MetaObject) => boolean;
10
+ /**
11
+ * Opt a TPH subtype IN to its own per-subtype grid. Default `() => false`: a
12
+ * discriminator base emits ONE polymorphic grid and its subtypes emit none, which is
13
+ * the single-source-of-truth arrangement almost every TPH model wants.
14
+ *
15
+ * This is an OPTION rather than a `filter` clause because it WIDENS. A `filter` is
16
+ * ANDed with the built-in gates, so it can only ever narrow — an opt-in is not
17
+ * expressible through it. (It is not a metadata attribute either: `@emitGrid` was
18
+ * never registered vocabulary, so authoring it failed `meta verify`.)
19
+ *
20
+ * Pass the SAME predicate to `tanstackGrid()` and `tanstackGridHook()`. If they
21
+ * disagree you reproduce #287 exactly: the hook emits a `<Sub>.grid.ts` whose sibling
22
+ * `<Sub>.columns.tsx` is never emitted — a dangling `use<Sub>DefaultGrid()`, and an
23
+ * outright TS2307 when the inherited layout carries an `@filter` preset, since the hook
24
+ * then imports `<sub>DefaultFilter` from the missing columns module.
25
+ */
26
+ tphSubtypeGrids?: (entity: MetaObject) => boolean;
8
27
  target?: string;
9
28
  }
10
29
 
11
30
  /**
12
- * Per-entity opt-out via `@emitTanstack: false`. Per-entity opt-IN: presence of
13
- * at least one `dataGrid` layout on the object. If both pass and the user-supplied
14
- * filter passes, the generator emits.
31
+ * Per-entity opt-IN: presence of at least one `dataGrid` layout on the object. If that
32
+ * passes and the user-supplied filter passes, the generator emits.
33
+ *
34
+ * A TPH subtype additionally needs `tphSubtypeGrids` — pass the SAME predicate to
35
+ * tanstackGridHook(), or the emitted `<Sub>.grid.ts` has no `<Sub>.columns.tsx`.
36
+ *
37
+ * Decide per generator what you consume: wire only the generators whose output you
38
+ * actually import, and narrow this one with its `filter` option. There is no `@emit*`
39
+ * metadata attribute — those were never registered vocabulary, so `meta verify` rejects
40
+ * them (ERR_UNKNOWN_ATTR).
15
41
  */
16
42
  export const tanstackGrid = function tanstackGrid(opts?: TanstackGridOpts): Generator {
17
43
  const userFilter = opts?.filter ?? (() => true);
44
+ // Default OFF — byte-identical to the behaviour every project that never declared a
45
+ // per-subtype grid already had.
46
+ const tphSubtypeGrids = opts?.tphSubtypeGrids ?? (() => false);
18
47
  // Every gate EXCEPT the dataGrid-layout opt-in: the framework instance-artifact
19
- // guard (skips abstract types), the metadata opt-out, and the user filter.
48
+ // guard (skips abstract types) and the user filter.
20
49
  // FR-017 Tier 3: a TPH discriminator base emits ONE polymorphic grid. Its
21
50
  // subtypes inherit the base's dataGrid layout via extends, but per-subtype grids
22
- // are opt-IN only (own `@emitGrid: true`) — otherwise the polymorphic grid is the
23
- // single source of truth.
51
+ // are opt-IN only, via the `tphSubtypeGrids` option — otherwise the polymorphic
52
+ // grid is the single source of truth. Pass the SAME predicate to tanstackGridHook().
24
53
  // Split out so the discoverability note can name exactly the entities the LAYOUT
25
- // gate alone held back (#287) — an opted-out or abstract type is not a surprise.
54
+ // gate alone held back (#287) — an abstract type is not a surprise.
26
55
  const passesOtherGates = (e: MetaObject): boolean =>
27
56
  servesReadApi(e)
28
- // ADR-0039: resolving — a concrete entity may inherit its @emit* opt-out flag via extends.
29
- && e.attr(CODEGEN_ATTR_EMIT_TANSTACK) !== false
30
57
  && userFilter(e)
31
- && (!isTphSubtype(e) || e.attr(CODEGEN_ATTR_EMIT_GRID) === true);
58
+ && (!isTphSubtype(e) || tphSubtypeGrids(e));
32
59
  const emit = perEntity(async (entity: MetaObject, ctx) => {
33
60
  if (!ctx.renderContext) {
34
61
  throw new Error("tanstack-grid: renderContext is required (provided by runGen)");
35
62
  }
36
63
  return {
37
64
  path: entityOutputPath(ctx.renderContext.outputLayout, entity.package, `${entity.name}.columns.tsx`),
38
- content: await formatTs(renderColumnsFile(entity, ctx.renderContext)),
65
+ content: withClientDirective(
66
+ await formatTs(renderColumnsFile(entity, ctx.renderContext)),
67
+ ctx.renderContext.clientDirective,
68
+ ),
39
69
  };
40
70
  });
41
71
  const generator: Generator = {
@@ -1,5 +1,7 @@
1
1
  import type { MetaObject } from "@metaobjectsdev/metadata";
2
- import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath, entityMetaFileName, renderEntityMetaFile, servesReadApi, isTphSubtype, CODEGEN_ATTR_EMIT_TANSTACK } from "@metaobjectsdev/codegen-ts";
2
+ import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath, entityMetaFileName, renderEntityMetaFile, servesReadApi, isTphSubtype,
3
+ withClientDirective, namesRef, namesConstArg,
4
+ } from "@metaobjectsdev/codegen-ts";
3
5
  import { renderHooksFile } from "./templates/hooks-file.js";
4
6
 
5
7
  export interface TanstackQueryOpts {
@@ -11,21 +13,24 @@ export interface TanstackQueryOpts {
11
13
  * Per-entity generator that emits <Entity>.hooks.ts — a query-key factory
12
14
  * plus 2 query hooks and 3 mutation hooks backed by useEntityFetcher().
13
15
  *
14
- * Per-entity opt-out via `@emitTanstack: false` is honored. If the user
15
- * supplies their own filter, both must pass (AND).
16
+ * If the user supplies their own filter, it AND-composes with the built-in gates.
17
+ *
18
+ * Decide per generator what you consume: wire only the generators whose output you
19
+ * actually import, and narrow this one with its `filter` option. There is no `@emit*`
20
+ * metadata attribute — those were never registered vocabulary, so `meta verify` rejects
21
+ * them (ERR_UNKNOWN_ATTR).
16
22
  */
17
23
  export const tanstackQuery = function tanstackQuery(opts?: TanstackQueryOpts): Generator {
18
24
  const userFilter = opts?.filter ?? (() => true);
19
25
  const generator: Generator = {
20
26
  name: "tanstack-query",
21
27
  // AND-composes the framework instance-artifact guard (skips abstract types —
22
- // they contribute shape via inheritance only and have no instance to query),
23
- // the metadata opt-out, and the optional user filter. Projections still pass
24
- // here and get read-only hooks via renderHooksFile's isProjection branch.
28
+ // they contribute shape via inheritance only and have no instance to query)
29
+ // with the optional user filter. Projections still pass here and get read-only
30
+ // hooks via renderHooksFile's isProjection branch.
25
31
  // FR-017 Tier 3: TPH subtypes get no standalone hooks file — their per-subtype
26
32
  // hooks live in the discriminator base's hooks file (polymorphic + per-subtype).
27
- // ADR-0039: resolving a concrete entity may inherit @emitTanstack via extends.
28
- filter: (e: MetaObject) => servesReadApi(e) && e.attr(CODEGEN_ATTR_EMIT_TANSTACK) !== false && !isTphSubtype(e) && userFilter(e),
33
+ filter: (e: MetaObject) => servesReadApi(e) && !isTphSubtype(e) && userFilter(e),
29
34
  generate: perEntity(async (entity, ctx) => {
30
35
  if (!ctx.renderContext) {
31
36
  throw new Error(
@@ -36,14 +41,33 @@ export const tanstackQuery = function tanstackQuery(opts?: TanstackQueryOpts): G
36
41
  // generator: the entity generator is scaffold-and-own (ADR-0034), so it cannot
37
42
  // be changed from the package. Emissions are byte-identical between generators,
38
43
  // which the runner collapses (#266).
44
+ //
45
+ // §A6/§B2 — same `namesRef` pair every other §A6 site builds, scoped to THIS
46
+ // generator's own render context: `.meta.ts` is a UI-generator artifact that can
47
+ // sit on a DIFFERENT target from `namesFile()` (unlike the entity module, which
48
+ // shares a target with names by construction — see names-file.ts).
49
+ // `ctx.renderContext.includeNames` is already computed per-target by the runner,
50
+ // so it is false here whenever the names artifact does not land in THIS
51
+ // generator's own target — no separate check needed.
52
+ const rc = ctx.renderContext;
53
+ const metaNames = namesRef(entity, rc);
39
54
  const metaFile = {
40
55
  path: entityOutputPath(ctx.renderContext.outputLayout, entity.package,
41
56
  entityMetaFileName(entity.name)),
42
- content: await formatTs(renderEntityMetaFile(entity, ctx.renderContext.apiPrefix)),
57
+ content: await formatTs(renderEntityMetaFile(
58
+ entity,
59
+ ctx.renderContext.apiPrefix,
60
+ namesConstArg(metaNames),
61
+ )),
43
62
  };
44
63
  return [metaFile, {
45
64
  path: entityOutputPath(ctx.renderContext.outputLayout, entity.package, `${entity.name}.hooks.ts`),
46
- content: await formatTs(renderHooksFile(entity, ctx.renderContext)),
65
+ // Outside formatTs deliberately: the directive must stay the module's first
66
+ // token, and a formatter is entitled to move a leading string expression.
67
+ content: withClientDirective(
68
+ await formatTs(renderHooksFile(entity, ctx.renderContext)),
69
+ ctx.renderContext.clientDirective,
70
+ ),
47
71
  }];
48
72
  }),
49
73
  };
@@ -9,6 +9,7 @@ import {
9
9
  LAYOUT_DATA_GRID_ATTR_FILTER,
10
10
  LAYOUT_DATA_GRID_ATTR_COLUMNS,
11
11
  OBJECT_ATTR_DISCRIMINATOR,
12
+ VIEW_SUBTYPE_HIDDEN,
12
13
  } from "@metaobjectsdev/metadata";
13
14
  import type { RenderContext } from "@metaobjectsdev/codegen-ts";
14
15
  import {
@@ -16,6 +17,9 @@ import {
16
17
  entityModuleSpecifier,
17
18
  isTphDiscriminatorBase,
18
19
  collectTphSubtypeFields,
20
+ isSortableField,
21
+ viewForContext,
22
+ VIEW_CONTEXT_GRID,
19
23
  } from "@metaobjectsdev/codegen-ts";
20
24
 
21
25
  /** FR-017 TPH grid context, threaded into extractGrids when the entity is a
@@ -52,16 +56,27 @@ function humanize(s: string): string {
52
56
  }
53
57
 
54
58
  function fieldViewKind(field: MetaField): string {
59
+ // #356: the view declared for the GRID, never whichever view came first — a
60
+ // field declaring a form control and a grid cell must render both.
55
61
  // ADR-0039: resolving — a field's view may be inherited via extends.
56
- const view = field.views()[0];
62
+ const view = viewForContext(field, VIEW_CONTEXT_GRID);
57
63
  return view?.subType ?? "text";
58
64
  }
59
65
 
60
66
  function fieldLabel(field: MetaField): string {
61
- // ADR-0039: resolving a field's view (and its @label) may be inherited via extends.
62
- const view = field.views()[0];
63
- const label = view?.attr("label");
64
- if (typeof label === "string") return label;
67
+ // #353this read `@label`, which NO provider registers, so the override branch was
68
+ // unreachable: authoring it fails the strict load `meta verify` runs (ERR_UNKNOWN_ATTR)
69
+ // and every header fell back to humanize(). `title` is already a registered common attr
70
+ // on every node and already means "a noun phrase", so nothing was registered for this
71
+ // — ADR-0037 step 0: the vocabulary existed, only the read was wrong.
72
+ // View first, then the field itself: a view-level title is the more specific override
73
+ // (this rendering of the field); a field-level one names the field wherever it appears.
74
+ // ADR-0039: resolving — a field's view (and its @title) may be inherited via extends.
75
+ // #356: the GRID's own view — a form control's @title must not retitle a column.
76
+ const viewTitle = viewForContext(field, VIEW_CONTEXT_GRID)?.attr("title");
77
+ if (typeof viewTitle === "string" && viewTitle.length > 0) return viewTitle;
78
+ const fieldTitle = field.attr("title");
79
+ if (typeof fieldTitle === "string" && fieldTitle.length > 0) return fieldTitle;
65
80
  return humanize(field.name);
66
81
  }
67
82
 
@@ -106,10 +121,28 @@ function extractGrids(entity: MetaObject, tph?: TphGridInfo): GridSpec[] {
106
121
  const columns: ColumnSpec[] = columnNames.flatMap((name) => {
107
122
  const field = fieldsByName.get(name);
108
123
  if (!field) return []; // columns ref that doesn't exist on entity; defensive skip
124
+ const viewKind = fieldViewKind(field);
125
+ // #355 — `view.hidden` is registered as "Not rendered; carried but not shown", and
126
+ // the generated FORM has always honoured that (an <input type="hidden">). The grid
127
+ // did not: no renderer is keyed `hidden`, so EntityGrid's `if (!renderer) return col`
128
+ // fell through to TanStack's default cell and PRINTED the value — the opposite of
129
+ // what the subtype says. Emitting a blank cell would not fix it either: the column
130
+ // would still hold a header and a sort target, so the value would be hidden while
131
+ // the column was not. The column is dropped instead, which is what "not rendered"
132
+ // means. This applies even when @columns names the field: a declaration that the
133
+ // field is a column and a declaration that it is not rendered contradict each other,
134
+ // and the one about RENDERING is the specific answer to the question a grid asks.
135
+ if (viewKind === VIEW_SUBTYPE_HIDDEN) return [];
109
136
  const spec: ColumnSpec = {
110
137
  id: name,
111
138
  header: fieldLabel(field),
112
- viewKind: fieldViewKind(field),
139
+ viewKind,
140
+ // #352 — ALWAYS emitted, never left absent. EntityGrid gates on
141
+ // `meta?.sortable !== false`, so an omitted flag reads as "offer it" and the
142
+ // header renders clickable for a field the server's SortAllowlist rejects
143
+ // (400 `sort.unknown_field`). isSortableField is the SAME predicate that builds
144
+ // that allowlist — imported (#354), never reimplemented, so the two cannot drift.
145
+ sortable: isSortableField(field),
113
146
  };
114
147
  // FR-017: the discriminator column renders as a subtype badge.
115
148
  if (tph && name === tph.discField) spec.renderer = "badge";
@@ -138,7 +138,7 @@ export function ${hookName}() {
138
138
  const query = ${useQuerySym}<{ rows: ${entityName}Row[]; total: number }>({
139
139
  queryKey: [${JSON.stringify(lcEntity)}, "grid", ${JSON.stringify(grid.name)}, qs],
140
140
  queryFn: () => fetcher<{ rows: ${entityName}Row[]; total: number }>(
141
- \`\${${entityName}.$apiPrefix}\${${entityName}.$path}?\${qs}\`,
141
+ \`\${${entityName}.$path}?\${qs}\`,
142
142
  ),
143
143
  });
144
144
 
@@ -144,7 +144,7 @@ export function ${hookName}(
144
144
  const fetcher = ${useEntityFetcherSym}();
145
145
  return ${useQuerySym}<${targetSym}[]>({
146
146
  queryKey: ${keysVar}.relation(${relLit}, sourceId),
147
- queryFn: () => fetcher<${targetSym}[]>(\`\${${source}.$apiPrefix}\${${source}.$path}/\${sourceId}/${e.name}\`),
147
+ queryFn: () => fetcher<${targetSym}[]>(\`\${${source}.$path}/\${sourceId}/${e.name}\`),
148
148
  enabled: sourceId != null && (opts?.enabled ?? true),
149
149
  ...opts,
150
150
  });
@@ -206,7 +206,7 @@ export function use${entityName}(
206
206
  const fetcher = ${useEntityFetcherSym}();
207
207
  return ${useQuerySym}<${entityName}Row>({
208
208
  queryKey: ${keysVar}.detail(id),
209
- queryFn: () => fetcher<${entityName}Row>(\`\${${entityName}.$apiPrefix}\${${entityName}.$path}/\${id}\`),
209
+ queryFn: () => fetcher<${entityName}Row>(\`\${${entityName}.$path}/\${id}\`),
210
210
  ...opts,
211
211
  });
212
212
  }
@@ -219,7 +219,7 @@ export function use${entityNamePlural}(
219
219
  const qs = filter ? "?" + ${buildFilterQsSym}(filter as Record<string, unknown>) : "";
220
220
  return ${useQuerySym}<${entityName}Row[]>({
221
221
  queryKey: ${keysVar}.list(filter),
222
- queryFn: () => fetcher<${entityName}Row[]>(\`\${${entityName}.$apiPrefix}\${${entityName}.$path}\${qs}\`),
222
+ queryFn: () => fetcher<${entityName}Row[]>(\`\${${entityName}.$path}\${qs}\`),
223
223
  ...opts,
224
224
  });
225
225
  }
@@ -287,7 +287,7 @@ export function use${entityName}(
287
287
  const fetcher = ${useEntityFetcherSym}();
288
288
  return ${useQuerySym}<${entityName}Row>({
289
289
  queryKey: ${keysVar}.detail(id),
290
- queryFn: () => fetcher<${entityName}Row>(\`\${${entityName}.$apiPrefix}\${${entityName}.$path}/\${id}\`),
290
+ queryFn: () => fetcher<${entityName}Row>(\`\${${entityName}.$path}/\${id}\`),
291
291
  ...opts,
292
292
  });
293
293
  }
@@ -300,7 +300,7 @@ export function use${entityNamePlural}(
300
300
  const qs = filter ? "?" + ${buildFilterQsSym}(filter as Record<string, unknown>) : "";
301
301
  return ${useQuerySym}<${entityName}Row[]>({
302
302
  queryKey: ${keysVar}.list(filter),
303
- queryFn: () => fetcher<${entityName}Row[]>(\`\${${entityName}.$apiPrefix}\${${entityName}.$path}\${qs}\`),
303
+ queryFn: () => fetcher<${entityName}Row[]>(\`\${${entityName}.$path}\${qs}\`),
304
304
  ...opts,
305
305
  });
306
306
  }
@@ -315,7 +315,7 @@ export function useCreate${entityName}(
315
315
  const fetcher = ${useEntityFetcherSym}();
316
316
  const qc = ${useQueryClientSym}();
317
317
  return ${useMutationSym}<${entityName}Row, Error, ${entityName}Insert>({
318
- mutationFn: (input) => fetcher<${entityName}Row>(\`\${${entityName}.$apiPrefix}\${${entityName}.$path}\`, {
318
+ mutationFn: (input) => fetcher<${entityName}Row>(\`\${${entityName}.$path}\`, {
319
319
  method: "POST",
320
320
  headers: { "Content-Type": "application/json" },
321
321
  body: JSON.stringify(input),
@@ -334,7 +334,7 @@ export function useUpdate${entityName}(
334
334
  const fetcher = ${useEntityFetcherSym}();
335
335
  const qc = ${useQueryClientSym}();
336
336
  return ${useMutationSym}({
337
- mutationFn: ({ id, input }) => fetcher<${entityName}Row>(\`\${${entityName}.$apiPrefix}\${${entityName}.$path}/\${id}\`, {
337
+ mutationFn: ({ id, input }) => fetcher<${entityName}Row>(\`\${${entityName}.$path}/\${id}\`, {
338
338
  method: "PATCH",
339
339
  headers: { "Content-Type": "application/json" },
340
340
  body: JSON.stringify(input),
@@ -353,7 +353,7 @@ export function useDelete${entityName}(
353
353
  const fetcher = ${useEntityFetcherSym}();
354
354
  const qc = ${useQueryClientSym}();
355
355
  return ${useMutationSym}({
356
- mutationFn: (id) => fetcher<void>(\`\${${entityName}.$apiPrefix}\${${entityName}.$path}/\${id}\`, { method: "DELETE" }),
356
+ mutationFn: (id) => fetcher<void>(\`\${${entityName}.$path}/\${id}\`, { method: "DELETE" }),
357
357
  ...opts,
358
358
  onSuccess: (...args) => {
359
359
  qc.invalidateQueries({ queryKey: ${keysVar}.all() });
@@ -401,7 +401,7 @@ function renderTphHooksFile(base: MetaObject, ctx: RenderContext, baseModule: st
401
401
 
402
402
  const subtypes = plan.subtypes;
403
403
 
404
- // `${baseName}` imports BOTH the constants value (for $path/$apiPrefix) and the
404
+ // `${baseName}` imports BOTH the constants value (for $path) and the
405
405
  // discriminated-union type (declaration merge). Each subtype contributes its
406
406
  // interface type AND its own filter type (discriminator-excluded — the route
407
407
  // pins it), so per-subtype hooks filter on the fields the per-subtype
@@ -442,7 +442,7 @@ export function use${baseName}(
442
442
  const fetcher = ${useEntityFetcherSym}();
443
443
  return ${useQuerySym}<${baseName}>({
444
444
  queryKey: ${keysVar}.detail(id),
445
- queryFn: () => fetcher<${baseName}>(\`\${${baseName}.$apiPrefix}\${${baseName}.$path}/\${id}\`),
445
+ queryFn: () => fetcher<${baseName}>(\`\${${baseName}.$path}/\${id}\`),
446
446
  ...opts,
447
447
  });
448
448
  }
@@ -455,7 +455,7 @@ export function use${pluralize(baseName)}(
455
455
  const qs = filter ? "?" + ${buildFilterQsSym}(filter as Record<string, unknown>) : "";
456
456
  return ${useQuerySym}<${baseName}[]>({
457
457
  queryKey: ${keysVar}.list(filter),
458
- queryFn: () => fetcher<${baseName}[]>(\`\${${baseName}.$apiPrefix}\${${baseName}.$path}\${qs}\`),
458
+ queryFn: () => fetcher<${baseName}[]>(\`\${${baseName}.$path}\${qs}\`),
459
459
  ...opts,
460
460
  });
461
461
  }
@@ -467,7 +467,7 @@ export function use${pluralize(baseName)}(
467
467
  const valueLit = JSON.stringify(value);
468
468
  const createInput = `Omit<${subName}, ${JSON.stringify(discField)}>`;
469
469
  const updateInput = `Partial<${createInput}>`;
470
- const subPath = `\`\${${baseName}.$apiPrefix}\${${baseName}.$path}/${seg}\``;
470
+ const subPath = `\`\${${baseName}.$path}/${seg}\``;
471
471
  return code`
472
472
  export function use${pluralize(subName)}(
473
473
  filter?: ${subName}Filter,
@@ -477,7 +477,7 @@ export function use${pluralize(subName)}(
477
477
  const qs = filter ? "?" + ${buildFilterQsSym}(filter as Record<string, unknown>) : "";
478
478
  return ${useQuerySym}<${subName}[]>({
479
479
  queryKey: ${keysVar}.subtypeList(${valueLit}, filter),
480
- queryFn: () => fetcher<${subName}[]>(\`\${${baseName}.$apiPrefix}\${${baseName}.$path}/${seg}\${qs}\`),
480
+ queryFn: () => fetcher<${subName}[]>(\`\${${baseName}.$path}/${seg}\${qs}\`),
481
481
  ...opts,
482
482
  });
483
483
  }
@@ -489,7 +489,7 @@ export function use${subName}(
489
489
  const fetcher = ${useEntityFetcherSym}();
490
490
  return ${useQuerySym}<${subName}>({
491
491
  queryKey: ${keysVar}.subtypeDetail(${valueLit}, id),
492
- queryFn: () => fetcher<${subName}>(\`\${${baseName}.$apiPrefix}\${${baseName}.$path}/${seg}/\${id}\`),
492
+ queryFn: () => fetcher<${subName}>(\`\${${baseName}.$path}/${seg}/\${id}\`),
493
493
  ...opts,
494
494
  });
495
495
  }
@@ -519,7 +519,7 @@ export function useUpdate${subName}(
519
519
  const fetcher = ${useEntityFetcherSym}();
520
520
  const qc = ${useQueryClientSym}();
521
521
  return ${useMutationSym}({
522
- mutationFn: ({ id, input }) => fetcher<${subName}>(\`\${${baseName}.$apiPrefix}\${${baseName}.$path}/${seg}/\${id}\`, {
522
+ mutationFn: ({ id, input }) => fetcher<${subName}>(\`\${${baseName}.$path}/${seg}/\${id}\`, {
523
523
  method: "PATCH",
524
524
  headers: { "Content-Type": "application/json" },
525
525
  body: JSON.stringify(input),
@@ -538,7 +538,7 @@ export function useDelete${subName}(
538
538
  const fetcher = ${useEntityFetcherSym}();
539
539
  const qc = ${useQueryClientSym}();
540
540
  return ${useMutationSym}({
541
- mutationFn: (id) => fetcher<void>(\`\${${baseName}.$apiPrefix}\${${baseName}.$path}/${seg}/\${id}\`, { method: "DELETE" }),
541
+ mutationFn: (id) => fetcher<void>(\`\${${baseName}.$path}/${seg}/\${id}\`, { method: "DELETE" }),
542
542
  ...opts,
543
543
  onSuccess: (...args) => {
544
544
  qc.invalidateQueries({ queryKey: ${keysVar}.all() });