@mettlecast/domain-cli 0.2.60 → 0.2.61

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 (89) hide show
  1. package/dist/builder/build-registry.d.ts +1 -1
  2. package/dist/builder/build-registry.js +1 -36
  3. package/dist/builder/build-types.d.ts +1 -1
  4. package/dist/builder/load-module.d.ts +1 -1
  5. package/dist/cli.js +1 -1
  6. package/dist/commands/add-api.js +2 -2
  7. package/dist/commands/add-domain.js +4 -4
  8. package/dist/commands/add-fixture-factory.js +5 -6
  9. package/dist/commands/build-catalog.d.ts +6 -22
  10. package/dist/commands/build-catalog.js +7 -18
  11. package/dist/commands/build.js +2 -1
  12. package/dist/commands/dev.js +1 -1
  13. package/dist/commands/doctor.js +66 -37
  14. package/dist/commands/explain.js +13 -13
  15. package/dist/commands/generate-openapi.d.ts +10 -1
  16. package/dist/commands/generate-openapi.js +19 -33
  17. package/dist/commands/show.d.ts +2 -3
  18. package/dist/commands/show.js +0 -2
  19. package/dist/commands/test.js +0 -1
  20. package/dist/commands/upgrade-backend.js +3 -3
  21. package/dist/commands/validate.js +12 -90
  22. package/dist/server/api-server.d.ts +1 -1
  23. package/dist/server/mount-routes.d.ts +11 -2
  24. package/dist/server/mount-routes.js +20 -8
  25. package/dist/templates/api-skeleton.d.ts +5 -0
  26. package/dist/templates/api-skeleton.js +28 -27
  27. package/dist/templates/claude-md.js +1 -1
  28. package/dist/templates/patterns/api/create-with-event.d.ts +4 -0
  29. package/dist/templates/patterns/api/create-with-event.js +38 -32
  30. package/dist/templates/patterns/api/idempotent-mutation.d.ts +4 -0
  31. package/dist/templates/patterns/api/idempotent-mutation.js +47 -41
  32. package/dist/templates/patterns/api/paginated-list.d.ts +4 -0
  33. package/dist/templates/patterns/api/paginated-list.js +30 -24
  34. package/dist/templates/patterns/api/simple-crud.d.ts +4 -0
  35. package/dist/templates/patterns/api/simple-crud.js +46 -35
  36. package/dist/templates/patterns/api/streaming-list.d.ts +4 -0
  37. package/dist/templates/patterns/api/streaming-list.js +46 -41
  38. package/dist/templates/patterns/api/system-admin.d.ts +4 -0
  39. package/dist/templates/patterns/api/system-admin.js +59 -52
  40. package/dist/templates/patterns/api/webhook-receiver-style.d.ts +4 -0
  41. package/dist/templates/patterns/api/webhook-receiver-style.js +43 -35
  42. package/dist/types.d.ts +100 -0
  43. package/dist/types.js +1 -0
  44. package/dist/utils/file-helpers.d.ts +0 -2
  45. package/dist/utils/file-helpers.js +2 -3
  46. package/dist/utils/scaffold-config.d.ts +6 -0
  47. package/dist/utils/scaffold-config.js +2 -0
  48. package/package.json +1 -1
  49. package/src/__tests__/build-registry.test.ts +43 -20
  50. package/src/__tests__/build-types.test.ts +4 -7
  51. package/src/__tests__/builder/walkDomainDir.test.ts +19 -21
  52. package/src/__tests__/commands/add-api.test.ts +12 -10
  53. package/src/__tests__/commands/add-domain.test.ts +8 -5
  54. package/src/__tests__/commands/create-project.test.ts +5 -5
  55. package/src/__tests__/commands/dev.test.ts +0 -1
  56. package/src/__tests__/mount-routes.test.ts +64 -23
  57. package/src/__tests__/package-freshness.test.ts +1 -21
  58. package/src/__tests__/smoke/scaffold.test.ts +13 -15
  59. package/src/__tests__/validate.test.ts +21 -103
  60. package/src/builder/build-registry.ts +7 -44
  61. package/src/builder/build-types.ts +1 -1
  62. package/src/cli.ts +1 -1
  63. package/src/commands/add-api.ts +2 -2
  64. package/src/commands/add-domain.ts +4 -4
  65. package/src/commands/add-fixture-factory.ts +5 -6
  66. package/src/commands/build-catalog.ts +13 -35
  67. package/src/commands/build.ts +3 -2
  68. package/src/commands/dev.ts +1 -1
  69. package/src/commands/doctor.ts +68 -37
  70. package/src/commands/explain.ts +13 -13
  71. package/src/commands/generate-openapi.ts +30 -52
  72. package/src/commands/show.ts +2 -5
  73. package/src/commands/test.ts +0 -1
  74. package/src/commands/upgrade-backend.ts +3 -3
  75. package/src/commands/validate.ts +11 -96
  76. package/src/server/api-server.ts +1 -1
  77. package/src/server/mount-routes.ts +21 -10
  78. package/src/templates/api-skeleton.ts +29 -28
  79. package/src/templates/claude-md.ts +1 -1
  80. package/src/templates/patterns/api/create-with-event.ts +39 -33
  81. package/src/templates/patterns/api/idempotent-mutation.ts +48 -42
  82. package/src/templates/patterns/api/paginated-list.ts +31 -25
  83. package/src/templates/patterns/api/simple-crud.ts +47 -36
  84. package/src/templates/patterns/api/streaming-list.ts +47 -42
  85. package/src/templates/patterns/api/system-admin.ts +60 -53
  86. package/src/templates/patterns/api/webhook-receiver-style.ts +48 -40
  87. package/src/types.ts +128 -0
  88. package/src/utils/file-helpers.ts +2 -5
  89. package/src/utils/scaffold-config.ts +9 -0
package/src/cli.ts CHANGED
@@ -73,7 +73,7 @@ program
73
73
 
74
74
  program
75
75
  .command('dev <domain>')
76
- .description('Start a local HTTP server simulating API Gateway for all defineApi handlers')
76
+ .description('Start a local HTTP server simulating API Gateway for all API-exposed defineAction handlers')
77
77
  .option('--port <n>', 'Port to listen on', '3000')
78
78
  .action(async (domain: string, opts: { port?: string }) => {
79
79
  await runDev({ domainRoot: domain, port: opts.port ? parseInt(opts.port, 10) : 3000 });
@@ -96,7 +96,7 @@ export async function runAddApi(opts: AddApiOptions): Promise<void> {
96
96
  throw new Error(`Domain "${opts.domain}" not found at ${domainDir}`);
97
97
  }
98
98
 
99
- const apiFilePath = join(domainDir, 'api', `${opts.id}.ts`);
99
+ const apiFilePath = join(domainDir, 'actions', `${opts.id}.ts`);
100
100
 
101
101
  // Refuse if API already exists
102
102
  try {
@@ -124,7 +124,7 @@ export async function runAddApi(opts: AddApiOptions): Promise<void> {
124
124
  await writeFile(apiFilePath, apiContent);
125
125
 
126
126
  // Create fixture with the input schema's default shape as the example payload
127
- const apiTestDir = join(domainDir, 'api', '__tests__');
127
+ const apiTestDir = join(domainDir, 'actions', '__tests__');
128
128
  await mkdir(apiTestDir, { recursive: true });
129
129
  await writeFile(
130
130
  join(apiTestDir, `${opts.id}.fixture.json`),
@@ -51,18 +51,18 @@ export async function runAddDomain(opts: AddDomainOptions): Promise<void> {
51
51
  }
52
52
 
53
53
  // Create directory structure
54
- await mkdir(join(domainDir, 'api', '__tests__'), { recursive: true });
54
+ await mkdir(join(domainDir, 'actions', '__tests__'), { recursive: true });
55
55
  await mkdir(join(domainDir, 'subscribers'), { recursive: true });
56
56
  await mkdir(join(domainDir, 'publishes'), { recursive: true });
57
57
 
58
58
  // Write skeleton files
59
59
  await writeFile(join(domainDir, 'domain.config.ts'), domainConfigTemplate(opts.id, opts.tenancy));
60
60
  await writeFile(
61
- join(domainDir, 'api', 'example.ts'),
61
+ join(domainDir, 'actions', 'example.ts'),
62
62
  apiSkeletonTemplate(opts.id, 'example', opts.tenancy)
63
63
  );
64
64
  await writeFile(
65
- join(domainDir, 'api', '__tests__', 'example.fixture.json'),
65
+ join(domainDir, 'actions', '__tests__', 'example.fixture.json'),
66
66
  apiFixtureSkeleton(opts.id, 'example')
67
67
  );
68
68
  await writeFile(join(domainDir, 'publishes', 'events.ts'), eventsSkeletonTemplate(opts.id));
@@ -94,7 +94,7 @@ export async function runAddDomain(opts: AddDomainOptions): Promise<void> {
94
94
  cliLogger.info({ id: opts.id, dir: domainDir }, 'Domain scaffolded');
95
95
  // eslint-disable-next-line no-console
96
96
  console.log(
97
- `\n✓ Domain "${opts.id}" added at ${domainDir}\nNext: edit api/example.ts, ` +
97
+ `\n✓ Domain "${opts.id}" added at ${domainDir}\nNext: edit actions/example.ts, ` +
98
98
  `then run \`mc-domain-module build ${opts.id}\` to generate the registry.`
99
99
  );
100
100
  }
@@ -47,21 +47,20 @@ export async function runAddFixtureFactory(options: AddFixtureFactoryOptions): P
47
47
  const domain = options.domain;
48
48
  const apiId = options.apiId;
49
49
 
50
- const apiFile = join(projectRoot, 'domains', domain, 'api', `${apiId}.ts`);
50
+ const apiFile = join(projectRoot, 'domains', domain, 'actions', `${apiId}.ts`);
51
51
  await access(apiFile).catch(() => {
52
- throw new Error(`API file not found: ${apiFile}. Run add-api first.`);
52
+ throw new Error(`Action file not found: ${apiFile}. Run add-api first.`);
53
53
  });
54
54
 
55
- // Read the API file to extract the input type name
55
+ // Read the action file to extract the input type name
56
56
  const content = await readFile(apiFile, 'utf8');
57
- const outputTypeArg = content.match(/import\s+\{\s*[\w\s,]*\s*\}\s+from\s+['"]@mettlecast/);
58
57
 
59
- const factoryFile = join(projectRoot, 'domains', domain, 'api', '__tests__', `${apiId}.factory.ts`);
58
+ const factoryFile = join(projectRoot, 'domains', domain, 'actions', '__tests__', `${apiId}.factory.ts`);
60
59
  const factoryContent = FACTORY_TEMPLATE
61
60
  .replace(/\{domain\}/g, domain)
62
61
  .replace(/\{apiId\}/g, apiId);
63
62
 
64
- await mkdir(join(projectRoot, 'domains', domain, 'api', '__tests__'), { recursive: true });
63
+ await mkdir(join(projectRoot, 'domains', domain, 'actions', '__tests__'), { recursive: true });
65
64
  await writeFile(factoryFile, factoryContent, 'utf8');
66
65
 
67
66
  cliLogger.info({ factoryFile }, 'add-fixture-factory: factory written');
@@ -2,27 +2,18 @@ import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { cliLogger } from '../utils/logger.js';
4
4
 
5
- /** A single API entry in the catalog. */
6
- export interface CatalogApi {
7
- id: string;
8
- domainId: string;
9
- path: string;
10
- method: string;
11
- authType: string;
12
- description?: string;
13
- requestSchema?: Record<string, unknown>;
14
- responseSchema?: Record<string, unknown>;
15
- versions?: Array<{ version: string; requestSchema?: Record<string, unknown>; responseSchema?: Record<string, unknown> }>;
16
- examples?: { request?: Record<string, unknown>; response?: Record<string, unknown> };
17
- }
18
-
19
5
  /** A single action entry in the catalog. */
20
6
  export interface CatalogAction {
21
7
  id: string;
22
8
  domainId: string;
23
- visibility: string;
9
+ backendAccess: string;
10
+ exposure: Record<string, unknown>;
11
+ exposureDeclared?: boolean;
24
12
  idempotent: boolean;
25
13
  description?: string;
14
+ handlerFile?: string;
15
+ deployment?: Record<string, unknown>;
16
+ outboundAccess?: string;
26
17
  inputSchema?: Record<string, unknown>;
27
18
  outputSchema?: Record<string, unknown>;
28
19
  }
@@ -83,7 +74,6 @@ export interface DomainCatalog {
83
74
  version: 2;
84
75
  generatedAt: string;
85
76
  domains: CatalogDomain[];
86
- apis: CatalogApi[];
87
77
  actions: CatalogAction[];
88
78
  events: CatalogEvent[];
89
79
  subscribers: CatalogSubscriber[];
@@ -118,7 +108,6 @@ export async function runBuildCatalog(registryDir?: string): Promise<DomainCatal
118
108
  version: 2,
119
109
  generatedAt: new Date().toISOString(),
120
110
  domains: [],
121
- apis: [],
122
111
  actions: [],
123
112
  events: [],
124
113
  subscribers: [],
@@ -152,30 +141,19 @@ export async function runBuildCatalog(registryDir?: string): Promise<DomainCatal
152
141
  description: domainEntry['description'] as string | undefined,
153
142
  });
154
143
 
155
- const apis = (registry['apis'] as Record<string, unknown>[] | undefined) ?? [];
156
- for (const api of apis) {
157
- catalog.apis.push({
158
- id: String(api['id']),
159
- domainId,
160
- path: String(api['path']),
161
- method: String(api['method'] ?? 'ANY'),
162
- authType: String(api['authType'] ?? 'jwt'),
163
- description: api['description'] as string | undefined,
164
- requestSchema: api['requestSchema'] as Record<string, unknown> | undefined,
165
- responseSchema: api['responseSchema'] as Record<string, unknown> | undefined,
166
- versions: api['versions'] as CatalogApi['versions'],
167
- examples: api['examples'] as CatalogApi['examples'],
168
- });
169
- }
170
-
171
144
  const actions = (registry['actions'] as Record<string, unknown>[] | undefined) ?? [];
172
145
  for (const action of actions) {
173
146
  catalog.actions.push({
174
147
  id: String(action['id']),
175
148
  domainId,
176
- visibility: String(action['visibility'] ?? 'private'),
149
+ backendAccess: String(action['backendAccess'] ?? 'private'),
150
+ exposure: (action['exposure'] as Record<string, unknown> | undefined) ?? { type: 'internal' },
151
+ exposureDeclared: action['exposureDeclared'] as boolean | undefined,
177
152
  idempotent: Boolean(action['idempotent'] ?? false),
178
153
  description: action['description'] as string | undefined,
154
+ handlerFile: action['handlerFile'] as string | undefined,
155
+ deployment: action['deployment'] as Record<string, unknown> | undefined,
156
+ outboundAccess: action['outboundAccess'] as string | undefined,
179
157
  inputSchema: action['inputSchema'] as Record<string, unknown> | undefined,
180
158
  outputSchema: action['outputSchema'] as Record<string, unknown> | undefined,
181
159
  });
@@ -241,7 +219,7 @@ export async function runBuildCatalog(registryDir?: string): Promise<DomainCatal
241
219
  await writeFile(outPath, JSON.stringify(catalog), 'utf8');
242
220
 
243
221
  cliLogger.info(
244
- { outPath, domains: catalog.domains.length, apis: catalog.apis.length, actions: catalog.actions.length, events: catalog.events.length, jobs: catalog.jobs.length, schedules: catalog.schedules.length, integrations: catalog.integrations.length },
222
+ { outPath, domains: catalog.domains.length, actions: catalog.actions.length, events: catalog.events.length, jobs: catalog.jobs.length, schedules: catalog.schedules.length, integrations: catalog.integrations.length },
245
223
  'Domain catalog written'
246
224
  );
247
225
 
@@ -1,6 +1,6 @@
1
1
  import { writeFile, mkdir, readdir, readFile } from 'node:fs/promises';
2
2
  import { join, resolve, basename } from 'node:path';
3
- import type { DomainRegistry } from '@mettlecast/domain-cdk-packer';
3
+ import type { DomainRegistry } from '../types.js';
4
4
  import { buildRegistry } from '../builder/build-registry.js';
5
5
  import { buildActionsTypes } from '../builder/build-types.js';
6
6
  import { cliLogger } from '../utils/logger.js';
@@ -40,7 +40,8 @@ export async function runBuild(options: BuildOptions): Promise<string> {
40
40
  await mkdir(join(outFile, '..'), { recursive: true });
41
41
  await writeFile(outFile, JSON.stringify(registry), 'utf8');
42
42
 
43
- cliLogger.info({ outFile, apis: registry.apis.length, events: registry.events.length }, 'Registry written');
43
+ const apiExposedActions = registry.actions.filter(action => action.exposure.type === 'api').length;
44
+ cliLogger.info({ outFile, apiExposedActions, events: registry.events.length }, 'Registry written');
44
45
 
45
46
  // Discover all sibling registries and emit aggregated types
46
47
  const registryDir = join(outFile, '..');
@@ -55,7 +55,7 @@ export async function startDev(options: DevOptions): Promise<DevHandle> {
55
55
  }
56
56
 
57
57
  cliLogger.info(
58
- { domain: registry.domain.id, apis: registry.apis.length, port },
58
+ { domain: registry.domain.id, apiActions: registry.actions.filter(a => a.exposure?.type === 'api').length, port },
59
59
  'Starting local dev server'
60
60
  );
61
61
 
@@ -121,8 +121,10 @@ async function checkHandlersUseResult(projectRoot: string): Promise<DoctorCheck>
121
121
  const missing: string[] = [];
122
122
  for (const file of apiFiles) {
123
123
  const content = await readFile(file, 'utf8');
124
- // A file contains a handler export — check the return type
125
- if (/defineApi\s*\(/.test(content)) {
124
+ // A file contains a handler export — check the return type.
125
+ // Issue #4689: defineApi was removed. The action-first
126
+ // contract uses defineAction with exposure.type='api'.
127
+ if (/defineAction\s*\(/.test(content)) {
126
128
  // Look for Result<T> in the handler's return type annotation
127
129
  if (!/: .*Result</.test(content)) {
128
130
  missing.push(relative(projectRoot, file));
@@ -448,49 +450,64 @@ async function checkNoCrossDomainImports(projectRoot: string): Promise<DoctorChe
448
450
  async function checkApisHaveVersions(projectRoot: string): Promise<DoctorCheck> {
449
451
  try {
450
452
  const domainsDir = join(projectRoot, 'domains');
451
- const allApiFiles: string[] = [];
453
+ const allActionFiles: string[] = [];
452
454
 
453
- // Find all API files in all domains
455
+ // Find API-exposed action files in both actions/ and api/ directories.
456
+ // Issue #4689: defineApi was removed. API-exposed actions live in
457
+ // domains/*/actions/ but legacy api/ directories may still exist.
454
458
  const entries = await readdir(domainsDir, { withFileTypes: true });
455
459
  for (const entry of entries) {
456
460
  if (!entry.isDirectory()) continue;
457
- const apiDir = join(domainsDir, entry.name, 'api');
458
- const apiFiles = findFiles(apiDir, /\.ts$/);
459
- allApiFiles.push(...apiFiles);
461
+ for (const dir of ['actions', 'api']) {
462
+ const actionDir = join(domainsDir, entry.name, dir);
463
+ const files = findFiles(actionDir, /\.ts$/);
464
+ allActionFiles.push(...files);
465
+ }
460
466
  }
461
- const apiFiles = allApiFiles;
462
467
 
463
- if (apiFiles.length === 0) {
468
+ // Only consider files that define an API-exposed action (defineAction
469
+ // with exposure.type === 'api' or exposure: { type: 'api' }).
470
+ const apiExposedFiles: string[] = [];
471
+ for (const file of allActionFiles) {
472
+ const content = await readFile(file, 'utf8');
473
+ if (/defineAction\s*\(/.test(content) && /exposure\s*:\s*\{\s*type\s*:\s*['"]api['"]/.test(content)) {
474
+ apiExposedFiles.push(file);
475
+ }
476
+ }
477
+
478
+ if (apiExposedFiles.length === 0) {
464
479
  return {
465
480
  name: 'APIs declare versions',
466
481
  status: 'PASS',
467
- message: 'No APIs found (optional)',
482
+ message: 'No API-exposed actions found (optional)',
468
483
  kNodeRef: 'K:runbook:add-domain',
469
484
  };
470
485
  }
471
486
 
472
- // Simple heuristic: grep for 'versions:' in each API file
473
- let missingVersions = 0;
474
- for (const file of apiFiles) {
487
+ // Issue #4689: action-first contract requires input/output Zod schemas
488
+ // with `.default({...})` example data. Check that each API-exposed
489
+ // action file contains `.default(` (indicating example data).
490
+ let missingDefaults = 0;
491
+ for (const file of apiExposedFiles) {
475
492
  const content = await readFile(file, 'utf8');
476
- if (!content.includes('versions:')) {
477
- missingVersions++;
493
+ if (!content.includes('.default(')) {
494
+ missingDefaults++;
478
495
  }
479
496
  }
480
497
 
481
- if (missingVersions === 0) {
498
+ if (missingDefaults === 0) {
482
499
  return {
483
500
  name: 'APIs declare versions',
484
501
  status: 'PASS',
485
- message: `All ${apiFiles.length} API files declare versions`,
502
+ message: `All ${apiExposedFiles.length} API-exposed action(s) have .default() example data`,
486
503
  kNodeRef: 'K:runbook:add-domain',
487
504
  };
488
505
  } else {
489
506
  return {
490
507
  name: 'APIs declare versions',
491
508
  status: 'FAIL',
492
- message: `${missingVersions}/${apiFiles.length} API files missing versions: field`,
493
- fixHint: 'Add versions: { v1: { ... } } to defineApi calls',
509
+ message: `${missingDefaults}/${apiExposedFiles.length} API-exposed action(s) missing .default() example data. Add \`.default({...})\` to the top-level input and output Zod schemas in each defineAction({ exposure: { type: 'api', ... } }) call.`,
510
+ fixHint: "Add `input` and `output` Zod schemas with `.default({...})` to defineAction({ exposure: { type: 'api', ... } }) calls",
494
511
  kNodeRef: 'K:runbook:add-domain',
495
512
  };
496
513
  }
@@ -498,7 +515,7 @@ async function checkApisHaveVersions(projectRoot: string): Promise<DoctorCheck>
498
515
  return {
499
516
  name: 'APIs declare versions',
500
517
  status: 'WARN',
501
- message: `Could not check API versions: ${String(err)}`,
518
+ message: `Could not check API schemas: ${String(err)}`,
502
519
  kNodeRef: 'K:runbook:add-domain',
503
520
  };
504
521
  }
@@ -507,30 +524,42 @@ async function checkApisHaveVersions(projectRoot: string): Promise<DoctorCheck>
507
524
  async function checkApisHaveTenancy(projectRoot: string): Promise<DoctorCheck> {
508
525
  try {
509
526
  const domainsDir = join(projectRoot, 'domains');
510
- const allApiFiles: string[] = [];
527
+ const allActionFiles: string[] = [];
511
528
 
512
- // Find all API files in all domains
529
+ // Find API-exposed action files in both actions/ and api/ directories.
530
+ // Issue #4689: defineApi was removed. Tenancy is now declared on the
531
+ // action's exposure block via `exposure.tenancy`.
513
532
  const entries = await readdir(domainsDir, { withFileTypes: true });
514
533
  for (const entry of entries) {
515
534
  if (!entry.isDirectory()) continue;
516
- const apiDir = join(domainsDir, entry.name, 'api');
517
- const apiFiles = findFiles(apiDir, /\.ts$/);
518
- allApiFiles.push(...apiFiles);
535
+ for (const dir of ['actions', 'api']) {
536
+ const actionDir = join(domainsDir, entry.name, dir);
537
+ const files = findFiles(actionDir, /\.ts$/);
538
+ allActionFiles.push(...files);
539
+ }
519
540
  }
520
- const apiFiles = allApiFiles;
521
541
 
522
- if (apiFiles.length === 0) {
542
+ // Only consider files that define an API-exposed action.
543
+ const apiExposedFiles: string[] = [];
544
+ for (const file of allActionFiles) {
545
+ const content = await readFile(file, 'utf8');
546
+ if (/defineAction\s*\(/.test(content) && /exposure\s*:\s*\{\s*type\s*:\s*['"]api['"]/.test(content)) {
547
+ apiExposedFiles.push(file);
548
+ }
549
+ }
550
+
551
+ if (apiExposedFiles.length === 0) {
523
552
  return {
524
553
  name: 'APIs declare tenancy',
525
554
  status: 'PASS',
526
- message: 'No APIs found (optional)',
555
+ message: 'No API-exposed actions found (optional)',
527
556
  kNodeRef: 'K:convention:tier-1-foundations',
528
557
  };
529
558
  }
530
559
 
531
- // Simple heuristic: grep for 'tenancy:' in each API file
560
+ // Check for `tenancy:` anywhere in each API-exposed action file.
532
561
  let missingTenancy = 0;
533
- for (const file of apiFiles) {
562
+ for (const file of apiExposedFiles) {
534
563
  const content = await readFile(file, 'utf8');
535
564
  if (!content.includes('tenancy:')) {
536
565
  missingTenancy++;
@@ -541,15 +570,15 @@ async function checkApisHaveTenancy(projectRoot: string): Promise<DoctorCheck> {
541
570
  return {
542
571
  name: 'APIs declare tenancy',
543
572
  status: 'PASS',
544
- message: `All ${apiFiles.length} API files declare tenancy`,
573
+ message: `All ${apiExposedFiles.length} API-exposed action(s) declare tenancy`,
545
574
  kNodeRef: 'K:convention:tier-1-foundations',
546
575
  };
547
576
  } else {
548
577
  return {
549
578
  name: 'APIs declare tenancy',
550
579
  status: 'FAIL',
551
- message: `${missingTenancy}/${apiFiles.length} API files missing tenancy: field`,
552
- fixHint: "Add tenancy: 'required' | 'none' | 'system' to defineApi calls",
580
+ message: `${missingTenancy}/${apiExposedFiles.length} API-exposed action(s) missing tenancy. Tenancy must be declared on the action's exposure block: \`exposure.tenancy: 'required' | 'none' | 'system'\`.`,
581
+ fixHint: "Add `exposure.tenancy: 'required' | 'none' | 'system'` to defineAction({ exposure: { type: 'api', ... } }) calls",
553
582
  kNodeRef: 'K:convention:tier-1-foundations',
554
583
  };
555
584
  }
@@ -873,7 +902,7 @@ async function checkAllRoutesUseTanStackRouter(projectRoot: string): Promise<Doc
873
902
 
874
903
  /**
875
904
  * Check W5-4: Lambda handler files contain initOtel() call.
876
- * Scans `domains/*\/api/*.ts` and FAILs if any handler is missing initOtel().
905
+ * Scans `domains/*\/actions/*.ts` and `domains/*\/api/*.ts` and FAILs if any handler is missing initOtel().
877
906
  */
878
907
  async function checkOtelInitInLambdas(projectRoot: string): Promise<DoctorCheck> {
879
908
  try {
@@ -890,9 +919,11 @@ async function checkOtelInitInLambdas(projectRoot: string): Promise<DoctorCheck>
890
919
  const handlerFiles: string[] = [];
891
920
  for (const entry of entries) {
892
921
  if (!entry.isDirectory()) continue;
893
- const apiDir = join(domainsDir, entry.name, 'api');
894
- const files = findFiles(apiDir, /\.ts$/);
895
- handlerFiles.push(...files);
922
+ for (const dir of ['actions', 'api']) {
923
+ const handlerDir = join(domainsDir, entry.name, dir);
924
+ const files = findFiles(handlerDir, /\.ts$/);
925
+ handlerFiles.push(...files);
926
+ }
896
927
  }
897
928
 
898
929
  if (handlerFiles.length === 0) {
@@ -58,25 +58,25 @@ const RULE_EXPLANATIONS: Record<string, RuleExplanation> = {
58
58
  ruleId: 'no-raw-http-server',
59
59
  title: 'No raw HTTP server in domain code',
60
60
  description:
61
- 'Domain code must not create raw HTTP servers (express, fastify, etc.). All HTTP handling goes through defineApi which is wired to API Gateway by the scaffold.',
61
+ 'Domain code must not create raw HTTP servers (express, fastify, etc.). All HTTP handling goes through defineAction with exposure.type=\'api\' which is wired to API Gateway by the scaffold.',
62
62
  severity: 'error',
63
63
  category: 'structure',
64
64
  kNodeRef: 'K:convention:tier-1-foundations',
65
- fixHint: 'Wrap your HTTP handler with defineApi({ ... }). The scaffold handles API Gateway wiring.',
65
+ fixHint: 'Wrap your HTTP handler with defineAction({ exposure: { type: \'api\', ... } }). The scaffold handles API Gateway wiring.',
66
66
  exampleBad: "import express from 'express';\nconst app = express();",
67
- exampleGood: "export const myApi = defineApi({ id: 'my-api', path: '/v1/my-api', ... });",
67
+ exampleGood: "export const myAction = defineAction({ id: 'my-action', exposure: { type: 'api', path: '/v1/my-action', method: 'GET', auth: 'required', tenancy: 'required' }, ... });",
68
68
  },
69
69
  'require-define-primitive': {
70
70
  ruleId: 'require-define-primitive',
71
71
  title: 'Require define primitive factories',
72
72
  description:
73
- 'All domain handlers must use the appropriate factory function: defineApi for HTTP APIs, defineSubscriber for event subscribers, defineAction for cross-domain actions, defineJob for background jobs.',
73
+ 'All domain handlers must use the appropriate factory function: defineAction (HTTP APIs via exposure.type=\'api\', or internal-only actions), defineSubscriber for event subscribers, defineJob for background jobs, defineWebhook for webhooks, defineEvent for event types.',
74
74
  severity: 'error',
75
75
  category: 'structure',
76
76
  kNodeRef: 'K:runbook:add-domain',
77
77
  fixHint: "Wrap your handler with the appropriate define* factory from @mettlecast/domain-runtime.",
78
78
  exampleBad: "export const handler = async (event) => ({ statusCode: 200 });",
79
- exampleGood: "export const myApi = defineApi({ id: 'my-api', ... });",
79
+ exampleGood: "export const myAction = defineAction({ id: 'my-action', exposure: { type: 'api', ... }, ... });",
80
80
  },
81
81
  'flow-domain-ownership': {
82
82
  ruleId: 'flow-domain-ownership',
@@ -106,25 +106,25 @@ const RULE_EXPLANATIONS: Record<string, RuleExplanation> = {
106
106
  ruleId: 'apis-have-versions',
107
107
  title: 'APIs declare versions',
108
108
  description:
109
- 'Every defineApi must have a versions field with at least one version entry. Versioning is mandatory for API evolution.',
109
+ 'Every API-exposed action (defineAction with exposure.type=\'api\') must declare input and output Zod schemas with .default() so the runtime has a concrete example payload. The action-first contract replaced legacy defineApi\'s versions map (#4689).',
110
110
  severity: 'error',
111
111
  category: 'correctness',
112
112
  kNodeRef: 'K:runbook:add-domain',
113
- fixHint: 'Add versions: { v1: { status, input, output, handler } } to your defineApi call.',
114
- exampleBad: "defineApi({ id: 'my-api', path: '/v1/my-api' }) // missing versions",
115
- exampleGood: "defineApi({ id: 'my-api', path: '/v1/my-api', versions: { v1: { ... } } });",
113
+ fixHint: 'Add Zod input/output schemas to your defineAction call (e.g. z.object({...}).default({...})).',
114
+ exampleBad: "defineAction({ id: 'my-action', exposure: { type: 'api', path: '/v1/my-action', ... }, handler: ... }) // missing input/output schemas",
115
+ exampleGood: "defineAction({ id: 'my-action', exposure: { type: 'api', ... }, input: z.object({...}).default({...}), output: z.object({...}).default({...}), handler: ... });",
116
116
  },
117
117
  'apis-have-tenancy': {
118
118
  ruleId: 'apis-have-tenancy',
119
119
  title: 'APIs declare tenancy',
120
120
  description:
121
- 'Every defineApi must declare its tenancy mode: required (tenant-scoped), none (tenant-agnostic like registration), or system (system-internal admin).',
121
+ 'Every defineAction with exposure.type=\'api\' must declare exposure.tenancy: required (tenant-scoped), none (tenant-agnostic like registration), or system (system-internal admin). The action-first contract enforces this at registration time (#4689).',
122
122
  severity: 'error',
123
123
  category: 'correctness',
124
124
  kNodeRef: 'K:convention:tier-1-foundations',
125
- fixHint: "Add tenancy: 'required' | 'none' | 'system' to your defineApi call.",
126
- exampleBad: "defineApi({ id: 'my-api', path: '/v1/my-api' }) // missing tenancy",
127
- exampleGood: "defineApi({ id: 'my-api', path: '/v1/my-api', tenancy: 'required' });",
125
+ fixHint: "Add `exposure.tenancy: 'required' | 'none' | 'system'` to your defineAction call.",
126
+ exampleBad: "defineAction({ id: 'my-action', exposure: { type: 'api', path: '/v1/my-action' }, ... }) // missing exposure.tenancy",
127
+ exampleGood: "defineAction({ id: 'my-action', exposure: { type: 'api', path: '/v1/my-action', method: 'GET', auth: 'required', tenancy: 'required' }, ... });",
128
128
  },
129
129
  'no-cross-domain-imports': {
130
130
  ruleId: 'no-cross-domain-imports',
@@ -1,7 +1,12 @@
1
1
  /**
2
2
  * generate-openapi — read a domain's registry and produce an OpenAPI 3.1
3
3
  * specification as JSON. The registry is consumed at build time; each
4
- * `defineApi` entry contributes one path under the domain's route prefix.
4
+ * `defineAction({ exposure: { type: 'api', ... } })` entry contributes
5
+ * one path under the domain's route prefix.
6
+ *
7
+ * Issue #4689: the legacy `defineApi` factory was removed and the
8
+ * `registry.apis` slot is now always empty in new registries. This
9
+ * command therefore reads exclusively from `actions[]`.
5
10
  *
6
11
  * Usage: npx mc-domain-module generate-openapi <domain>
7
12
  */
@@ -17,51 +22,24 @@ export interface GenerateOpenapiOptions {
17
22
  output?: string;
18
23
  }
19
24
 
20
- interface ApiRegistryEntry {
21
- id: string;
22
- path: string;
23
- method: string;
24
- tenancy: string;
25
- versions: Record<string, {
26
- status: string;
27
- inputSchema?: unknown;
28
- outputSchema?: unknown;
29
- input?: unknown;
30
- output?: unknown;
31
- }>;
32
- examples?: { request?: unknown; response?: unknown };
33
- }
34
-
35
25
  interface DomainRegistry {
36
26
  domain: { id: string };
37
- apis: ApiRegistryEntry[];
27
+ actions: Array<{
28
+ id: string;
29
+ exposure?: {
30
+ type: 'api' | 'internal';
31
+ path?: string;
32
+ method?: string;
33
+ };
34
+ inputSchema?: Record<string, unknown>;
35
+ outputSchema?: Record<string, unknown>;
36
+ }>;
38
37
  }
39
38
 
40
39
  /**
41
- * Extract a Zod schema shape from a registry entry version.
42
- * The registry may store schemas as `input`/`output` (raw Zod
43
- * objects) or `inputSchema`/`outputSchema` (JSON Schema shapes).
44
- * Returns a best-effort JSON Schema object for the OpenAPI spec.
40
+ * Run the generate-openapi command: read the domain's registry and emit
41
+ * an OpenAPI 3.1 spec covering every API-exposed action.
45
42
  */
46
- function extractSchema(version: ApiRegistryEntry['versions'][string], field: 'input' | 'output'): unknown {
47
- // Prefer JSON Schema if present
48
- const schemaField = field === 'input' ? version.inputSchema : version.outputSchema;
49
- if (schemaField && typeof schemaField === 'object' && schemaField !== null) {
50
- return schemaField;
51
- }
52
- // Fall back to the raw Zod shape — try to produce a minimal JSON
53
- // Schema from the Zod _def. For full support, add zod-to-json-schema
54
- // to the CLI dependencies and call `zodToJsonSchema(zodSchema)`.
55
- const raw = field === 'input' ? version.input : version.output;
56
- if (raw && typeof raw === 'object' && raw !== null) {
57
- // Attempt minimal mapping: if the Zod shape has a `type` field
58
- // from its _def, describe it as JSON Schema.
59
- const def = raw as { type?: string; items?: unknown; properties?: unknown; required?: string[] };
60
- return { type: def.type ?? 'object', properties: def.properties, required: def.required };
61
- }
62
- return { type: 'object' };
63
- }
64
-
65
43
  export async function runGenerateOpenapi(options: GenerateOpenapiOptions): Promise<string> {
66
44
  const projectRoot = options.projectRoot ?? process.cwd();
67
45
  const domain = options.domain;
@@ -74,22 +52,22 @@ export async function runGenerateOpenapi(options: GenerateOpenapiOptions): Promi
74
52
 
75
53
  const paths: Record<string, unknown> = {};
76
54
 
77
- for (const api of registry.apis) {
78
- const method = (api.method ?? 'get').toLowerCase();
79
- const fullPath = `/v1/${domain}${api.path}`;
80
- const v1 = api.versions['v1'] ?? api.versions[Object.keys(api.versions)[0]];
81
- if (!v1) continue;
55
+ for (const action of registry.actions ?? []) {
56
+ if (action.exposure?.type !== 'api') continue;
57
+ const method = (action.exposure.method ?? 'get').toLowerCase();
58
+ const fullPath = `/v1/${domain}${action.exposure.path}`;
59
+ const inputSchema = action.inputSchema ?? { type: 'object' };
60
+ const outputSchema = action.outputSchema ?? { type: 'object' };
82
61
 
83
62
  if (!paths[fullPath]) paths[fullPath] = {};
84
-
85
63
  (paths[fullPath] as Record<string, unknown>)[method] = {
86
- operationId: `${domain}.${api.id}`,
87
- summary: `${domain}.${api.id}`,
88
- description: `Version: ${v1.status}`,
64
+ operationId: `${domain}.${action.id}`,
65
+ summary: `${domain}.${action.id}`,
66
+ description: 'Action-first contract (defineAction + exposure.type=api).',
89
67
  requestBody: {
90
68
  content: {
91
69
  'application/json': {
92
- schema: extractSchema(v1, 'input'),
70
+ schema: inputSchema,
93
71
  },
94
72
  },
95
73
  },
@@ -98,7 +76,7 @@ export async function runGenerateOpenapi(options: GenerateOpenapiOptions): Promi
98
76
  description: 'OK',
99
77
  content: {
100
78
  'application/json': {
101
- schema: extractSchema(v1, 'output'),
79
+ schema: outputSchema,
102
80
  },
103
81
  },
104
82
  },
@@ -151,4 +129,4 @@ export async function runGenerateOpenapiCli(domain: string, opts: { projectRoot?
151
129
  const outputPath = await runGenerateOpenapi({ domain, ...opts });
152
130
  // eslint-disable-next-line no-console
153
131
  console.log(`OpenAPI spec written to: ${outputPath}`);
154
- }
132
+ }
@@ -21,10 +21,10 @@ export interface ShowDomainOptions {
21
21
  const KEBAB_REGEX = /^[a-z][a-z0-9-]*$/;
22
22
 
23
23
  /**
24
- * Represents a primitive entry (API, subscriber, action, or job).
24
+ * Represents a primitive entry (action, subscriber, or job).
25
25
  */
26
26
  interface PrimitiveEntry {
27
- type: 'api' | 'subscriber' | 'action' | 'job';
27
+ type: 'subscriber' | 'action' | 'job';
28
28
  id: string;
29
29
  file: string;
30
30
  }
@@ -35,7 +35,6 @@ interface PrimitiveEntry {
35
35
  interface DomainSummary {
36
36
  domain: string;
37
37
  primitives: {
38
- apis: PrimitiveEntry[];
39
38
  subscribers: PrimitiveEntry[];
40
39
  actions: PrimitiveEntry[];
41
40
  jobs: PrimitiveEntry[];
@@ -114,7 +113,6 @@ export async function runShowDomain(opts: ShowDomainOptions): Promise<DomainSumm
114
113
  }
115
114
 
116
115
  // Scan primitives
117
- const apis = await scanPrimitives(join(domainDir, 'api'), 'api');
118
116
  const subscribers = await scanPrimitives(join(domainDir, 'subscribers'), 'subscriber');
119
117
  const actions = await scanPrimitives(join(domainDir, 'actions'), 'action');
120
118
  const jobs = await scanPrimitives(join(domainDir, 'jobs'), 'job');
@@ -155,7 +153,6 @@ export async function runShowDomain(opts: ShowDomainOptions): Promise<DomainSumm
155
153
  const summary: DomainSummary = {
156
154
  domain: opts.domain,
157
155
  primitives: {
158
- apis,
159
156
  subscribers,
160
157
  actions,
161
158
  jobs,