@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.
- package/dist/builder/build-registry.d.ts +1 -1
- package/dist/builder/build-registry.js +1 -36
- package/dist/builder/build-types.d.ts +1 -1
- package/dist/builder/load-module.d.ts +1 -1
- package/dist/cli.js +1 -1
- package/dist/commands/add-api.js +2 -2
- package/dist/commands/add-domain.js +4 -4
- package/dist/commands/add-fixture-factory.js +5 -6
- package/dist/commands/build-catalog.d.ts +6 -22
- package/dist/commands/build-catalog.js +7 -18
- package/dist/commands/build.js +2 -1
- package/dist/commands/dev.js +1 -1
- package/dist/commands/doctor.js +66 -37
- package/dist/commands/explain.js +13 -13
- package/dist/commands/generate-openapi.d.ts +10 -1
- package/dist/commands/generate-openapi.js +19 -33
- package/dist/commands/show.d.ts +2 -3
- package/dist/commands/show.js +0 -2
- package/dist/commands/test.js +0 -1
- package/dist/commands/upgrade-backend.js +3 -3
- package/dist/commands/validate.js +12 -90
- package/dist/server/api-server.d.ts +1 -1
- package/dist/server/mount-routes.d.ts +11 -2
- package/dist/server/mount-routes.js +20 -8
- package/dist/templates/api-skeleton.d.ts +5 -0
- package/dist/templates/api-skeleton.js +28 -27
- package/dist/templates/claude-md.js +1 -1
- package/dist/templates/patterns/api/create-with-event.d.ts +4 -0
- package/dist/templates/patterns/api/create-with-event.js +38 -32
- package/dist/templates/patterns/api/idempotent-mutation.d.ts +4 -0
- package/dist/templates/patterns/api/idempotent-mutation.js +47 -41
- package/dist/templates/patterns/api/paginated-list.d.ts +4 -0
- package/dist/templates/patterns/api/paginated-list.js +30 -24
- package/dist/templates/patterns/api/simple-crud.d.ts +4 -0
- package/dist/templates/patterns/api/simple-crud.js +46 -35
- package/dist/templates/patterns/api/streaming-list.d.ts +4 -0
- package/dist/templates/patterns/api/streaming-list.js +46 -41
- package/dist/templates/patterns/api/system-admin.d.ts +4 -0
- package/dist/templates/patterns/api/system-admin.js +59 -52
- package/dist/templates/patterns/api/webhook-receiver-style.d.ts +4 -0
- package/dist/templates/patterns/api/webhook-receiver-style.js +43 -35
- package/dist/types.d.ts +100 -0
- package/dist/types.js +1 -0
- package/dist/utils/file-helpers.d.ts +0 -2
- package/dist/utils/file-helpers.js +2 -3
- package/dist/utils/scaffold-config.d.ts +6 -0
- package/dist/utils/scaffold-config.js +2 -0
- package/package.json +1 -1
- package/src/__tests__/build-registry.test.ts +43 -20
- package/src/__tests__/build-types.test.ts +4 -7
- package/src/__tests__/builder/walkDomainDir.test.ts +19 -21
- package/src/__tests__/commands/add-api.test.ts +12 -10
- package/src/__tests__/commands/add-domain.test.ts +8 -5
- package/src/__tests__/commands/create-project.test.ts +5 -5
- package/src/__tests__/commands/dev.test.ts +0 -1
- package/src/__tests__/mount-routes.test.ts +64 -23
- package/src/__tests__/package-freshness.test.ts +1 -21
- package/src/__tests__/smoke/scaffold.test.ts +13 -15
- package/src/__tests__/validate.test.ts +21 -103
- package/src/builder/build-registry.ts +7 -44
- package/src/builder/build-types.ts +1 -1
- package/src/cli.ts +1 -1
- package/src/commands/add-api.ts +2 -2
- package/src/commands/add-domain.ts +4 -4
- package/src/commands/add-fixture-factory.ts +5 -6
- package/src/commands/build-catalog.ts +13 -35
- package/src/commands/build.ts +3 -2
- package/src/commands/dev.ts +1 -1
- package/src/commands/doctor.ts +68 -37
- package/src/commands/explain.ts +13 -13
- package/src/commands/generate-openapi.ts +30 -52
- package/src/commands/show.ts +2 -5
- package/src/commands/test.ts +0 -1
- package/src/commands/upgrade-backend.ts +3 -3
- package/src/commands/validate.ts +11 -96
- package/src/server/api-server.ts +1 -1
- package/src/server/mount-routes.ts +21 -10
- package/src/templates/api-skeleton.ts +29 -28
- package/src/templates/claude-md.ts +1 -1
- package/src/templates/patterns/api/create-with-event.ts +39 -33
- package/src/templates/patterns/api/idempotent-mutation.ts +48 -42
- package/src/templates/patterns/api/paginated-list.ts +31 -25
- package/src/templates/patterns/api/simple-crud.ts +47 -36
- package/src/templates/patterns/api/streaming-list.ts +47 -42
- package/src/templates/patterns/api/system-admin.ts +60 -53
- package/src/templates/patterns/api/webhook-receiver-style.ts +48 -40
- package/src/types.ts +128 -0
- package/src/utils/file-helpers.ts +2 -5
- 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
|
|
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 });
|
package/src/commands/add-api.ts
CHANGED
|
@@ -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, '
|
|
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, '
|
|
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, '
|
|
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, '
|
|
61
|
+
join(domainDir, 'actions', 'example.ts'),
|
|
62
62
|
apiSkeletonTemplate(opts.id, 'example', opts.tenancy)
|
|
63
63
|
);
|
|
64
64
|
await writeFile(
|
|
65
|
-
join(domainDir, '
|
|
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
|
|
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, '
|
|
50
|
+
const apiFile = join(projectRoot, 'domains', domain, 'actions', `${apiId}.ts`);
|
|
51
51
|
await access(apiFile).catch(() => {
|
|
52
|
-
throw new Error(`
|
|
52
|
+
throw new Error(`Action file not found: ${apiFile}. Run add-api first.`);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
// Read the
|
|
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, '
|
|
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, '
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
package/src/commands/build.ts
CHANGED
|
@@ -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 '
|
|
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
|
-
|
|
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, '..');
|
package/src/commands/dev.ts
CHANGED
|
@@ -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,
|
|
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
|
|
package/src/commands/doctor.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
453
|
+
const allActionFiles: string[] = [];
|
|
452
454
|
|
|
453
|
-
// Find
|
|
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
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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
|
|
482
|
+
message: 'No API-exposed actions found (optional)',
|
|
468
483
|
kNodeRef: 'K:runbook:add-domain',
|
|
469
484
|
};
|
|
470
485
|
}
|
|
471
486
|
|
|
472
|
-
//
|
|
473
|
-
|
|
474
|
-
|
|
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('
|
|
477
|
-
|
|
493
|
+
if (!content.includes('.default(')) {
|
|
494
|
+
missingDefaults++;
|
|
478
495
|
}
|
|
479
496
|
}
|
|
480
497
|
|
|
481
|
-
if (
|
|
498
|
+
if (missingDefaults === 0) {
|
|
482
499
|
return {
|
|
483
500
|
name: 'APIs declare versions',
|
|
484
501
|
status: 'PASS',
|
|
485
|
-
message: `All ${
|
|
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: `${
|
|
493
|
-
fixHint:
|
|
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
|
|
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
|
|
527
|
+
const allActionFiles: string[] = [];
|
|
511
528
|
|
|
512
|
-
// Find
|
|
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
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
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
|
|
555
|
+
message: 'No API-exposed actions found (optional)',
|
|
527
556
|
kNodeRef: 'K:convention:tier-1-foundations',
|
|
528
557
|
};
|
|
529
558
|
}
|
|
530
559
|
|
|
531
|
-
//
|
|
560
|
+
// Check for `tenancy:` anywhere in each API-exposed action file.
|
|
532
561
|
let missingTenancy = 0;
|
|
533
|
-
for (const file of
|
|
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 ${
|
|
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}/${
|
|
552
|
-
fixHint: "Add tenancy: 'required' | 'none' | 'system' to
|
|
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
|
-
|
|
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
|
|
894
|
-
|
|
895
|
-
|
|
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) {
|
package/src/commands/explain.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
114
|
-
exampleBad: "
|
|
115
|
-
exampleGood: "
|
|
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
|
|
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
|
|
126
|
-
exampleBad: "
|
|
127
|
-
exampleGood: "
|
|
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
|
-
* `
|
|
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
|
-
|
|
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
|
-
*
|
|
42
|
-
*
|
|
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
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
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}.${
|
|
87
|
-
summary: `${domain}.${
|
|
88
|
-
description:
|
|
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:
|
|
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:
|
|
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
|
+
}
|
package/src/commands/show.ts
CHANGED
|
@@ -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 (
|
|
24
|
+
* Represents a primitive entry (action, subscriber, or job).
|
|
25
25
|
*/
|
|
26
26
|
interface PrimitiveEntry {
|
|
27
|
-
type: '
|
|
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,
|