@mettlecast/eslint-plugin-domain-module 0.2.60 → 0.2.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/__tests__/api-needs-fixture.test.js +11 -7
- package/dist/__tests__/no-raw-fetch.test.js +6 -6
- package/dist/__tests__/no-raw-http-server.test.js +1 -1
- package/dist/__tests__/no-raw-pg-client.test.js +1 -1
- package/dist/__tests__/prefer-result-over-throw.test.js +11 -11
- package/dist/__tests__/require-define-primitive.test.js +1 -1
- package/dist/rules/api-needs-fixture.js +34 -22
- package/dist/rules/no-raw-http-server.js +1 -1
- package/dist/rules/require-define-primitive.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -34,8 +34,8 @@ export default [
|
|
|
34
34
|
| `no-cross-domain-internal-import` | Importing internal modules (not exported types) from another domain directory | error |
|
|
35
35
|
| `flow-domain-ownership` | Flow files in `domains/{x}/flows/` must declare `owningDomain: 'x'` | error |
|
|
36
36
|
| `zod-defaults-required` | Top-level Zod input/output schemas must end in `.default(...)` | error |
|
|
37
|
-
| `api-needs-fixture` | Every `
|
|
38
|
-
| `prefer-result-over-throw` | Domain
|
|
37
|
+
| `api-needs-fixture` | Every API-exposed `defineAction` needs a sibling `__tests__/<id>.fixture.json` | error |
|
|
38
|
+
| `prefer-result-over-throw` | Domain handlers must `return err({...})` instead of throwing | error |
|
|
39
39
|
| `use-tanstack-router` | Frontend must use TanStack Router | error |
|
|
40
40
|
| `tanstack-query-options` | TanStack Query data must be wrapped in `queryOptions(...)` | warn |
|
|
41
41
|
|
|
@@ -114,4 +114,4 @@ await ctx.db.release(); // only if you really need to release early
|
|
|
114
114
|
## See Also
|
|
115
115
|
|
|
116
116
|
- `@mettlecast/domain-runtime` — handler definitions and `DomainContext`
|
|
117
|
-
- `@mettlecast/domain-cli` — validation and linting
|
|
117
|
+
- `@mettlecast/domain-cli` — validation and linting
|
|
@@ -14,10 +14,10 @@ RuleTester.describe = describe;
|
|
|
14
14
|
RuleTester.it = it;
|
|
15
15
|
const existsSyncMock = vi.mocked(existsSync);
|
|
16
16
|
const FIXTURE_DIRS_WITH_FIXTURE = new Set([
|
|
17
|
-
'/repo/domains/auth/
|
|
17
|
+
'/repo/domains/auth/actions/__tests__/get-user.fixture.json',
|
|
18
18
|
]);
|
|
19
19
|
const FIXTURE_DIRS_WITHOUT_FIXTURE = new Set([
|
|
20
|
-
'/repo/domains/payments/
|
|
20
|
+
'/repo/domains/payments/actions/__tests__/charge-card.fixture.json',
|
|
21
21
|
]);
|
|
22
22
|
beforeEach(() => {
|
|
23
23
|
existsSyncMock.mockReset();
|
|
@@ -39,22 +39,26 @@ const tester = new RuleTester({
|
|
|
39
39
|
tester.run('api-needs-fixture', apiNeedsFixture, {
|
|
40
40
|
valid: [
|
|
41
41
|
{
|
|
42
|
-
filename: '/repo/domains/auth/
|
|
43
|
-
code: "import {
|
|
42
|
+
filename: '/repo/domains/auth/actions/get-user.ts',
|
|
43
|
+
code: "import { defineAction } from '@mettlecast/domain-runtime'; export const getUser = defineAction({ id: 'get-user', backendAccess: 'domain', exposure: { type: 'api', path: '/v1/users/{id}', method: 'GET', auth: 'required', tenancy: 'required' }, idempotent: true, input: z.object({}), output: z.object({}), handler: async () => ({}) });",
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
filename: '/repo/domains/auth/domain.config.ts',
|
|
47
47
|
code: "export const config = { id: 'auth', name: 'Auth' };",
|
|
48
48
|
},
|
|
49
49
|
{
|
|
50
|
-
filename: '/repo/domains/auth/
|
|
50
|
+
filename: '/repo/domains/auth/actions/utils.ts',
|
|
51
51
|
code: "export function helper() { return 1; }",
|
|
52
52
|
},
|
|
53
|
+
{
|
|
54
|
+
filename: '/repo/domains/auth/actions/internal-helper.ts',
|
|
55
|
+
code: "import { defineAction } from '@mettlecast/domain-runtime'; export const helper = defineAction({ id: 'helper', backendAccess: 'private', exposure: { type: 'internal' }, idempotent: true, input: z.object({}), output: z.object({}), handler: async () => ({}) });",
|
|
56
|
+
},
|
|
53
57
|
],
|
|
54
58
|
invalid: [
|
|
55
59
|
{
|
|
56
|
-
filename: '/repo/domains/payments/
|
|
57
|
-
code: "import {
|
|
60
|
+
filename: '/repo/domains/payments/actions/charge-card.ts',
|
|
61
|
+
code: "import { defineAction } from '@mettlecast/domain-runtime'; export const chargeCard = defineAction({ id: 'charge-card', backendAccess: 'domain', exposure: { type: 'api', path: '/v1/charge', method: 'POST', auth: 'required', tenancy: 'required' }, idempotent: false, input: z.object({}), output: z.object({}), handler: async () => ({}) });",
|
|
58
62
|
errors: [{ messageId: 'apiNeedsFixture' }],
|
|
59
63
|
},
|
|
60
64
|
],
|
|
@@ -10,26 +10,26 @@ const tester = new RuleTester({
|
|
|
10
10
|
tester.run('no-raw-fetch', noRawFetch, {
|
|
11
11
|
valid: [
|
|
12
12
|
{
|
|
13
|
-
filename: '/repo/domains/auth/
|
|
14
|
-
code: "export const get =
|
|
13
|
+
filename: '/repo/domains/auth/actions/get-user.ts',
|
|
14
|
+
code: "export const get = defineAction({ handler: async (input, ctx) => { return await ctx.fetch('https://example.com'); } });",
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
|
-
filename: '/repo/domains/auth/
|
|
17
|
+
filename: '/repo/domains/auth/actions/get-user.ts',
|
|
18
18
|
code: "import ky from 'ky'; const r = await ky.get('https://example.com');",
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
|
-
filename: '/repo/domains/auth/
|
|
21
|
+
filename: '/repo/domains/auth/actions/get-user.ts',
|
|
22
22
|
code: "const r = await globalThis.fetch('https://example.com');",
|
|
23
23
|
},
|
|
24
24
|
],
|
|
25
25
|
invalid: [
|
|
26
26
|
{
|
|
27
|
-
filename: '/repo/domains/auth/
|
|
27
|
+
filename: '/repo/domains/auth/actions/get-user.ts',
|
|
28
28
|
code: "const r = await fetch('https://example.com');",
|
|
29
29
|
errors: [{ messageId: 'noRawFetch' }],
|
|
30
30
|
},
|
|
31
31
|
{
|
|
32
|
-
filename: '/repo/domains/payments/
|
|
32
|
+
filename: '/repo/domains/payments/actions/charge.ts',
|
|
33
33
|
code: "fetch('https://api.stripe.com/v1/charges', { method: 'POST' });",
|
|
34
34
|
errors: [{ messageId: 'noRawFetch' }],
|
|
35
35
|
},
|
|
@@ -9,7 +9,7 @@ const tester = new RuleTester({
|
|
|
9
9
|
});
|
|
10
10
|
tester.run('no-raw-http-server', noRawHttpServer, {
|
|
11
11
|
valid: [
|
|
12
|
-
{ code: "import {
|
|
12
|
+
{ code: "import { defineAction } from '@mettlecast/domain-runtime';" },
|
|
13
13
|
{ code: "import path from 'node:path';" },
|
|
14
14
|
],
|
|
15
15
|
invalid: [
|
|
@@ -10,7 +10,7 @@ const tester = new RuleTester({
|
|
|
10
10
|
tester.run('no-raw-pg-client', noRawPgClient, {
|
|
11
11
|
valid: [
|
|
12
12
|
// Domain code that uses ctx.db is fine.
|
|
13
|
-
{ code: "import {
|
|
13
|
+
{ code: "import { defineAction } from '@mettlecast/domain-runtime';" },
|
|
14
14
|
{ code: "import { drizzle } from 'drizzle-orm/node-postgres';" },
|
|
15
15
|
// Other packages that aren't in the ban-list stay allowed.
|
|
16
16
|
{ code: "import { something } from 'pg-query-emscripten';" },
|
|
@@ -10,17 +10,17 @@ const ruleTester = new RuleTester({
|
|
|
10
10
|
ruleTester.run('prefer-result-over-throw', preferResultOverThrow, {
|
|
11
11
|
valid: [
|
|
12
12
|
{
|
|
13
|
-
filename: 'domains/auth/
|
|
13
|
+
filename: 'domains/auth/actions/me.ts',
|
|
14
14
|
code: `
|
|
15
15
|
import { ok, err } from '@mettlecast/domain-runtime';
|
|
16
|
-
export const me =
|
|
16
|
+
export const me = defineAction({
|
|
17
17
|
handler: async (_input, _ctx) => {
|
|
18
18
|
return ok({ id: 'usr_1' });
|
|
19
19
|
},
|
|
20
20
|
});`,
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
|
-
filename: 'domains/auth/
|
|
23
|
+
filename: 'domains/auth/actions/me.ts',
|
|
24
24
|
code: `
|
|
25
25
|
import { redirect } from '@tanstack/react-router';
|
|
26
26
|
// TSR redirect is not a domain error — it's a navigation directive
|
|
@@ -31,10 +31,10 @@ throw redirect({ to: '/dashboard' });`,
|
|
|
31
31
|
code: `throw new Error('Upgrade failed');`,
|
|
32
32
|
},
|
|
33
33
|
{
|
|
34
|
-
filename: 'domains/auth/
|
|
34
|
+
filename: 'domains/auth/actions/me.ts',
|
|
35
35
|
code: `
|
|
36
36
|
import { ok, err, notFound } from '@mettlecast/domain-runtime';
|
|
37
|
-
export const me =
|
|
37
|
+
export const me = defineAction({
|
|
38
38
|
handler: async (_input, _ctx) => {
|
|
39
39
|
const user = await ctx.db.query(...);
|
|
40
40
|
if (!user) return notFound('user', input.userId);
|
|
@@ -45,9 +45,9 @@ export const me = defineApi({
|
|
|
45
45
|
],
|
|
46
46
|
invalid: [
|
|
47
47
|
{
|
|
48
|
-
filename: 'domains/auth/
|
|
48
|
+
filename: 'domains/auth/actions/me.ts',
|
|
49
49
|
code: `
|
|
50
|
-
export const me =
|
|
50
|
+
export const me = defineAction({
|
|
51
51
|
handler: async () => {
|
|
52
52
|
throw new Error('User not found');
|
|
53
53
|
},
|
|
@@ -55,9 +55,9 @@ export const me = defineApi({
|
|
|
55
55
|
errors: [{ messageId: 'preferResult' }],
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
|
-
filename: 'domains/orgs/
|
|
58
|
+
filename: 'domains/orgs/actions/list-orgs.ts',
|
|
59
59
|
code: `
|
|
60
|
-
export const listOrgs =
|
|
60
|
+
export const listOrgs = defineAction({
|
|
61
61
|
handler: async (_input, _ctx) => {
|
|
62
62
|
if (!ctx.tenant) throw new Error('Missing tenant');
|
|
63
63
|
return { items: [] };
|
|
@@ -66,9 +66,9 @@ export const listOrgs = defineApi({
|
|
|
66
66
|
errors: [{ messageId: 'preferResult' }],
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
|
-
filename: 'domains/tenants/
|
|
69
|
+
filename: 'domains/tenants/actions/create-tenant.ts',
|
|
70
70
|
code: `
|
|
71
|
-
export const createTenant =
|
|
71
|
+
export const createTenant = defineAction({
|
|
72
72
|
handler: async () => {
|
|
73
73
|
throw { kind: 'not_found' }; // raw object throw — no stack trace
|
|
74
74
|
},
|
|
@@ -9,7 +9,7 @@ const tester = new RuleTester({
|
|
|
9
9
|
});
|
|
10
10
|
tester.run('require-define-primitive', requireDefinePrimitive, {
|
|
11
11
|
valid: [
|
|
12
|
-
{ code: "export const
|
|
12
|
+
{ code: "export const myAction = defineAction({ id: 'test', exposure: { type: 'internal' } });" },
|
|
13
13
|
{ code: "export const sub = defineSubscriber({ id: 'x', event: 'y', semverRange: '^1.0' });" },
|
|
14
14
|
{ code: "const domain = defineDomain({ id: 'my-domain', name: 'My Domain', tenancy: 'single' });" },
|
|
15
15
|
],
|
|
@@ -2,18 +2,9 @@ import { ESLintUtils } from '@typescript-eslint/utils';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/LawrenceGeeHouse/Mettlecast/blob/develop/packages/eslint-plugin-domain-module/docs/${name}.md`);
|
|
5
|
-
const
|
|
6
|
-
function
|
|
7
|
-
const
|
|
8
|
-
if (!arg || arg.type !== 'ObjectExpression')
|
|
9
|
-
return undefined;
|
|
10
|
-
const idProp = arg.properties.find((p) => p.type === 'Property' &&
|
|
11
|
-
'name' in p.key &&
|
|
12
|
-
!p.computed &&
|
|
13
|
-
p.key.name === 'id');
|
|
14
|
-
if (!idProp)
|
|
15
|
-
return undefined;
|
|
16
|
-
const val = idProp.value;
|
|
5
|
+
const DOMAIN_ACTION_FILE_PATTERN = /[/\\]domains[/\\][^/\\]+[/\\]actions[/\\][^/\\]+\.ts$/;
|
|
6
|
+
function stringPropertyValue(prop) {
|
|
7
|
+
const val = prop.value;
|
|
17
8
|
if (val.type === 'Literal' && typeof val.value === 'string')
|
|
18
9
|
return val.value;
|
|
19
10
|
if (val.type === 'TemplateLiteral' && val.expressions.length === 0 && val.quasis.length === 1) {
|
|
@@ -21,29 +12,50 @@ function extractFixtureIdFromDefineApi(node) {
|
|
|
21
12
|
}
|
|
22
13
|
return undefined;
|
|
23
14
|
}
|
|
24
|
-
function
|
|
15
|
+
function findProperty(object, name) {
|
|
16
|
+
return object.properties.find((p) => p.type === 'Property' &&
|
|
17
|
+
'name' in p.key &&
|
|
18
|
+
!p.computed &&
|
|
19
|
+
p.key.name === name);
|
|
20
|
+
}
|
|
21
|
+
function extractFixtureIdFromApiAction(node) {
|
|
22
|
+
const arg = node.arguments[0];
|
|
23
|
+
if (!arg || arg.type !== 'ObjectExpression')
|
|
24
|
+
return undefined;
|
|
25
|
+
const exposureProp = findProperty(arg, 'exposure');
|
|
26
|
+
if (!exposureProp || exposureProp.value.type !== 'ObjectExpression')
|
|
27
|
+
return undefined;
|
|
28
|
+
const typeProp = findProperty(exposureProp.value, 'type');
|
|
29
|
+
if (!typeProp || stringPropertyValue(typeProp) !== 'api')
|
|
30
|
+
return undefined;
|
|
31
|
+
const idProp = findProperty(arg, 'id');
|
|
32
|
+
if (!idProp)
|
|
33
|
+
return undefined;
|
|
34
|
+
return stringPropertyValue(idProp);
|
|
35
|
+
}
|
|
36
|
+
function isDefineActionCall(node) {
|
|
25
37
|
if (!node)
|
|
26
38
|
return false;
|
|
27
|
-
return node.callee.type === 'Identifier' && node.callee.name === '
|
|
39
|
+
return node.callee.type === 'Identifier' && node.callee.name === 'defineAction';
|
|
28
40
|
}
|
|
29
41
|
export const apiNeedsFixture = createRule({
|
|
30
42
|
name: 'api-needs-fixture',
|
|
31
43
|
meta: {
|
|
32
44
|
type: 'problem',
|
|
33
45
|
docs: {
|
|
34
|
-
description: 'Every exported
|
|
46
|
+
description: 'Every exported API-exposed defineAction({...}) in domains/{x}/actions/*.ts must have a matching __tests__/{id}.fixture.json sibling file.',
|
|
35
47
|
},
|
|
36
48
|
messages: {
|
|
37
|
-
apiNeedsFixture: 'API
|
|
49
|
+
apiNeedsFixture: 'API-exposed action "{{name}}" is missing a __tests__/{{name}}.fixture.json sibling file.\n fixHint: Create `domains/<domain>/actions/__tests__/{{name}}.fixture.json` with realistic input/output examples. This file is consumed by `mc-domain-module test` to drive the handler in a local fixture run.\n kNodeRef: K:runbook:fixture-driven-tests',
|
|
38
50
|
},
|
|
39
51
|
schema: [],
|
|
40
52
|
},
|
|
41
53
|
defaultOptions: [],
|
|
42
54
|
create(context) {
|
|
43
55
|
const filename = context.filename ?? context.getFilename?.() ?? '';
|
|
44
|
-
if (!filename || !
|
|
56
|
+
if (!filename || !DOMAIN_ACTION_FILE_PATTERN.test(filename))
|
|
45
57
|
return {};
|
|
46
|
-
const
|
|
58
|
+
const actionDir = dirname(filename);
|
|
47
59
|
return {
|
|
48
60
|
ExportNamedDeclaration(node) {
|
|
49
61
|
const decl = node.declaration;
|
|
@@ -52,7 +64,7 @@ export const apiNeedsFixture = createRule({
|
|
|
52
64
|
let callNode;
|
|
53
65
|
if (decl.type === 'VariableDeclaration') {
|
|
54
66
|
for (const d of decl.declarations) {
|
|
55
|
-
if (d.init && d.init.type === 'CallExpression' &&
|
|
67
|
+
if (d.init && d.init.type === 'CallExpression' && isDefineActionCall(d.init)) {
|
|
56
68
|
callNode = d.init;
|
|
57
69
|
break;
|
|
58
70
|
}
|
|
@@ -63,16 +75,16 @@ export const apiNeedsFixture = createRule({
|
|
|
63
75
|
// with a double-cast through unknown to avoid TS's
|
|
64
76
|
// "may be a mistake" error on discriminated unions.
|
|
65
77
|
const d = decl;
|
|
66
|
-
if (d.type === 'CallExpression' &&
|
|
78
|
+
if (d.type === 'CallExpression' && isDefineActionCall(decl)) {
|
|
67
79
|
callNode = decl;
|
|
68
80
|
}
|
|
69
81
|
}
|
|
70
82
|
if (!callNode)
|
|
71
83
|
return;
|
|
72
|
-
const id =
|
|
84
|
+
const id = extractFixtureIdFromApiAction(callNode);
|
|
73
85
|
if (!id)
|
|
74
86
|
return;
|
|
75
|
-
const fixturePath = join(
|
|
87
|
+
const fixturePath = join(actionDir, '__tests__', `${id}.fixture.json`);
|
|
76
88
|
if (!existsSync(fixturePath)) {
|
|
77
89
|
context.report({ node, messageId: 'apiNeedsFixture', data: { name: id } });
|
|
78
90
|
}
|
|
@@ -9,7 +9,7 @@ export const noRawHttpServer = createRule({
|
|
|
9
9
|
description: 'Disallow HTTP server framework imports (express, koa, fastify, aws-lambda) in domain files.',
|
|
10
10
|
},
|
|
11
11
|
messages: {
|
|
12
|
-
noRawHttpServer: 'HTTP framework import "{{source}}" is not allowed in domain files. The Domain Module runtime handles transport binding.\n fixHint: Remove the `{{source}}` import. Domain primitives (
|
|
12
|
+
noRawHttpServer: 'HTTP framework import "{{source}}" is not allowed in domain files. The Domain Module runtime handles transport binding.\n fixHint: Remove the `{{source}}` import. Domain primitives (defineAction, defineWebhook) are transport-agnostic — the runtime handles HTTP binding.\n kNodeRef: K:convention:tier-1-foundations',
|
|
13
13
|
},
|
|
14
14
|
schema: [],
|
|
15
15
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ESLintUtils } from '@typescript-eslint/utils';
|
|
2
2
|
const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/LawrenceGeeHouse/Mettlecast/blob/develop/packages/eslint-plugin-domain-module/docs/${name}.md`);
|
|
3
3
|
const DEFINE_CALLS = new Set([
|
|
4
|
-
'
|
|
4
|
+
'defineWebhook', 'defineSubscriber', 'defineSchedule',
|
|
5
5
|
'defineJob', 'defineAction', 'defineIntegration', 'defineEvent', 'defineDomain', 'defineFlow',
|
|
6
6
|
]);
|
|
7
7
|
export const requireDefinePrimitive = createRule({
|
|
@@ -12,7 +12,7 @@ export const requireDefinePrimitive = createRule({
|
|
|
12
12
|
description: 'Domain primitive files must export at least one define*() call result.',
|
|
13
13
|
},
|
|
14
14
|
messages: {
|
|
15
|
-
missingDefinePrimitive: 'No define*() call found in this domain primitive file. Export at least one
|
|
15
|
+
missingDefinePrimitive: 'No define*() call found in this domain primitive file. Export at least one defineAction(), defineWebhook(), defineSubscriber(), etc.\n fixHint: Wrap the file\'s export in a define*() call (e.g., `export const handler = defineAction({...})`). See domain.config.ts for the list of available primitives.\n kNodeRef: K:runbook:add-domain',
|
|
16
16
|
},
|
|
17
17
|
schema: [],
|
|
18
18
|
},
|