proteum 2.5.1 → 2.5.2

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.
@@ -5,10 +5,13 @@ import * as ts from 'typescript';
5
5
 
6
6
  import {
7
7
  Application as ApplicationConfig,
8
+ defineVerificationConfig,
8
9
  normalizeApplicationIdentityConfig,
9
10
  normalizeApplicationSetupConfig,
11
+ normalizeVerificationConfig,
10
12
  type TApplicationIdentityConfig,
11
13
  type TApplicationSetupConfig,
14
+ type TVerificationConfig,
12
15
  } from './applicationConfig';
13
16
  import { loadOptionalProteumDotenv } from './env/proteumEnv';
14
17
 
@@ -51,7 +54,8 @@ const loadTsModule = (filepath: string): unknown => {
51
54
 
52
55
  const requireFromFile = createRequire(normalizedFilepath);
53
56
  const runtimeRequire = (specifier: string) => {
54
- if (specifier === 'proteum/config' || specifier === 'proteum/config.ts') return { Application: ApplicationConfig };
57
+ if (specifier === 'proteum/config' || specifier === 'proteum/config.ts')
58
+ return { Application: ApplicationConfig, defineVerificationConfig };
55
59
 
56
60
  if (specifier.startsWith('.') || specifier.startsWith('/')) {
57
61
  const resolved = resolveLocalModulePath(specifier, normalizedFilepath);
@@ -81,9 +85,11 @@ const getDefaultExport = <T>(value: unknown): T => {
81
85
 
82
86
  export const identityConfigFilename = 'identity.config.ts';
83
87
  export const setupConfigFilename = 'proteum.config.ts';
88
+ export const verificationConfigFilename = 'proteum.verify.config.ts';
84
89
 
85
90
  export const resolveIdentityConfigFilepath = (appDir: string) => path.join(appDir, identityConfigFilename);
86
91
  export const resolveSetupConfigFilepath = (appDir: string) => path.join(appDir, setupConfigFilename);
92
+ export const resolveVerificationConfigFilepath = (rootDir: string) => path.join(rootDir, verificationConfigFilename);
87
93
 
88
94
  export const loadApplicationIdentityConfig = (appDir: string): TApplicationIdentityConfig => {
89
95
  const filepath = resolveIdentityConfigFilepath(appDir);
@@ -100,3 +106,29 @@ export const loadApplicationSetupConfig = (appDir: string): TApplicationSetupCon
100
106
 
101
107
  return normalizeApplicationSetupConfig(getDefaultExport(loadTsModule(filepath)), filepath);
102
108
  };
109
+
110
+ export const findVerificationConfigFilepath = (startDir: string) => {
111
+ let currentDir = path.resolve(startDir);
112
+
113
+ while (true) {
114
+ const candidate = resolveVerificationConfigFilepath(currentDir);
115
+ if (fs.existsSync(candidate)) return candidate;
116
+
117
+ const parentDir = path.dirname(currentDir);
118
+ if (parentDir === currentDir) return undefined;
119
+ currentDir = parentDir;
120
+ }
121
+ };
122
+
123
+ export const loadVerificationConfig = (
124
+ startDir: string,
125
+ ): { config: TVerificationConfig; filepath?: string; root: string } => {
126
+ const filepath = findVerificationConfigFilepath(startDir);
127
+ if (!filepath) return { config: normalizeVerificationConfig(undefined), root: path.resolve(startDir) };
128
+
129
+ return {
130
+ config: normalizeVerificationConfig(getDefaultExport(loadTsModule(filepath)), filepath),
131
+ filepath,
132
+ root: path.dirname(filepath),
133
+ };
134
+ };
@@ -152,8 +152,24 @@ const isRouterRenderCallback = (node: ts.FunctionLikeDeclaration) => {
152
152
  return lastFunctionArgument === unwrappedNode;
153
153
  };
154
154
 
155
+ const isDefinitionRouteRenderCallback = (node: ts.FunctionLikeDeclaration) => {
156
+ const unwrappedNode = unwrapExpression(node);
157
+ const parent = unwrappedNode.parent;
158
+ if (!ts.isPropertyAssignment(parent)) return false;
159
+ if (!ts.isIdentifier(parent.name) || parent.name.text !== 'render') return false;
160
+ if (!ts.isObjectLiteralExpression(parent.parent)) return false;
161
+
162
+ const callExpression = parent.parent.parent;
163
+ if (!ts.isCallExpression(callExpression)) return false;
164
+ if (!ts.isIdentifier(callExpression.expression)) return false;
165
+
166
+ const helperName = callExpression.expression.text;
167
+ return helperName === 'definePageRoute' || helperName === 'defineErrorRoute';
168
+ };
169
+
155
170
  const isValidHookContainer = (node: ts.FunctionLikeDeclaration) => {
156
171
  if (isRouterRenderCallback(node)) return true;
172
+ if (isDefinitionRouteRenderCallback(node)) return true;
157
173
 
158
174
  const functionName = getFunctionName(node);
159
175
  if (!functionName) return false;
package/config.ts CHANGED
@@ -1,5 +1,9 @@
1
- export { Application } from './common/applicationConfig';
1
+ export { Application, defineVerificationConfig } from './common/applicationConfig';
2
2
  export type {
3
3
  TApplicationIdentityConfig as ApplicationIdentityConfig,
4
4
  TApplicationSetupConfig as ApplicationSetupConfig,
5
+ TVerificationCheckScope as VerificationCheckScope,
6
+ TVerificationConfig as VerificationConfig,
7
+ TVerificationRuleConfig as VerificationRuleConfig,
8
+ TVerificationSuiteConfig as VerificationSuiteConfig,
5
9
  } from './common/applicationConfig';
@@ -14,7 +14,7 @@ Proteum 2.5 is a breaking cleanup release. It removes contextual app/router magi
14
14
 
15
15
  ## 1. Install The Published Package
16
16
 
17
- Update every Proteum app package in the repo to `proteum@^2.5.0`, then reinstall from npm.
17
+ Update every Proteum app package in the repo to `proteum@^2.5.1`, then reinstall from npm.
18
18
 
19
19
  ```bash
20
20
  npm install
@@ -71,16 +71,39 @@ const createProjectRouter = (app: ProjectApp): ProjectRouter =>
71
71
  app,
72
72
  );
73
73
 
74
- const ProjectApplication = defineApplication<ProjectServices, ProjectRouter>({
75
- services: (app) => ({
76
- Billing: new BillingService(app, {}, app),
77
- }),
74
+ const createProjectServices = (app: ProjectApp): ProjectServices => ({
75
+ Billing: new BillingService(app, {}, app),
76
+ });
77
+
78
+ const ProjectApplication = defineApplication({
79
+ services: createProjectServices,
78
80
  router: createProjectRouter,
79
81
  });
80
82
 
81
83
  export default ProjectApplication;
82
84
  ```
83
85
 
86
+ Generated app globals read the named app type exported by `server/index.ts`. Derive that named type from the default `defineApplication` export when the app does not need a more explicit bootstrap type:
87
+
88
+ ```ts
89
+ import type ProjectApplication from '@/server/index';
90
+
91
+ type ProjectApp = InstanceType<typeof ProjectApplication>;
92
+ ```
93
+
94
+ In app roots with service constructors that need earlier services during boot, use an explicit exported app type plus a progressively assigned service factory:
95
+
96
+ ```ts
97
+ export type ProjectServices = {
98
+ Models: ModelsService;
99
+ Domains: DomainsService;
100
+ };
101
+
102
+ export type ProjectApp = Application & ProjectServices & {
103
+ Router: ProjectRouter;
104
+ };
105
+ ```
106
+
84
107
  ## 4. Migrate Pages
85
108
 
86
109
  Replace legacy page registration with a default-exported page definition.
@@ -139,7 +162,7 @@ Use `defineServerRoutes((app) => [...])` only when the route definitions need ap
139
162
  Replace controller classes and `this.input(schema)` with explicit actions.
140
163
 
141
164
  ```ts
142
- import { defineAction, defineController, schema } from '@server/app/controller';
165
+ import { defineAction, defineController, schema } from '@generated/server/controller';
143
166
 
144
167
  export default defineController({
145
168
  path: 'Billing',
@@ -158,8 +181,27 @@ Rules:
158
181
  - Read parsed input from `context.input`.
159
182
  - Read request state from `request`, `response`, `api`, `auth`, and router-plugin context.
160
183
  - Call business logic through `services`, `models`, or `app`.
184
+ - Import controller definition helpers from `@generated/server/controller` when app-specific services, models, router, or request plugins are needed. The generated helper binds context to the concrete app type exported by `server/index.ts`.
185
+ - Keep action schemas aligned with existing typed service methods. If a service method accepts `{ domainIds?: string[]; entries?: Entry[] }`, the `defineAction({ input })` schema must mark those fields optional too. Proteum 2.5 generates strict client types from these schemas, so stale schemas that were previously hidden by loose clients become compile errors.
186
+
187
+ ## 7. Migrate Commands
188
+
189
+ Commands should no longer import a default `App` type from `server/index.ts`. The default export is now the application constructor returned by `defineApplication`.
190
+
191
+ ```ts
192
+ import { Commands } from '@server/app/commands';
193
+ import type ProjectApplication from '@/server/index';
194
+
195
+ type ProjectApp = InstanceType<typeof ProjectApplication>;
196
+
197
+ export default class BillingCommands extends Commands<ProjectApp> {
198
+ public async sync() {
199
+ return this.app.Billing.sync();
200
+ }
201
+ }
202
+ ```
161
203
 
162
- ## 7. Remove Legacy Magic
204
+ ## 8. Remove Legacy Magic
163
205
 
164
206
  Search user source for old contracts and remove every match.
165
207
 
@@ -175,7 +217,7 @@ Allowed replacements:
175
217
  - `this.app`, `this.services`, and `this.models` inside typed services.
176
218
  - `defineServerRoutes((app) => [...])` when server route definitions need app services.
177
219
 
178
- ## 8. Standardize Caught Error Handling
220
+ ## 9. Standardize Caught Error Handling
179
221
 
180
222
  Every caught error must end at the same framework error surface. Local UI feedback or protocol responses can still happen, but they are not the terminal error handling step by themselves.
181
223
 
@@ -195,7 +237,7 @@ Client rules:
195
237
 
196
238
  Do not treat `console.error(error)`, `console.warn(error)`, or any other `console.*(error)` call as error handling. Console calls can be temporary diagnostics, but they must not be the last stop for a caught error.
197
239
 
198
- ## 9. Refresh Generated Artifacts
240
+ ## 10. Refresh Generated Artifacts
199
241
 
200
242
  Do not edit `.proteum/**` manually. Regenerate it from source.
201
243
 
@@ -206,7 +248,7 @@ npx proteum typecheck
206
248
 
207
249
  If connected local projects are used through `file:` sources, start or validate producer apps before validating the consumer.
208
250
 
209
- ## 10. Validate Runtime Behavior
251
+ ## 11. Validate Runtime Behavior
210
252
 
211
253
  Run the smallest trustworthy checks first, then broaden when the touched surface requires it.
212
254
 
@@ -222,5 +264,6 @@ For protected flows, prefer Proteum session helpers over automating login unless
222
264
 
223
265
  - Production route-generation errors where top-level `Router.express(...)` was lifted outside registration are fixed by moving the route into `defineServerRoute({ handler: expressHandler(...) })`.
224
266
  - `@app` import errors are fixed by moving runtime access into `data`, `render`, route handlers, controller action handlers, or typed services.
225
- - Missing `this.app.Router` typings are fixed by exporting the concrete app and router types from `server/index.ts`.
267
+ - Missing `this.app.Router`, service, or `env` typings are fixed by exporting the project app type from `server/index.ts`, letting `defineApplication({ services, router })` infer from typed factories, and refreshing generated artifacts.
226
268
  - Static metadata errors are fixed by moving runtime-dependent values out of `path`, `method`, `options`, and error `code`.
269
+ - Shared package interfaces should match the server controller schemas they wrap. A shared client type that allows `null` or extra enum values while the server schema rejects them will now fail assignment against the generated controller client.
package/eslint.js CHANGED
@@ -16,23 +16,24 @@ const createZodTypeFactorySelector = (factoryName) =>
16
16
 
17
17
  const skippedTraversalKeys = new Set(['parent', 'loc', 'range', 'tokens', 'comments']);
18
18
 
19
- const traverseNode = (node, visit, parent = null, parentKey = null) => {
19
+ const traverseNode = (node, visit, parent = null, parentKey = null, ancestors = []) => {
20
20
  if (!node || typeof node !== 'object') return;
21
21
 
22
- visit(node, parent, parentKey);
22
+ visit(node, parent, parentKey, ancestors);
23
23
 
24
24
  for (const key of Object.keys(node)) {
25
25
  if (skippedTraversalKeys.has(key)) continue;
26
26
 
27
27
  const value = node[key];
28
+ const nextAncestors = [...ancestors, { node, childKey: key }];
28
29
  if (Array.isArray(value)) {
29
30
  value.forEach((child) => {
30
- if (child && typeof child.type === 'string') traverseNode(child, visit, node, key);
31
+ if (child && typeof child.type === 'string') traverseNode(child, visit, node, key, nextAncestors);
31
32
  });
32
33
  continue;
33
34
  }
34
35
 
35
- if (value && typeof value.type === 'string') traverseNode(value, visit, node, key);
36
+ if (value && typeof value.type === 'string') traverseNode(value, visit, node, key, nextAncestors);
36
37
  }
37
38
  };
38
39
 
@@ -160,8 +161,35 @@ const isServerErrorReporterCall = (callExpression) =>
160
161
 
161
162
  const isPromiseRejectCall = (callExpression) => getCalleePropertyName(callExpression.callee) === 'reject';
162
163
 
163
- const isPreservingCall = (callExpression, names, side) => {
164
+ const hasOptionalCallBoundary = (callExpression, ancestors = []) => {
165
+ if (callExpression.optional === true) return true;
166
+
167
+ let hasOptional = false;
168
+ traverseNode(callExpression.callee, (child) => {
169
+ if (child.type === 'ChainExpression' || child.optional === true) hasOptional = true;
170
+ });
171
+
172
+ return hasOptional || ancestors.some(({ node }) => node.type === 'ChainExpression');
173
+ };
174
+
175
+ const isUnderConditionalControlFlow = (ancestors = []) =>
176
+ ancestors.some(({ node, childKey }) => {
177
+ if (node.type === 'IfStatement') return childKey === 'consequent' || childKey === 'alternate';
178
+ if (node.type === 'ConditionalExpression') return childKey === 'consequent' || childKey === 'alternate';
179
+ if (node.type === 'LogicalExpression') return childKey === 'right';
180
+ if (node.type === 'SwitchCase') return childKey === 'consequent';
181
+
182
+ return (
183
+ ['ForInStatement', 'ForOfStatement', 'ForStatement', 'WhileStatement', 'DoWhileStatement'].includes(
184
+ node.type,
185
+ ) && childKey === 'body'
186
+ );
187
+ });
188
+
189
+ const isPreservingCall = (callExpression, names, side, ancestors = []) => {
164
190
  if (!nodeReferencesName(callExpression, names)) return false;
191
+ if (hasOptionalCallBoundary(callExpression, ancestors)) return false;
192
+ if (isUnderConditionalControlFlow(ancestors)) return false;
165
193
  if (isConsoleMember(callExpression.callee)) return false;
166
194
  if (isPromiseRejectCall(callExpression)) return true;
167
195
  if (side === 'client') return isClientErrorHandlerCall(callExpression);
@@ -173,9 +201,15 @@ const isPreservingCall = (callExpression, names, side) => {
173
201
  const handlerPreservesCaughtError = (node, names, side) => {
174
202
  let preserves = false;
175
203
 
176
- traverseNode(node, (child) => {
177
- if (child.type === 'ThrowStatement' && nodeReferencesName(child.argument, names)) preserves = true;
178
- if (child.type === 'CallExpression' && isPreservingCall(child, names, side)) preserves = true;
204
+ traverseNode(node, (child, _parent, _parentKey, ancestors) => {
205
+ if (
206
+ child.type === 'ThrowStatement' &&
207
+ nodeReferencesName(child.argument, names) &&
208
+ !isUnderConditionalControlFlow(ancestors)
209
+ ) {
210
+ preserves = true;
211
+ }
212
+ if (child.type === 'CallExpression' && isPreservingCall(child, names, side, ancestors)) preserves = true;
179
213
  });
180
214
 
181
215
  return preserves;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "proteum",
3
3
  "description": "LLM-first Opinionated Typescript Framework for web applications.",
4
- "version": "2.5.1",
4
+ "version": "2.5.2",
5
5
  "author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
6
6
  "repository": "git://github.com/gaetanlegac/proteum.git",
7
7
  "license": "MIT",
@@ -205,6 +205,20 @@ test('mcp help describes projectId routing', () => {
205
205
  assert.match(output, /--stdio/);
206
206
  });
207
207
 
208
+ test('verify help documents changed-file verification flags', () => {
209
+ const result = spawnSync(process.execPath, [cliBin, 'verify', '--help'], {
210
+ cwd: coreRoot,
211
+ encoding: 'utf8',
212
+ });
213
+ const output = `${result.stdout}\n${result.stderr}`;
214
+
215
+ assert.equal(result.status, 0);
216
+ assert.match(output, /changed/);
217
+ assert.match(output, /--staged/);
218
+ assert.match(output, /--base/);
219
+ assert.match(output, /--dry-run/);
220
+ });
221
+
208
222
  test('mcp daemon launch prints a central MCP connection banner', async () => {
209
223
  const registryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-mcp-daemon-launch-'));
210
224
  const reserveServer = http.createServer((req, res) => res.end('reserved'));
@@ -0,0 +1,98 @@
1
+ const assert = require('node:assert/strict');
2
+ const fs = require('node:fs');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+
6
+ const coreRoot = path.resolve(__dirname, '..');
7
+ process.env.TS_NODE_PROJECT = path.join(coreRoot, 'tsconfig.json');
8
+ process.env.TS_NODE_TRANSPILE_ONLY = '1';
9
+ require('ts-node/register/transpile-only');
10
+
11
+ const { buildContractsDoctorResponse } = require('../common/dev/contractsDoctor.ts');
12
+
13
+ const touch = (filepath, content = '') => {
14
+ fs.mkdirSync(path.dirname(filepath), { recursive: true });
15
+ fs.writeFileSync(filepath, content);
16
+ };
17
+
18
+ const createManifest = (appRoot, pageFilepath) => ({
19
+ app: {
20
+ identityFilepath: path.join(appRoot, 'identity.config.ts'),
21
+ root: appRoot,
22
+ setupFilepath: path.join(appRoot, 'proteum.config.ts'),
23
+ },
24
+ connectedProjects: [],
25
+ commands: [],
26
+ controllers: [],
27
+ layouts: [],
28
+ routes: {
29
+ client: [
30
+ {
31
+ chunkFilepath: 'landing/index',
32
+ filepath: pageFilepath,
33
+ },
34
+ ],
35
+ server: [],
36
+ },
37
+ services: {
38
+ app: [],
39
+ routerPlugins: [],
40
+ },
41
+ });
42
+
43
+ test('contracts doctor allows hooks inside definePageRoute render callbacks', () => {
44
+ const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-contracts-doctor-'));
45
+ const pageFilepath = path.join(appRoot, 'client/pages/landing/index.tsx');
46
+
47
+ touch(path.join(appRoot, 'proteum.config.ts'), 'export default {};');
48
+ touch(path.join(appRoot, 'identity.config.ts'), 'export default {};');
49
+ touch(path.join(appRoot, 'client/hooks/useToast.ts'), 'export default function useToast() { return () => undefined; }');
50
+ touch(
51
+ pageFilepath,
52
+ `import { definePageRoute } from '@common/router/definitions';
53
+ import useToast from '@/client/hooks/useToast';
54
+
55
+ export default definePageRoute({
56
+ path: '/',
57
+ options: {},
58
+ data: null,
59
+ render: () => {
60
+ const toast = useToast();
61
+ return toast ? null : null;
62
+ },
63
+ });
64
+ `,
65
+ );
66
+
67
+ for (const filepath of [
68
+ 'proteum.connected.json',
69
+ '.proteum/manifest.json',
70
+ '.proteum/proteum.connected.d.ts',
71
+ '.proteum/client/context.ts',
72
+ '.proteum/client/controllers.ts',
73
+ '.proteum/client/layouts.ts',
74
+ '.proteum/client/models.ts',
75
+ '.proteum/client/routes.ts',
76
+ '.proteum/client/services.d.ts',
77
+ '.proteum/common/controllers.ts',
78
+ '.proteum/common/models.ts',
79
+ '.proteum/common/services.d.ts',
80
+ '.proteum/server/commands.app.d.ts',
81
+ '.proteum/server/commands.d.ts',
82
+ '.proteum/server/commands.ts',
83
+ '.proteum/server/controllers.ts',
84
+ '.proteum/server/models.ts',
85
+ '.proteum/server/routes.ts',
86
+ '.proteum/server/services.d.ts',
87
+ '.proteum/client/route-modules/landing/index.tsx',
88
+ '.proteum/server/route-modules/client/pages/landing/index.tsx',
89
+ ]) {
90
+ touch(path.join(appRoot, filepath));
91
+ }
92
+
93
+ const response = buildContractsDoctorResponse(createManifest(appRoot, pageFilepath));
94
+ assert.equal(
95
+ response.diagnostics.some((diagnostic) => diagnostic.code === 'runtime/provider-hook-outside-provider'),
96
+ false,
97
+ );
98
+ });
@@ -18,6 +18,7 @@ const {
18
18
  indexRouteDefinitions,
19
19
  writeGeneratedRouteModule,
20
20
  } = require('../cli/compiler/common/generatedRouteModules.ts');
21
+ const { createTypedControllerHelperContent } = require('../cli/compiler/artifacts/controllerHelper.ts');
21
22
  const { findClientRouteFiles } = require('../cli/compiler/artifacts/discovery.ts');
22
23
  const { indexControllers } = require('../cli/compiler/common/controllers.ts');
23
24
  const {
@@ -107,6 +108,51 @@ export default definePageRoute({
107
108
  assert.deepEqual(definition.normalizedOptionKeys, ['auth']);
108
109
  });
109
110
 
111
+ test('route indexer accepts imported const-only option metadata', () => {
112
+ const root = createTempDir();
113
+ const filepath = path.join(root, 'client/pages/feed.tsx');
114
+ const catalogFilepath = path.join(root, 'client/catalogs/routes.ts');
115
+
116
+ writeFile(
117
+ catalogFilepath,
118
+ `export const PAGE_AUTH = {
119
+ feed: {
120
+ auth: {
121
+ hasFeature: 'opportunitiesFreq',
122
+ },
123
+ authTracking: {
124
+ source: 'investor_feed',
125
+ feature: 'opportunitiesFreq',
126
+ action: 'open-feed',
127
+ triggerKind: 'open',
128
+ },
129
+ },
130
+ } as const;
131
+ `,
132
+ );
133
+ writeFile(
134
+ filepath,
135
+ `import { definePageRoute } from '@common/router/definitions';
136
+ import { PAGE_AUTH } from '@/client/catalogs/routes';
137
+
138
+ export default definePageRoute({
139
+ path: '/feed',
140
+ options: {
141
+ auth: PAGE_AUTH.feed.auth,
142
+ authTracking: PAGE_AUTH.feed.authTracking,
143
+ },
144
+ data: null,
145
+ render: () => null,
146
+ });
147
+ `,
148
+ );
149
+
150
+ const [definition] = indexRouteDefinitions({ side: 'client', sourceFilepath: filepath });
151
+
152
+ assert.equal(definition.path, '/feed');
153
+ assert.deepEqual(definition.normalizedOptionKeys, ['auth', 'authTracking']);
154
+ });
155
+
110
156
  test('router port override updates absolute runtime URLs', () => {
111
157
  const root = createTempDir();
112
158
 
@@ -262,6 +308,37 @@ export default defineServerRoutes(({ Users }) => [
262
308
  assert.equal(definition.targetResolution, 'static-expression');
263
309
  });
264
310
 
311
+ test('server route factories can push explicit route definitions from a block body', () => {
312
+ const root = createTempDir();
313
+ const filepath = path.join(root, 'server/routes/api.ts');
314
+
315
+ writeFile(
316
+ filepath,
317
+ `import { defineServerRoute, defineServerRoutes } from '@common/router/definitions';
318
+
319
+ export default defineServerRoutes((app) => {
320
+ const { Users } = app;
321
+ const routeOptions = {};
322
+ const routes = [];
323
+
324
+ routes.push(defineServerRoute({
325
+ method: 'GET',
326
+ path: '/api/users',
327
+ options: routeOptions,
328
+ handler: async () => Users.list(),
329
+ }));
330
+
331
+ return routes;
332
+ });
333
+ `,
334
+ );
335
+
336
+ const [definition] = indexRouteDefinitions({ side: 'server', sourceFilepath: filepath });
337
+
338
+ assert.equal(definition.methodName, 'get');
339
+ assert.equal(definition.path, '/api/users');
340
+ });
341
+
265
342
  test('route definitions register through explicit router registrar', () => {
266
343
  const calls = [];
267
344
 
@@ -386,6 +463,17 @@ export default class Legacy extends Controller {
386
463
  );
387
464
  });
388
465
 
466
+ test('generated typed controller helper binds action context to app root', () => {
467
+ const content = createTypedControllerHelperContent('TestApp');
468
+
469
+ assert.match(content, /@server\/app\/controller/);
470
+ assert.match(content, /@\/server\/index/);
471
+ assert.match(content, /export type TApplication = import\('@\/server\/index'\)\.TestApp/);
472
+ assert.match(content, /export type TControllerRequestServices = import\('@\/server\/index'\)\.TControllerRequestServices/);
473
+ assert.match(content, /TControllerActionContext<\n TInput,\n TApplication,\n TControllerRequestServices\n>/);
474
+ assert.match(content, /handler: \(context: TTypedControllerActionContext<z\.output<TSchema>>\) => TResult/);
475
+ });
476
+
389
477
  test('service artifact parser reads defineApplication router factories', () => {
390
478
  const root = createTempDir();
391
479
  const filepath = path.join(root, 'server/index.ts');
@@ -451,3 +539,44 @@ export default App;
451
539
  assert.deepEqual(bootstrap.rootServices.map((service) => service.registeredName), ['Router']);
452
540
  assert.deepEqual(bootstrap.routerPlugins.map((service) => service.registeredName), ['schema']);
453
541
  });
542
+
543
+ test('service artifact parser reads named defineApplication service factories', () => {
544
+ const root = createTempDir();
545
+ const filepath = path.join(root, 'server/index.ts');
546
+ const serviceFilepath = path.join(root, 'server/services/Billing.ts');
547
+ const { parseAppBootstrapSource } = loadServiceArtifactsForAppRoot(root);
548
+
549
+ writeFile(serviceFilepath, 'export default class Billing {}');
550
+ writeFile(
551
+ filepath,
552
+ `import { defineApplication } from '@server/app';
553
+ import Router from '@server/services/router';
554
+ import SchemaRouter from '@server/services/schema/router';
555
+ import Billing from '@/server/services/Billing';
556
+
557
+ const createServices = (app) => ({
558
+ Billing: new Billing(app, {}, app),
559
+ });
560
+
561
+ const createRouter = (app) => new Router(
562
+ app,
563
+ {
564
+ plugins: {
565
+ schema: new SchemaRouter({}, app),
566
+ },
567
+ },
568
+ app,
569
+ );
570
+
571
+ export default defineApplication({
572
+ services: createServices,
573
+ router: createRouter,
574
+ });
575
+ `,
576
+ );
577
+
578
+ const bootstrap = parseAppBootstrapSource(filepath);
579
+
580
+ assert.deepEqual(bootstrap.rootServices.map((service) => service.registeredName), ['Billing', 'Router']);
581
+ assert.deepEqual(bootstrap.routerPlugins.map((service) => service.registeredName), ['schema']);
582
+ });