domain-driver 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # domain-driver 🚀
2
2
 
3
- A CLI scaffolding tool for domain-driven feature folders. Like Laravel's `php artisan make`, but for Next.js, React, Node, and NestJS projects. It detects your stack and generates only the layers that stack needs, one file per action.
3
+ A CLI scaffolding tool for domain-driven feature folders. Like Laravel's `php artisan make`, but for Next.js, React, Node, NestJS, and TanStack Start projects. It detects your stack and generates only the layers that stack needs, one file per action.
4
4
 
5
5
  ---
6
6
 
@@ -29,6 +29,7 @@ Stack: next-fullstack (detected)
29
29
  | Stack | Detected when | Features live in |
30
30
  |---|---|---|
31
31
  | `nest` | `@nestjs/core` is a dependency | `src/<feature>` |
32
+ | `tanstack-start` | `@tanstack/react-start` is a dependency | `src/routes/<feature>` or `routes/<feature>` |
32
33
  | `next-fullstack` | `next` is a dependency and `app/api`, `src/app/api`, `pages/api`, or `src/pages/api` exists | `app/<feature>` or `src/app/<feature>` |
33
34
  | `next-frontend` | `next` is a dependency, no api directory | `app/<feature>` or `src/app/<feature>` |
34
35
  | `react` | `react` is a dependency, `next` is not | `src/features/<feature>` or `features/<feature>` |
@@ -46,19 +47,19 @@ domain-driver --stack nest make:feature cat -a
46
47
 
47
48
  ## What each stack generates
48
49
 
49
- | Layer | next-fullstack | next-frontend | react | node | nest |
50
- |---|---|---|---|---|---|
51
- | `page.tsx` | yes | yes | | | |
52
- | components | client + server | client + server | flat | | |
53
- | containers, hooks | yes | yes | yes | | |
54
- | client services + repositories (fetch) | `client/` | top level | top level | | |
55
- | server services + repositories (database stubs) | `server/` | | | yes | yes |
56
- | controllers | `app/api/<feature>/` route handlers | | | five files + routes file | five files |
57
- | module | | | | | yes |
58
- | DTOs (`nestjs-zod`) | | | | | yes |
59
- | schemas (Zod), types | yes | yes | yes | yes | yes |
50
+ | Layer | next-fullstack | next-frontend | react | node | nest | tanstack-start |
51
+ |---|---|---|---|---|---|---|
52
+ | entry file (`page.tsx`; `index.tsx` on tanstack-start) | yes | yes | | | | yes |
53
+ | components | client + server | client + server | flat | | | flat |
54
+ | containers, hooks | yes | yes | yes | | | yes (Query hooks) |
55
+ | client services + repositories | `client/` (fetch) | top level (fetch) | top level (fetch) | | | `-client/` (calls server functions, no fetch) |
56
+ | server services + repositories (database stubs) | `server/` | | | yes | yes | `-server/` |
57
+ | controllers | `app/api/<feature>/` route handlers | | | five files + routes file | five files | server functions (`-server/functions/*.fn.ts`) |
58
+ | module | | | | | yes | |
59
+ | DTOs (`nestjs-zod`) | | | | | yes | |
60
+ | schemas (Zod), types | yes | yes | yes | yes | yes | yes |
60
61
 
61
- Every layer that has actions gets one file per action: `List`, `Show`, `Create`, `Update`, `Delete`. Saving a cat means `CreateCat.controller.ts`, `CreateCat.service.ts`, `CreateCat.repository.ts`, and `CreateCat.schema.ts`.
62
+ Every layer that has actions gets one file per action: `List`, `Show`, `Create`, `Update`, `Delete`. Saving a cat means `CreateCat.controller.ts`, `CreateCat.service.ts`, `CreateCat.repository.ts`, `CreateCat.schema.ts`, and, on stacks with a hook layer, `CreateCat.hook.ts` exporting `useCreateCat`.
62
63
 
63
64
  ---
64
65
 
@@ -69,7 +70,7 @@ Every layer command takes one target, `<feature>/<Name>`: the feature folder on
69
70
  ### `make:feature`
70
71
 
71
72
  ```bash
72
- domain-driver make:feature users # folders + .gitkeep, plus page.tsx (Next) or the module (Nest)
73
+ domain-driver make:feature users # folders + .gitkeep, plus page.tsx (Next), index.tsx (TanStack Start), or the module (Nest)
73
74
  domain-driver make:feature users/User -a # every layer for the detected stack, entity User
74
75
  ```
75
76
 
@@ -95,8 +96,9 @@ Scaffolds a bespoke operation as its own files, so it never lands inside `ShowUs
95
96
  |---|---|
96
97
  | `node` | service, repository, controller for the detected framework, plus the line to add above the `/:id` routes in `<feature>.routes.ts` printed |
97
98
  | `nest` | injectable service and repository, `@Controller` class, DTO with input, plus the classes to register printed, with the controller listed before `Show<Entity>Controller` |
98
- | `next-fullstack` | server service and repository, client service and repository, `app/api/<feature>/<slug>/route.ts` |
99
- | `next-frontend`, `react` | client service and repository calling `/api/<feature>/<slug>` |
99
+ | `next-fullstack` | server service and repository, client service and repository, a hook, `app/api/<feature>/<slug>/route.ts` |
100
+ | `next-frontend`, `react` | client service and repository calling `/api/<feature>/<slug>`, and a hook |
101
+ | `tanstack-start` | server service and repository, client service and repository, a hook, and a server function (`<Name>.fn.ts`) in place of a route handler |
100
102
 
101
103
  ### `make:controller`
102
104
 
@@ -104,7 +106,7 @@ Scaffolds a bespoke operation as its own files, so it never lands inside `ShowUs
104
106
  domain-driver make:controller users/User
105
107
  ```
106
108
 
107
- Node: five controllers plus `<feature>.routes.ts` for Express, Fastify, or Hono. Nest: five single-action controllers. Next.js fullstack: `app/api/<feature>/route.ts` and `app/api/<feature>/[id]/route.ts`. Not available on frontend-only stacks.
109
+ Node: five controllers plus `<feature>.routes.ts` for Express, Fastify, or Hono. Nest: five single-action controllers. Next.js fullstack: `app/api/<feature>/route.ts` and `app/api/<feature>/[id]/route.ts`. TanStack Start: five server functions (`<Name>.fn.ts`, built with `createServerFn`) under `-server/functions/`. Not available on frontend-only stacks (`next-frontend`, `react`).
108
110
 
109
111
  ### `make:service` and `make:repository`
110
112
 
@@ -113,7 +115,7 @@ domain-driver make:service users/User [--side client|server|both]
113
115
  domain-driver make:repository users/User [--side client|server|both]
114
116
  ```
115
117
 
116
- `--side` matters on `next-fullstack`, where both sides exist. Default is `both`.
118
+ `--side` matters on `next-fullstack` and `tanstack-start`, where both sides exist. Default is `both`.
117
119
 
118
120
  ### `make:schema`
119
121
 
@@ -129,11 +131,27 @@ Writes `CreateUser.schema.ts` and `UpdateUser.schema.ts`. On Nest it also writes
129
131
  domain-driver make:types users/User
130
132
  domain-driver make:component users/UserCard [client|server]
131
133
  domain-driver make:container users/UserContainer
132
- domain-driver make:hook users/useUser
134
+ domain-driver make:hook users/User
133
135
  ```
134
136
 
135
137
  Component, container, and hook commands fail with a clear message on backend stacks, and `server` components are rejected on React.
136
138
 
139
+ `make:hook` writes one file per action instead of a combined hook: `ListUser.hook.ts`, `ShowUser.hook.ts`, `CreateUser.hook.ts`, `UpdateUser.hook.ts`, `DeleteUser.hook.ts`, each exporting one hook (`useListUser`, `useShowUser`, `useCreateUser`, `useUpdateUser`, `useDeleteUser`). There is no combined `useUser.ts`. On `react`, `next-frontend`, and `next-fullstack` these hold plain React state; on `tanstack-start` they are TanStack Query hooks backed by a generated `<feature>.keys.ts` cache-key module. Generating hooks on this profile prints a one-time reminder to install `@tanstack/react-query`, since TanStack Start does not bundle it.
140
+
141
+ Because the five hooks no longer share state, containers wire them together. On `react`, `next-frontend`, and `next-fullstack`, where hooks hold plain state:
142
+
143
+ ```tsx
144
+ const { data, loading, error, refetch } = useListCat();
145
+ const { createCat } = useCreateCat({ onSuccess: refetch });
146
+ ```
147
+
148
+ On `tanstack-start`, `useListCat()` returns the `useQuery` result (`data`, `isPending`, `error`, ...) and `useCreateCat()` returns the `useMutation` result (`mutate`, `mutateAsync`, `isPending`, ...) directly — there is no `createCat` property and no `onSuccess` option; the hook invalidates the query cache internally on success:
149
+
150
+ ```tsx
151
+ const { data, isPending, error } = useListCat();
152
+ const { mutate: createCat } = useCreateCat();
153
+ ```
154
+
137
155
  ### `init`
138
156
 
139
157
  ```bash
@@ -192,14 +210,42 @@ Install `nestjs-zod` and register `ZodValidationPipe` as `APP_PIPE` once in your
192
210
 
193
211
  ---
194
212
 
213
+ ## Example: `make:feature coffee-type -a` on TanStack Start
214
+
215
+ ```
216
+ src/routes/coffee-type/
217
+ ├── index.tsx createFileRoute route — the entry, not page.tsx
218
+ ├── -components/
219
+ │ └── CoffeeType.tsx
220
+ ├── -containers/
221
+ │ └── CoffeeTypeContainer.tsx
222
+ ├── -hooks/ coffee-type.keys.ts, plus five hook files (one per action)
223
+ ├── -client/
224
+ │ ├── services/ (five files)
225
+ │ └── repositories/ (five files, call the server functions directly)
226
+ ├── -server/
227
+ │ ├── functions/ (five *.fn.ts files, built with createServerFn)
228
+ │ ├── services/ (five files)
229
+ │ └── repositories/ (five files, database-agnostic stubs)
230
+ ├── -schemas/
231
+ │ ├── CreateCoffeeType.schema.ts
232
+ │ └── UpdateCoffeeType.schema.ts
233
+ └── -types/
234
+ └── CoffeeType.types.ts
235
+ ```
236
+
237
+ Every layer directory carries a `-` prefix so TanStack Router excludes it from routing. The controller layer is server functions — `<Name>.fn.ts` built with `createServerFn` — and client repositories import and call them directly, so there is no `fetch` and no `Response.json`. Hooks are TanStack Query, backed by the generated `coffee-type.keys.ts`. Generating hooks on this profile prints a one-time reminder to install `@tanstack/react-query`, since TanStack Start does not bundle it.
238
+
239
+ ---
240
+
195
241
  ## Philosophy
196
242
 
197
243
  Everything for a feature lives in one folder, and every file does one thing.
198
244
 
199
- - **repositories** — data access only. Client-side repositories call your API; server-side repositories call your database.
245
+ - **repositories** — data access only. Client-side repositories call your API (on TanStack Start, the server function directly); server-side repositories call your database.
200
246
  - **services** — business logic, one class per action.
201
- - **controllers** — HTTP in, service call, HTTP out, one file per action.
202
- - **hooks** — React state and side effects, calls services.
247
+ - **controllers** — HTTP in, service call, HTTP out, one file per action (server functions on TanStack Start).
248
+ - **hooks** — state and side effects, one hook per action, calls services (TanStack Query on TanStack Start, plain React state elsewhere).
203
249
  - **containers** — wire hooks into UI.
204
250
  - **components** — presentational UI.
205
251
  - **schemas** — Zod validation for create and update; the update body never carries the id, it comes from the path.
@@ -231,6 +277,19 @@ The cache lives in `~/.cache/domain-driver` (or `$XDG_CACHE_HOME/domain-driver`)
231
277
 
232
278
  ---
233
279
 
280
+ ## Upgrading from 0.3.x
281
+
282
+ Hooks are now one file per action — `<Action><Entity>.hook.ts` exporting `use<Action><Entity>` (`useListCat`, `useCreateCat`, ...) — instead of a single combined `use<Entity>.ts`, which is no longer generated. `make:hook` now takes `<feature>/<Entity>`, not `<feature>/use<Entity>`. `make:action` writes a matching hook alongside the service and repository on any stack that has a hook layer.
283
+
284
+ domain-driver never overwrites a file that already exists, so this only changes new scaffolding: a `use<Entity>.ts` written by an older version is left alone and keeps working. New features and new actions get the per-action hooks; wire them together in the container, since they no longer share state:
285
+
286
+ ```tsx
287
+ const { data, loading, error, refetch } = useListCat();
288
+ const { createCat } = useCreateCat({ onSuccess: refetch });
289
+ ```
290
+
291
+ This release also adds a sixth stack, `tanstack-start`, detected from `@tanstack/react-start` — see the TanStack Start example above.
292
+
234
293
  ## Upgrading from 0.1.0
235
294
 
236
295
  Every layer command now takes a single `<feature>/<Name>` target instead of separate feature and name arguments, for example `make:schema users User` becomes `make:schema users/User`. `make:feature users -a` still works and names the entity `Users`; write `users/User` if you want a different entity name.
@@ -239,7 +298,7 @@ Every layer command now takes a single `<feature>/<Name>` target instead of sepa
239
298
 
240
299
  ## Requirements
241
300
 
242
- - Node.js 18+
301
+ - Node.js 20+
243
302
 
244
303
  ---
245
304
 
@@ -255,6 +314,8 @@ npm test
255
314
  npm run test:coverage
256
315
  ```
257
316
 
317
+ `npm run typecheck:tanstack-fixture` compiles a generated TanStack Start feature against the real `@tanstack/react-start` and `@tanstack/react-query` packages to catch drift between the templates and the real APIs. It is network-dependent and slow, so it is not part of `npm test` — run it locally after touching the `tanstack-start` profile or its templates.
318
+
258
319
  ---
259
320
 
260
321
  ## Roadmap
@@ -264,6 +325,8 @@ npm run test:coverage
264
325
  - [x] Per-action files in every layer
265
326
  - [x] Bespoke actions with make:action
266
327
  - [x] Agent guidance with init and a guarded postinstall
328
+ - [x] TanStack Start stack, with server functions and per-action query hooks
329
+ - [x] Per-action hooks on every stack that has them, replacing the single combined hook
267
330
  - [ ] Config file — override stack and feature root per project
268
331
  - [ ] Configurable API base URL for client repositories
269
332
  - [ ] ORM-aware server repositories
package/dist/cli.js CHANGED
@@ -126,7 +126,7 @@ function createProgram(deps) {
126
126
  });
127
127
  program
128
128
  .command('make:hook <target>')
129
- .description('Scaffold a custom hook inside an existing feature (<feature>/<useName>)')
129
+ .description('Scaffold single-responsibility hook files inside an existing feature (<feature>/<Entity>)')
130
130
  .action((target) => {
131
131
  const { feature, name } = (0, target_1.parseTarget)(target);
132
132
  (0, hook_1.makeHook)(feature, name);
@@ -39,17 +39,18 @@ const path = __importStar(require("path"));
39
39
  const registry_1 = require("../stack/registry");
40
40
  const actions_1 = require("../templates/actions");
41
41
  const server_repository_1 = require("../templates/backend/server-repository");
42
- const nest_1 = require("../templates/controllers/nest");
43
42
  const next_action_route_1 = require("../templates/controllers/next-action-route");
44
43
  const node_1 = require("../templates/controllers/node");
45
- const client_repository_1 = require("../templates/frontend/client-repository");
46
44
  const dto_1 = require("../templates/nest/dto");
47
45
  const service_1 = require("../templates/service");
48
46
  const schema_1 = require("../templates/shared/schema");
49
47
  const fs_1 = require("../utils/fs");
50
48
  const naming_1 = require("../utils/naming");
51
49
  const paths_1 = require("../utils/paths");
50
+ const controller_renderer_1 = require("./controller-renderer");
52
51
  const hints_1 = require("./hints");
52
+ const hook_renderer_1 = require("./hook-renderer");
53
+ const repository_1 = require("./repository");
53
54
  const resolve_1 = require("./resolve");
54
55
  const write_1 = require("./write");
55
56
  function parseReturns(value) {
@@ -70,6 +71,8 @@ function makeAction(feature, entity, actionName, options) {
70
71
  wroteAny = writeController(ctx, spec, entity) || wroteAny;
71
72
  if ((0, registry_1.hasLayer)(ctx.profile, 'clientRepository'))
72
73
  wroteAny = writeSide(ctx, spec, entity, 'client') || wroteAny;
74
+ if ((0, registry_1.hasLayer)(ctx.profile, 'hook'))
75
+ wroteAny = writeHook(ctx, spec, entity) || wroteAny;
73
76
  if (wroteAny)
74
77
  console.log(`✅ Action "${spec.name}" scaffolded in "${feature}"`);
75
78
  return wroteAny;
@@ -93,13 +96,23 @@ function writeInput(ctx, spec) {
93
96
  function writeSide(ctx, spec, entity, side) {
94
97
  const repositoryLayer = side === 'client' ? 'clientRepository' : 'serverRepository';
95
98
  const serviceLayer = side === 'client' ? 'clientService' : 'serverService';
96
- const renderRepository = side === 'client' ? client_repository_1.renderClientRepository : server_repository_1.renderServerRepository;
99
+ const renderRepository = side === 'client' ? (0, repository_1.clientRenderer)(ctx) : server_repository_1.renderServerRepository;
97
100
  const repositoryFile = path.join((0, resolve_1.ensureLayerDir)(ctx, repositoryLayer), `${spec.name}.repository.ts`);
98
101
  const wroteRepository = (0, write_1.writeIfAbsent)(repositoryFile, () => renderRepository(ctx, spec, entity, repositoryFile));
99
102
  const serviceFile = path.join((0, resolve_1.ensureLayerDir)(ctx, serviceLayer), `${spec.name}.service.ts`);
100
103
  const wroteService = (0, write_1.writeIfAbsent)(serviceFile, () => (0, service_1.renderService)(ctx, spec, entity, serviceFile, side));
101
104
  return wroteRepository || wroteService;
102
105
  }
106
+ function writeHook(ctx, spec, entity) {
107
+ const dir = (0, resolve_1.ensureLayerDir)(ctx, 'hook');
108
+ (0, hook_renderer_1.ensureQueryKeys)(ctx, dir);
109
+ const render = (0, hook_renderer_1.hookRenderer)(ctx);
110
+ const filePath = path.join(dir, `${spec.name}.hook.ts`);
111
+ const wrote = (0, write_1.writeIfAbsent)(filePath, () => render(ctx, spec, entity, filePath));
112
+ if (wrote)
113
+ (0, hints_1.hintReactQuery)(ctx.profile);
114
+ return wrote;
115
+ }
103
116
  function writeController(ctx, spec, entity) {
104
117
  if (ctx.profile.name === 'next-fullstack') {
105
118
  const routeDir = path.join((0, paths_1.apiRouteDir)(ctx.stack, ctx.feature), spec.path.slice(1));
@@ -107,8 +120,9 @@ function writeController(ctx, spec, entity) {
107
120
  const routeFile = path.join(routeDir, 'route.ts');
108
121
  return (0, write_1.writeIfAbsent)(routeFile, () => (0, next_action_route_1.renderActionRoute)(ctx, spec, routeFile));
109
122
  }
110
- const controllerFile = path.join((0, resolve_1.ensureLayerDir)(ctx, 'controller'), `${spec.name}.controller.ts`);
111
- const render = ctx.profile.name === 'nest' ? nest_1.renderNestController : node_1.renderNodeController;
123
+ const suffix = (0, controller_renderer_1.controllerSuffix)(ctx.profile.name);
124
+ const controllerFile = path.join((0, resolve_1.ensureLayerDir)(ctx, 'controller'), `${spec.name}.${suffix}.ts`);
125
+ const render = (0, controller_renderer_1.controllerRenderer)(ctx.profile.name);
112
126
  const wroteController = (0, write_1.writeIfAbsent)(controllerFile, () => render(ctx, spec, entity, controllerFile));
113
127
  const line = ctx.profile.name === 'node' ? (0, node_1.renderNodeRouteLine)(ctx, spec, entity) : null;
114
128
  if (wroteController && line !== null) {
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.controllerRenderer = controllerRenderer;
4
+ exports.controllerSuffix = controllerSuffix;
5
+ const nest_1 = require("../templates/controllers/nest");
6
+ const node_1 = require("../templates/controllers/node");
7
+ const server_fn_1 = require("../templates/controllers/server-fn");
8
+ const RENDERERS = Object.freeze({
9
+ nest: nest_1.renderNestController,
10
+ node: node_1.renderNodeController,
11
+ 'tanstack-start': server_fn_1.renderServerFn,
12
+ });
13
+ const SUFFIXES = Object.freeze({
14
+ 'tanstack-start': 'fn',
15
+ });
16
+ function controllerRenderer(stack) {
17
+ const renderer = RENDERERS[stack];
18
+ if (renderer === undefined) {
19
+ throw new Error(`No controller renderer registered for the "${stack}" stack.`);
20
+ }
21
+ return renderer;
22
+ }
23
+ function controllerSuffix(stack) {
24
+ return SUFFIXES[stack] ?? 'controller';
25
+ }
@@ -37,11 +37,11 @@ exports.makeController = makeController;
37
37
  const path = __importStar(require("path"));
38
38
  const registry_1 = require("../stack/registry");
39
39
  const actions_1 = require("../templates/actions");
40
- const nest_1 = require("../templates/controllers/nest");
41
40
  const next_route_1 = require("../templates/controllers/next-route");
42
41
  const node_1 = require("../templates/controllers/node");
43
42
  const fs_1 = require("../utils/fs");
44
43
  const paths_1 = require("../utils/paths");
44
+ const controller_renderer_1 = require("./controller-renderer");
45
45
  const resolve_1 = require("./resolve");
46
46
  const write_1 = require("./write");
47
47
  function makeController(feature, name) {
@@ -51,10 +51,12 @@ function makeController(feature, name) {
51
51
  return writeNextRoutes(ctx, name);
52
52
  }
53
53
  const dir = (0, resolve_1.ensureLayerDir)(ctx, 'controller');
54
- const render = ctx.profile.name === 'nest' ? nest_1.renderNestController : node_1.renderNodeController;
55
- const written = (0, write_1.writeSpecFiles)(dir, (0, actions_1.standardActions)(name), 'controller', (spec, filePath) => render(ctx, spec, name, filePath));
54
+ const render = (0, controller_renderer_1.controllerRenderer)(ctx.profile.name);
55
+ const suffix = (0, controller_renderer_1.controllerSuffix)(ctx.profile.name);
56
+ const written = (0, write_1.writeSpecFiles)(dir, (0, actions_1.standardActions)(name), suffix, (spec, filePath) => render(ctx, spec, name, filePath));
57
+ const label = ctx.profile.name === 'tanstack-start' ? 'Server functions' : 'Controllers';
56
58
  if (written > 0)
57
- console.log(`✅ Controllers for "${name}" created at ${dir}`);
59
+ console.log(`✅ ${label} for "${name}" created at ${dir}`);
58
60
  const wroteRoutes = ctx.profile.name === 'node' ? writeNodeRoutes(ctx, name) : false;
59
61
  return written > 0 || wroteRoutes;
60
62
  }
@@ -37,6 +37,7 @@ exports.makeFeature = makeFeature;
37
37
  const path = __importStar(require("path"));
38
38
  const registry_1 = require("../stack/registry");
39
39
  const page_1 = require("../templates/frontend/page");
40
+ const route_1 = require("../templates/frontend/route");
40
41
  const module_1 = require("../templates/nest/module");
41
42
  const fs_1 = require("../utils/fs");
42
43
  const naming_1 = require("../utils/naming");
@@ -88,7 +89,7 @@ function scaffoldLayers(ctx, entity) {
88
89
  if ((0, registry_1.hasLayer)(profile, 'clientService'))
89
90
  (0, service_1.makeService)(feature, entity, 'client');
90
91
  if ((0, registry_1.hasLayer)(profile, 'hook'))
91
- (0, hook_1.makeHook)(feature, `use${entity}`, entity);
92
+ (0, hook_1.makeHook)(feature, entity);
92
93
  if ((0, registry_1.hasLayer)(profile, 'component'))
93
94
  (0, component_1.makeComponent)(feature, entity, 'client');
94
95
  if ((0, registry_1.hasLayer)(profile, 'container'))
@@ -96,8 +97,12 @@ function scaffoldLayers(ctx, entity) {
96
97
  }
97
98
  function writeEntryFile(ctx, entity, all) {
98
99
  if ((0, registry_1.hasLayer)(ctx.profile, 'page')) {
99
- const filePath = path.join(ctx.featureDir, 'page.tsx');
100
- (0, fs_1.writeFileSafe)(filePath, (0, page_1.renderPage)(ctx, entity, filePath, all));
100
+ const isRoute = ctx.profile.name === 'tanstack-start';
101
+ const filePath = path.join(ctx.featureDir, isRoute ? 'index.tsx' : 'page.tsx');
102
+ const content = isRoute
103
+ ? (0, route_1.renderRoute)(ctx, entity, filePath, all)
104
+ : (0, page_1.renderPage)(ctx, entity, filePath, all);
105
+ (0, fs_1.writeFileSafe)(filePath, content);
101
106
  }
102
107
  if ((0, registry_1.hasLayer)(ctx.profile, 'module')) {
103
108
  const filePath = path.join(ctx.featureDir, `${ctx.feature}.module.ts`);
@@ -2,13 +2,16 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.resetHints = resetHints;
4
4
  exports.hintNestjsZod = hintNestjsZod;
5
+ exports.hintReactQuery = hintReactQuery;
5
6
  exports.standardClassNames = standardClassNames;
6
7
  exports.hintRegisterInModule = hintRegisterInModule;
7
8
  const detect_1 = require("../stack/detect");
8
9
  const actions_1 = require("../templates/actions");
9
10
  let nestjsZodHinted = false;
11
+ let reactQueryHinted = false;
10
12
  function resetHints() {
11
13
  nestjsZodHinted = false;
14
+ reactQueryHinted = false;
12
15
  }
13
16
  function hintNestjsZod(stack) {
14
17
  if (stack.stack !== 'nest' || stack.hasNestjsZod || nestjsZodHinted)
@@ -17,6 +20,13 @@ function hintNestjsZod(stack) {
17
20
  console.log('ℹ️ nestjs-zod is not installed. Run: npm install nestjs-zod');
18
21
  console.log(' Then register the pipe in AppModule: { provide: APP_PIPE, useClass: ZodValidationPipe }');
19
22
  }
23
+ function hintReactQuery(profile) {
24
+ if (!profile.queryHooks || reactQueryHinted)
25
+ return;
26
+ reactQueryHinted = true;
27
+ console.log('ℹ️ Hooks use TanStack Query. Install it: npm install @tanstack/react-query');
28
+ console.log(' Then wrap your app in a QueryClientProvider.');
29
+ }
20
30
  function standardClassNames(name, suffix) {
21
31
  return actions_1.ACTIONS.map((action) => `${action}${name}${suffix}`);
22
32
  }
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.hookRenderer = hookRenderer;
37
+ exports.ensureQueryKeys = ensureQueryKeys;
38
+ const path = __importStar(require("path"));
39
+ const hook_1 = require("../templates/frontend/hook");
40
+ const query_hook_1 = require("../templates/frontend/query-hook");
41
+ const query_keys_1 = require("../templates/frontend/query-keys");
42
+ const fs_1 = require("../utils/fs");
43
+ /**
44
+ * Picks the hook template for the profile: TanStack Query hooks where the profile supports
45
+ * them, the plain useState/useEffect hook otherwise.
46
+ */
47
+ function hookRenderer(ctx) {
48
+ return ctx.profile.queryHooks ? query_hook_1.renderQueryHook : hook_1.renderHook;
49
+ }
50
+ /**
51
+ * Ensures the shared `<feature>.keys.ts` module exists for query-hook profiles. Unlike
52
+ * `writeIfAbsent`, this never warns: the keys file is deliberately shared across every hook
53
+ * in the feature, so every `make:action`/`make:hook` call re-checking it is expected, not a
54
+ * skipped write worth flagging.
55
+ */
56
+ function ensureQueryKeys(ctx, dir) {
57
+ if (!ctx.profile.queryHooks)
58
+ return;
59
+ const keysFile = path.join(dir, `${ctx.feature}.keys.ts`);
60
+ if ((0, fs_1.fileExists)(keysFile))
61
+ return;
62
+ (0, fs_1.writeFileSafe)(keysFile, (0, query_keys_1.renderQueryKeys)(ctx.feature));
63
+ }
@@ -1,52 +1,30 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
2
  Object.defineProperty(exports, "__esModule", { value: true });
36
3
  exports.makeHook = makeHook;
37
- const path = __importStar(require("path"));
38
4
  const registry_1 = require("../stack/registry");
39
- const hook_1 = require("../templates/frontend/hook");
40
- const fs_1 = require("../utils/fs");
5
+ const actions_1 = require("../templates/actions");
6
+ const hints_1 = require("./hints");
7
+ const hook_renderer_1 = require("./hook-renderer");
41
8
  const resolve_1 = require("./resolve");
42
- function makeHook(feature, name, pascalName) {
9
+ const write_1 = require("./write");
10
+ const OLD_HOOK_NAME_PATTERN = /^use[A-Z]/;
11
+ function rejectOldHookName(feature, entity) {
12
+ if (!OLD_HOOK_NAME_PATTERN.test(entity))
13
+ return;
14
+ const suggestedEntity = entity.slice('use'.length);
15
+ throw new Error(`make:hook now takes the entity, not the hook name — try make:hook ${feature}/${suggestedEntity}.`);
16
+ }
17
+ function makeHook(feature, entity) {
43
18
  const ctx = (0, resolve_1.requireFeature)(feature);
44
19
  (0, registry_1.assertLayer)(ctx.profile, 'hook', 'make:hook');
45
- const filePath = path.join((0, resolve_1.ensureLayerDir)(ctx, 'hook'), `${name}.ts`);
46
- if ((0, fs_1.fileExists)(filePath)) {
47
- throw new Error(`Hook "${name}" already exists at ${filePath}`);
20
+ rejectOldHookName(feature, entity);
21
+ const dir = (0, resolve_1.ensureLayerDir)(ctx, 'hook');
22
+ (0, hook_renderer_1.ensureQueryKeys)(ctx, dir);
23
+ const render = (0, hook_renderer_1.hookRenderer)(ctx);
24
+ const written = (0, write_1.writeSpecFiles)(dir, (0, actions_1.standardActions)(entity), 'hook', (spec, filePath) => render(ctx, spec, entity, filePath));
25
+ if (written > 0) {
26
+ console.log(`✅ Hooks for "${entity}" created at ${dir}`);
27
+ (0, hints_1.hintReactQuery)(ctx.profile);
48
28
  }
49
- const entity = pascalName ?? name.replace(/^use/, '');
50
- (0, fs_1.writeFileSafe)(filePath, (0, hook_1.renderHook)(ctx, name, entity, filePath));
51
- console.log(`✅ Hook "${name}" created at ${filePath}`);
29
+ return written > 0;
52
30
  }
@@ -1,18 +1,25 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.clientRenderer = clientRenderer;
3
4
  exports.makeRepository = makeRepository;
4
5
  const actions_1 = require("../templates/actions");
5
6
  const server_repository_1 = require("../templates/backend/server-repository");
6
7
  const client_repository_1 = require("../templates/frontend/client-repository");
8
+ const server_fn_repository_1 = require("../templates/frontend/server-fn-repository");
7
9
  const resolve_1 = require("./resolve");
8
10
  const sides_1 = require("./sides");
9
11
  const write_1 = require("./write");
12
+ // Selected on the profile name, not on `queryHooks`: the two coincide today but mean different
13
+ // things. `queryHooks` picks the hook renderer; this picks whether a server function exists to call.
14
+ function clientRenderer(ctx) {
15
+ return ctx.profile.name === 'tanstack-start' ? server_fn_repository_1.renderServerFnRepository : client_repository_1.renderClientRepository;
16
+ }
10
17
  function makeRepository(feature, name, side = 'both') {
11
18
  const ctx = (0, resolve_1.requireFeature)(feature);
12
19
  let wroteAny = false;
13
20
  for (const current of (0, sides_1.resolveSides)(ctx.profile, side, sides_1.REPOSITORY_SIDES)) {
14
21
  const dir = (0, resolve_1.ensureLayerDir)(ctx, sides_1.REPOSITORY_SIDES[current]);
15
- const render = current === 'client' ? client_repository_1.renderClientRepository : server_repository_1.renderServerRepository;
22
+ const render = current === 'client' ? clientRenderer(ctx) : server_repository_1.renderServerRepository;
16
23
  const written = (0, write_1.writeSpecFiles)(dir, (0, actions_1.standardActions)(name), 'repository', (spec, filePath) => render(ctx, spec, name, filePath));
17
24
  if (written > 0) {
18
25
  console.log(`✅ Repositories (${current}) for "${name}" created at ${dir}`);
@@ -15,6 +15,7 @@ const AGENTS_LINES = [
15
15
  'Rules the generated code follows, and that new code must keep:',
16
16
  '',
17
17
  '- One file per action per layer. `findActiveUsers` gets `FindActiveUsers.service.ts`, `FindActiveUsers.repository.ts`, and `FindActiveUsers.controller.ts`. It never goes inside `ShowUser.service.ts` or `ListUser.service.ts`.',
18
+ '- Hooks are one file per action too: `<Action><Entity>.hook.ts` exporting `use<Action><Entity>` (for example `useListUser`, `useCreateUser`). There is no combined `use<Entity>.ts`.',
18
19
  '- The chain is controller or hook, then service, then repository. Business logic lives in services. Data access lives in repositories. Controllers validate input and call one service.',
19
20
  '- Feature folders are kebab-case (`coffee-type`). Entity, action, and class names are PascalCase (`CoffeeType`, `FindActiveUsers`).',
20
21
  '- Generated repositories throw until you wire them to your data source. Generated controllers on Node need a line in `<feature>.routes.ts`, and on Nest need registering in `<feature>.module.ts`; the tool prints the exact line.',
@@ -34,7 +35,7 @@ const SKILL_LINES = [
34
35
  '',
35
36
  '## Detect the stack',
36
37
  '',
37
- 'The tool reads `package.json` and prints `Stack: <stack> (detected)` before every command. Stacks: `next-fullstack`, `next-frontend`, `react`, `node` (Express, Fastify, Hono, or none), `nest`. Override with `--stack <name>` if detection is wrong.',
38
+ 'The tool reads `package.json` and prints `Stack: <stack> (detected)` before every command. Stacks: `next-fullstack`, `next-frontend`, `react`, `node` (Express, Fastify, Hono, or none), `nest`, `tanstack-start`. Override with `--stack <name>` if detection is wrong.',
38
39
  '',
39
40
  '## Commands',
40
41
  '',
@@ -49,10 +50,12 @@ const SKILL_LINES = [
49
50
  '| Five controllers or Next route handlers | `npx domain-driver make:controller users/User` |',
50
51
  '| A bespoke operation | `npx domain-driver make:action users/User findActiveUsers` |',
51
52
  '| Bespoke operation with a request body | `npx domain-driver make:action users/User archiveUser --with-input --returns one` |',
52
- '| Component, container, hook | `npx domain-driver make:component users/UserCard`, `make:container users/UserContainer`, `make:hook users/useUser` |',
53
+ '| Component, container, hook | `npx domain-driver make:component users/UserCard`, `make:container users/UserContainer`, `make:hook users/User` |',
53
54
  '| Refresh this guidance | `npx domain-driver init` |',
54
55
  '',
55
- 'On `next-fullstack`, `make:service` and `make:repository` take `--side client|server|both` (default both).',
56
+ 'On `next-fullstack` and `tanstack-start`, `make:service` and `make:repository` take `--side client|server|both` (default both).',
57
+ '',
58
+ 'Hooks are one file per action — `<Action><Entity>.hook.ts` exporting `use<Action><Entity>` — never a combined `use<Entity>.ts`. On `tanstack-start` they are TanStack Query hooks backed by a generated `<feature>.keys.ts`; elsewhere they are plain React state.',
56
59
  '',
57
60
  '## Rules',
58
61
  '',
@@ -68,6 +71,7 @@ const SKILL_LINES = [
68
71
  '- Next.js: `app/<feature>` or `src/app/<feature>`; route handlers under `app/api/<feature>`.',
69
72
  '- React and Node: `src/features/<feature>` or `features/<feature>`.',
70
73
  '- Nest: `src/<feature>` with a `<feature>.module.ts`.',
74
+ '- TanStack Start: `src/routes/<feature>` or `routes/<feature>`; every layer folder is prefixed with `-` so the router ignores it, and the entry file is `index.tsx`, not `page.tsx`.',
71
75
  ];
72
76
  exports.AGENTS_SECTION = AGENTS_LINES.join('\n');
73
77
  exports.SKILL_CONTENT = `${SKILL_LINES.join('\n')}\n`;
@@ -99,6 +99,8 @@ function parseOverride(value) {
99
99
  function inferStack(cwd, deps) {
100
100
  if (deps.has('@nestjs/core'))
101
101
  return 'nest';
102
+ if (deps.has('@tanstack/react-start'))
103
+ return 'tanstack-start';
102
104
  if (deps.has('next'))
103
105
  return hasApiDir(cwd) ? 'next-fullstack' : 'next-frontend';
104
106
  if (deps.has('react'))
@@ -121,5 +123,9 @@ function resolveFeatureRoot(cwd, stack) {
121
123
  return (0, fs_1.isDirectory)(path.join(cwd, 'src')) ? 'src/features' : 'features';
122
124
  case 'nest':
123
125
  return 'src';
126
+ case 'tanstack-start':
127
+ if ((0, fs_1.isDirectory)(path.join(cwd, 'src', 'routes')))
128
+ return 'src/routes';
129
+ return (0, fs_1.isDirectory)(path.join(cwd, 'routes')) ? 'routes' : 'src/routes';
124
130
  }
125
131
  }
@@ -15,4 +15,5 @@ exports.nest = Object.freeze({
15
15
  },
16
16
  clientDirective: false,
17
17
  serverComponents: false,
18
+ queryHooks: false,
18
19
  });
@@ -25,4 +25,5 @@ exports.nextFrontend = Object.freeze({
25
25
  },
26
26
  clientDirective: true,
27
27
  serverComponents: true,
28
+ queryHooks: false,
28
29
  });
@@ -41,4 +41,5 @@ exports.nextFullstack = Object.freeze({
41
41
  },
42
42
  clientDirective: true,
43
43
  serverComponents: true,
44
+ queryHooks: false,
44
45
  });
@@ -14,4 +14,5 @@ exports.node = Object.freeze({
14
14
  },
15
15
  clientDirective: false,
16
16
  serverComponents: false,
17
+ queryHooks: false,
17
18
  });
@@ -16,4 +16,5 @@ exports.react = Object.freeze({
16
16
  },
17
17
  clientDirective: false,
18
18
  serverComponents: false,
19
+ queryHooks: false,
19
20
  });
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.tanstackStart = void 0;
4
+ exports.tanstackStart = Object.freeze({
5
+ name: 'tanstack-start',
6
+ folders: [
7
+ '-components',
8
+ '-containers',
9
+ '-hooks',
10
+ '-client/services',
11
+ '-client/repositories',
12
+ '-server/functions',
13
+ '-server/services',
14
+ '-server/repositories',
15
+ '-schemas',
16
+ '-types',
17
+ ],
18
+ layers: [
19
+ 'page',
20
+ 'component',
21
+ 'container',
22
+ 'hook',
23
+ 'clientService',
24
+ 'clientRepository',
25
+ 'serverService',
26
+ 'serverRepository',
27
+ 'controller',
28
+ 'schema',
29
+ 'types',
30
+ ],
31
+ layerDirs: {
32
+ component: '-components',
33
+ container: '-containers',
34
+ hook: '-hooks',
35
+ clientService: '-client/services',
36
+ clientRepository: '-client/repositories',
37
+ serverService: '-server/services',
38
+ serverRepository: '-server/repositories',
39
+ controller: '-server/functions',
40
+ schema: '-schemas',
41
+ types: '-types',
42
+ },
43
+ clientDirective: false,
44
+ serverComponents: false,
45
+ queryHooks: true,
46
+ });
@@ -11,12 +11,14 @@ const next_frontend_1 = require("./profiles/next-frontend");
11
11
  const react_1 = require("./profiles/react");
12
12
  const node_1 = require("./profiles/node");
13
13
  const nest_1 = require("./profiles/nest");
14
+ const tanstack_start_1 = require("./profiles/tanstack-start");
14
15
  const PROFILES = Object.freeze({
15
16
  'next-fullstack': next_fullstack_1.nextFullstack,
16
17
  'next-frontend': next_frontend_1.nextFrontend,
17
18
  react: react_1.react,
18
19
  node: node_1.node,
19
20
  nest: nest_1.nest,
21
+ 'tanstack-start': tanstack_start_1.tanstackStart,
20
22
  });
21
23
  const LAYER_COMMANDS = Object.freeze({
22
24
  component: 'make:component',
@@ -45,7 +47,8 @@ function layerDir(profile, layer) {
45
47
  return dir;
46
48
  }
47
49
  function componentDir(profile, type) {
48
- return profile.serverComponents ? `components/${type}` : 'components';
50
+ const base = layerDir(profile, 'component');
51
+ return profile.serverComponents ? `${base}/${type}` : base;
49
52
  }
50
53
  function availableCommands(profile) {
51
54
  const commands = profile.layers
@@ -2,7 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.LAYERS = exports.HTTP_FRAMEWORKS = exports.STACK_NAMES = void 0;
4
4
  exports.isStackName = isStackName;
5
- exports.STACK_NAMES = ['next-fullstack', 'next-frontend', 'react', 'node', 'nest'];
5
+ exports.STACK_NAMES = ['next-fullstack', 'next-frontend', 'react', 'node', 'nest', 'tanstack-start'];
6
6
  exports.HTTP_FRAMEWORKS = ['express', 'fastify', 'hono'];
7
7
  exports.LAYERS = [
8
8
  'page',
@@ -5,6 +5,7 @@ exports.actionCase = actionCase;
5
5
  exports.standardAction = standardAction;
6
6
  exports.standardActions = standardActions;
7
7
  exports.customAction = customAction;
8
+ exports.isQueryAction = isQueryAction;
8
9
  const naming_1 = require("../utils/naming");
9
10
  exports.ACTIONS = ['List', 'Show', 'Create', 'Update', 'Delete'];
10
11
  exports.WRITE_ACTIONS = ['Create', 'Update'];
@@ -66,3 +67,14 @@ function customAction(entity, actionName, options) {
66
67
  failure: `Failed to ${camel} ${entity}`,
67
68
  });
68
69
  }
70
+ /**
71
+ * Whether a hook renders as a query (auto-fetches, returns data) rather than a mutation
72
+ * (triggered imperatively). `spec.method === 'get'` alone is not enough: a custom action
73
+ * declared `--returns void` is also a GET (no input forces GET regardless of return kind),
74
+ * but it has no payload worth polling for and must not auto-fire on mount. `usesEntityType`
75
+ * is false exactly when the action returns void, so requiring it here routes that case to
76
+ * the mutation branch instead.
77
+ */
78
+ function isQueryAction(spec) {
79
+ return spec.method === 'get' && spec.usesEntityType;
80
+ }
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderServerFn = renderServerFn;
4
+ const naming_1 = require("../../utils/naming");
5
+ function inputFor(spec) {
6
+ if (spec.schema !== null && spec.usesId) {
7
+ return {
8
+ validator: `z.object({ id: z.string(), data: ${spec.schema}Schema })`,
9
+ call: 'data.id, data.data',
10
+ handlerArg: '{ data }',
11
+ needsZod: true,
12
+ };
13
+ }
14
+ if (spec.schema !== null) {
15
+ return { validator: `${spec.schema}Schema`, call: 'data', handlerArg: '{ data }', needsZod: false };
16
+ }
17
+ if (spec.usesId) {
18
+ return { validator: 'z.string()', call: 'data', handlerArg: '{ data }', needsZod: true };
19
+ }
20
+ return { validator: null, call: '', handlerArg: '', needsZod: false };
21
+ }
22
+ function renderServerFn(ctx, spec, entity, fromFile) {
23
+ const { validator, call, handlerArg, needsZod } = inputFor(spec);
24
+ const method = spec.method === 'get' ? 'GET' : 'POST';
25
+ const servicePath = ctx.importLayer(fromFile, 'serverService', `${spec.name}.service`);
26
+ const imports = ["import { createServerFn } from '@tanstack/react-start';"];
27
+ if (needsZod)
28
+ imports.push("import { z } from 'zod';");
29
+ if (spec.schema !== null) {
30
+ const schemaPath = ctx.importLayer(fromFile, 'schema', `${spec.schema}.schema`);
31
+ imports.push(`import { ${spec.schema}Schema } from '${schemaPath}';`);
32
+ }
33
+ imports.push(`import { ${spec.name}Service } from '${servicePath}';`);
34
+ const validatorLine = validator === null ? '' : `\n .validator(${validator})`;
35
+ return `${imports.join('\n')}
36
+
37
+ const service = new ${spec.name}Service();
38
+
39
+ export const ${(0, naming_1.lowerFirst)(spec.name)} = createServerFn({ method: '${method}' })${validatorLine}
40
+ .handler(async (${handlerArg}) => service.handle(${call}));
41
+ `;
42
+ }
@@ -4,21 +4,25 @@ exports.renderContainer = renderContainer;
4
4
  const registry_1 = require("../../stack/registry");
5
5
  function renderContainer(ctx, containerName, entity, fromFile) {
6
6
  const header = ctx.profile.clientDirective ? "'use client';\n\n" : '';
7
- const hookName = `use${entity}`;
8
- const hookPath = ctx.importLayer(fromFile, 'hook', hookName);
7
+ const hookName = `useList${entity}`;
8
+ const hookPath = ctx.importLayer(fromFile, 'hook', `List${entity}.hook`);
9
9
  const componentPath = ctx.importFrom(fromFile, `${(0, registry_1.componentDir)(ctx.profile, 'client')}/${entity}`);
10
+ const query = ctx.profile.queryHooks;
11
+ const loadingField = query ? 'isPending' : 'loading';
12
+ const errorExpression = query ? '{error.message}' : '{error}';
13
+ const items = query ? '(data ?? [])' : 'data';
10
14
  return `${header}import { ${hookName} } from '${hookPath}';
11
15
  import ${entity} from '${componentPath}';
12
16
 
13
17
  export default function ${containerName}() {
14
- const { items, loading, error } = ${hookName}();
18
+ const { data, ${loadingField}, error } = ${hookName}();
15
19
 
16
- if (loading) return <div>Loading...</div>;
17
- if (error) return <div>Error: {error}</div>;
20
+ if (${loadingField}) return <div>Loading...</div>;
21
+ if (error) return <div>Error: ${errorExpression}</div>;
18
22
 
19
23
  return (
20
24
  <div>
21
- {items.map((item) => (
25
+ {${items}.map((item) => (
22
26
  <${entity} key={item.id} {...item} />
23
27
  ))}
24
28
  </div>
@@ -1,109 +1,93 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.renderHook = renderHook;
4
+ const naming_1 = require("../../utils/naming");
4
5
  const actions_1 = require("../actions");
5
- function serviceImports(ctx, fromFile, entity) {
6
- return actions_1.ACTIONS.map((action) => {
7
- const servicePath = ctx.importLayer(fromFile, 'clientService', `${action}${entity}.service`);
8
- return `import { ${action}${entity}Service } from '${servicePath}';`;
9
- }).join('\n');
6
+ const signatures_1 = require("../signatures");
7
+ function payloadType(spec) {
8
+ return spec.returns.replace(/^Promise<(.*)>$/, '$1');
10
9
  }
11
- function serviceInstances(entity) {
12
- return actions_1.ACTIONS.map((action) => `const ${action.toLowerCase()}Service = new ${action}${entity}Service();`).join('\n');
13
- }
14
- function renderHook(ctx, hookName, entity, fromFile) {
15
- const header = ctx.profile.clientDirective ? "'use client';\n\n" : '';
16
- const typePath = ctx.importLayer(fromFile, 'types', `${entity}.types`);
17
- const createPath = ctx.importLayer(fromFile, 'schema', `Create${entity}.schema`);
18
- const updatePath = ctx.importLayer(fromFile, 'schema', `Update${entity}.schema`);
19
- return `${header}import { useState, useEffect, useCallback } from 'react';
20
- import { ${entity} } from '${typePath}';
21
- ${serviceImports(ctx, fromFile, entity)}
22
- import { Create${entity} } from '${createPath}';
23
- import { Update${entity} } from '${updatePath}';
24
-
25
- ${serviceInstances(entity)}
10
+ function header(ctx, spec, entity, fromFile, hooks) {
11
+ const directive = ctx.profile.clientDirective ? "'use client';\n\n" : '';
12
+ const servicePath = ctx.importLayer(fromFile, 'clientService', `${spec.name}.service`);
13
+ const domain = (0, signatures_1.domainImports)(ctx, fromFile, spec, entity);
14
+ return `${directive}import { ${hooks} } from 'react';
15
+ ${domain.join('\n')}${domain.length > 0 ? '\n' : ''}import { ${spec.name}Service } from '${servicePath}';
26
16
 
27
- export function ${hookName}() {
28
- const [items, setItems] = useState<${entity}[]>([]);
29
- const [selected, setSelected] = useState<${entity} | null>(null);
17
+ const service = new ${spec.name}Service();
18
+ `;
19
+ }
20
+ function renderQuery(ctx, spec, entity, fromFile) {
21
+ const type = payloadType(spec);
22
+ const isList = type.endsWith('[]');
23
+ const stateType = isList ? type : `${type} | null`;
24
+ const initial = isList ? '[]' : 'null';
25
+ const deps = spec.usesId ? '[id]' : '[]';
26
+ return `${header(ctx, spec, entity, fromFile, 'useState, useEffect, useCallback')}
27
+ export function use${spec.name}(${spec.params}) {
28
+ const [data, setData] = useState<${stateType}>(${initial});
30
29
  const [loading, setLoading] = useState(false);
31
30
  const [error, setError] = useState<string | null>(null);
32
31
 
33
- const fetchAll = useCallback(async () => {
34
- setLoading(true);
35
- setError(null);
36
- try {
37
- const data = await listService.handle();
38
- setItems(data);
39
- } catch (err: unknown) {
40
- setError(err instanceof Error ? err.message : 'Failed to fetch');
41
- } finally {
42
- setLoading(false);
43
- }
44
- }, []);
45
-
46
- const fetchOne = useCallback(async (id: string) => {
32
+ const refetch = useCallback(async () => {
47
33
  setLoading(true);
48
34
  setError(null);
49
35
  try {
50
- const data = await showService.handle(id);
51
- setSelected(data);
36
+ setData(await service.handle(${spec.args}));
52
37
  } catch (err: unknown) {
53
- setError(err instanceof Error ? err.message : 'Failed to fetch');
38
+ setError(err instanceof Error ? err.message : '${spec.failure}');
54
39
  } finally {
55
40
  setLoading(false);
56
41
  }
57
- }, []);
42
+ }, ${deps});
58
43
 
59
- const create = useCallback(async (data: Create${entity}) => {
60
- setLoading(true);
61
- setError(null);
62
- try {
63
- const created = await createService.handle(data);
64
- setItems((prev) => [...prev, created]);
65
- return created;
66
- } catch (err: unknown) {
67
- setError(err instanceof Error ? err.message : 'Failed to create');
68
- return null;
69
- } finally {
70
- setLoading(false);
71
- }
72
- }, []);
44
+ useEffect(() => {
45
+ void refetch();
46
+ }, [refetch]);
73
47
 
74
- const update = useCallback(async (id: string, data: Update${entity}) => {
75
- setLoading(true);
76
- setError(null);
77
- try {
78
- const updated = await updateService.handle(id, data);
79
- setItems((prev) => prev.map((item) => (item.id === id ? updated : item)));
80
- return updated;
81
- } catch (err: unknown) {
82
- setError(err instanceof Error ? err.message : 'Failed to update');
83
- return null;
84
- } finally {
85
- setLoading(false);
86
- }
87
- }, []);
48
+ return { data, loading, error, refetch };
49
+ }
50
+ `;
51
+ }
52
+ function renderMutation(ctx, spec, entity, fromFile) {
53
+ const callable = (0, naming_1.lowerFirst)(spec.name);
54
+ const returnsValue = spec.usesEntityType;
55
+ const type = payloadType(spec);
56
+ const callbackType = returnsValue ? `(result: ${type}) => void` : '() => void';
57
+ const body = returnsValue
58
+ ? ` const result = await service.handle(${spec.args});
59
+ onSuccess?.(result);
60
+ return result;`
61
+ : ` await service.handle(${spec.args});
62
+ onSuccess?.();`;
63
+ const failure = returnsValue
64
+ ? ` setError(err instanceof Error ? err.message : '${spec.failure}');
65
+ return null;`
66
+ : ` setError(err instanceof Error ? err.message : '${spec.failure}');`;
67
+ return `${header(ctx, spec, entity, fromFile, 'useState, useCallback')}
68
+ export function use${spec.name}(options: { onSuccess?: ${callbackType} } = {}) {
69
+ const { onSuccess } = options;
70
+ const [loading, setLoading] = useState(false);
71
+ const [error, setError] = useState<string | null>(null);
88
72
 
89
- const remove = useCallback(async (id: string) => {
73
+ const ${callable} = useCallback(async (${spec.params}) => {
90
74
  setLoading(true);
91
75
  setError(null);
92
76
  try {
93
- await deleteService.handle(id);
94
- setItems((prev) => prev.filter((item) => item.id !== id));
77
+ ${body}
95
78
  } catch (err: unknown) {
96
- setError(err instanceof Error ? err.message : 'Failed to delete');
79
+ ${failure}
97
80
  } finally {
98
81
  setLoading(false);
99
82
  }
100
- }, []);
101
-
102
- useEffect(() => {
103
- fetchAll();
104
- }, [fetchAll]);
83
+ }, [onSuccess]);
105
84
 
106
- return { items, selected, loading, error, fetchAll, fetchOne, create, update, remove };
85
+ return { ${callable}, loading, error };
107
86
  }
108
87
  `;
109
88
  }
89
+ function renderHook(ctx, spec, entity, fromFile) {
90
+ return (0, actions_1.isQueryAction)(spec)
91
+ ? renderQuery(ctx, spec, entity, fromFile)
92
+ : renderMutation(ctx, spec, entity, fromFile);
93
+ }
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderQueryHook = renderQueryHook;
4
+ const actions_1 = require("../actions");
5
+ const signatures_1 = require("../signatures");
6
+ const query_keys_1 = require("./query-keys");
7
+ function preamble(ctx, spec, entity, fromFile, imported) {
8
+ const servicePath = ctx.importLayer(fromFile, 'clientService', `${spec.name}.service`);
9
+ const keysPath = ctx.importLayer(fromFile, 'hook', `${ctx.feature}.keys`);
10
+ const domain = (0, signatures_1.domainImports)(ctx, fromFile, spec, entity);
11
+ return `import { ${imported} } from '@tanstack/react-query';
12
+ ${domain.join('\n')}${domain.length > 0 ? '\n' : ''}import { ${spec.name}Service } from '${servicePath}';
13
+ import { ${(0, query_keys_1.keysConstant)(ctx.feature)} } from '${keysPath}';
14
+
15
+ const service = new ${spec.name}Service();
16
+ `;
17
+ }
18
+ /**
19
+ * A custom GET action (e.g. `FindActiveCats`) has `usesId: false`, just like List. Testing
20
+ * only `usesId` would give it the exact same key as List's `all` — one cache entry shared by
21
+ * two hooks, with whichever observer mounts last winning the `queryFn`. `spec.path === '/'`
22
+ * is what actually distinguishes List from a custom action, so it is the second branch.
23
+ * The `[...all, name]` spread (rather than a bare `[feature, name]`) is deliberate: TanStack
24
+ * Query invalidates by key prefix, so a Create mutation invalidating `keys.all` still
25
+ * invalidates this custom list for free.
26
+ */
27
+ function queryKey(ctx, spec) {
28
+ const keys = (0, query_keys_1.keysConstant)(ctx.feature);
29
+ if (spec.usesId)
30
+ return `${keys}.detail(id)`;
31
+ if (spec.path === '/')
32
+ return `${keys}.all`;
33
+ return `[...${keys}.all, '${spec.name}']`;
34
+ }
35
+ function renderQuery(ctx, spec, entity, fromFile) {
36
+ return `${preamble(ctx, spec, entity, fromFile, 'useQuery')}
37
+ export function use${spec.name}(${spec.params}) {
38
+ return useQuery({
39
+ queryKey: ${queryKey(ctx, spec)},
40
+ queryFn: (): ${spec.returns} => service.handle(${spec.args}),
41
+ });
42
+ }
43
+ `;
44
+ }
45
+ function mutationInput(spec) {
46
+ if (spec.usesId && spec.schema !== null) {
47
+ return {
48
+ signature: `({ id, data }: { id: string; data: ${spec.schema} })`,
49
+ call: 'id, data',
50
+ idFrom: '{ id }',
51
+ };
52
+ }
53
+ if (spec.usesId)
54
+ return { signature: '(id: string)', call: 'id', idFrom: 'id' };
55
+ if (spec.schema !== null)
56
+ return { signature: `(data: ${spec.schema})`, call: 'data', idFrom: null };
57
+ return { signature: '()', call: '', idFrom: null };
58
+ }
59
+ function renderMutation(ctx, spec, entity, fromFile) {
60
+ const keys = (0, query_keys_1.keysConstant)(ctx.feature);
61
+ const { signature, call, idFrom } = mutationInput(spec);
62
+ const args = idFrom === null ? '()' : `(_result, ${idFrom})`;
63
+ const detail = idFrom === null
64
+ ? ''
65
+ : `\n void queryClient.invalidateQueries({ queryKey: ${keys}.detail(id) });`;
66
+ return `${preamble(ctx, spec, entity, fromFile, 'useMutation, useQueryClient')}
67
+ export function use${spec.name}() {
68
+ const queryClient = useQueryClient();
69
+
70
+ return useMutation({
71
+ mutationFn: ${signature}: ${spec.returns} => service.handle(${call}),
72
+ onSuccess: ${args} => {
73
+ void queryClient.invalidateQueries({ queryKey: ${keys}.all });${detail}
74
+ },
75
+ });
76
+ }
77
+ `;
78
+ }
79
+ function renderQueryHook(ctx, spec, entity, fromFile) {
80
+ return (0, actions_1.isQueryAction)(spec)
81
+ ? renderQuery(ctx, spec, entity, fromFile)
82
+ : renderMutation(ctx, spec, entity, fromFile);
83
+ }
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.keysConstant = keysConstant;
4
+ exports.renderQueryKeys = renderQueryKeys;
5
+ const naming_1 = require("../../utils/naming");
6
+ function keysConstant(feature) {
7
+ return `${(0, naming_1.lowerFirst)((0, naming_1.toPascalCase)(feature))}Keys`;
8
+ }
9
+ function renderQueryKeys(feature) {
10
+ return `export const ${keysConstant(feature)} = {
11
+ all: ['${feature}'] as const,
12
+ detail: (id: string) => ['${feature}', id] as const,
13
+ };
14
+ `;
15
+ }
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderRoute = renderRoute;
4
+ function renderRoute(ctx, entity, fromFile, withContainer) {
5
+ const head = `import { createFileRoute } from '@tanstack/react-router';`;
6
+ const routeBlock = `export const Route = createFileRoute('/${ctx.feature}/')({
7
+ component: ${entity}Page,
8
+ });`;
9
+ if (!withContainer) {
10
+ return `${head}
11
+
12
+ ${routeBlock}
13
+
14
+ function ${entity}Page() {
15
+ return (
16
+ <div>
17
+ <h1>${entity}</h1>
18
+ </div>
19
+ );
20
+ }
21
+ `;
22
+ }
23
+ const containerPath = ctx.importLayer(fromFile, 'container', `${entity}Container`);
24
+ return `${head}
25
+ import ${entity}Container from '${containerPath}';
26
+
27
+ ${routeBlock}
28
+
29
+ function ${entity}Page() {
30
+ return <${entity}Container />;
31
+ }
32
+ `;
33
+ }
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderServerFnRepository = renderServerFnRepository;
4
+ const naming_1 = require("../../utils/naming");
5
+ const signatures_1 = require("../signatures");
6
+ function callArgument(spec) {
7
+ if (spec.usesId && spec.schema !== null)
8
+ return '({ data: { id, data } })';
9
+ if (spec.usesId)
10
+ return '({ data: id })';
11
+ if (spec.schema !== null)
12
+ return '({ data })';
13
+ return '()';
14
+ }
15
+ function renderServerFnRepository(ctx, spec, entity, fromFile) {
16
+ const fnName = (0, naming_1.lowerFirst)(spec.name);
17
+ const fnPath = ctx.importLayer(fromFile, 'controller', `${spec.name}.fn`);
18
+ const imports = [...(0, signatures_1.domainImports)(ctx, fromFile, spec, entity), `import { ${fnName} } from '${fnPath}';`];
19
+ return `${imports.join('\n')}
20
+
21
+ export class ${spec.name}Repository {
22
+ async handle(${spec.params}): ${spec.returns} {
23
+ return ${fnName}${callArgument(spec)};
24
+ }
25
+ }
26
+ `;
27
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "domain-driver",
3
- "version": "0.3.0",
4
- "description": "CLI scaffolding tool for domain-driven feature folders in Next.js, React, Node, and NestJS projects, with per-action files, bespoke actions, and agent guidance",
3
+ "version": "0.4.0",
4
+ "description": "CLI scaffolding tool for domain-driven feature folders in Next.js, React, Node, NestJS, and TanStack Start projects, with per-action files, bespoke actions, and agent guidance",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
7
7
  "domain-driver": "./dist/index.js"
@@ -18,6 +18,7 @@
18
18
  "build": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\" && tsc",
19
19
  "test": "vitest run",
20
20
  "test:coverage": "vitest run --coverage",
21
+ "typecheck:tanstack-fixture": "node scripts/typecheck-tanstack-fixture.js",
21
22
  "postinstall": "node ./scripts/postinstall.js"
22
23
  },
23
24
  "keywords": [
@@ -30,12 +31,14 @@
30
31
  "node",
31
32
  "express",
32
33
  "fastify",
33
- "hono"
34
+ "hono",
35
+ "tanstack",
36
+ "tanstack-start"
34
37
  ],
35
38
  "author": "Isaac Hatilima",
36
39
  "license": "MIT",
37
40
  "engines": {
38
- "node": ">=18"
41
+ "node": ">=20"
39
42
  },
40
43
  "devDependencies": {
41
44
  "@types/node": "^25.4.0",