@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 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 `defineApi` needs a sibling `__tests__/<id>.fixture.json` | error |
38
- | `prefer-result-over-throw` | Domain API handlers must `return err({...})` instead of throwing | error |
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/api/__tests__/get-user.fixture.json',
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/api/__tests__/charge-card.fixture.json',
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/api/get-user.ts',
43
- code: "import { defineApi } from '@mettlecast/domain-runtime'; export const getUser = defineApi({ id: 'get-user', path: '/v1/users/{id}', method: 'GET', tenancy: 'required', versions: { v1: { status: 'stable', input: z.object({}), output: z.object({}), handler: async () => ({}) } } });",
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/api/utils.ts',
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/api/charge-card.ts',
57
- code: "import { defineApi } from '@mettlecast/domain-runtime'; export const chargeCard = defineApi({ id: 'charge-card', path: '/v1/charge', method: 'POST', tenancy: 'required', versions: { v1: { status: 'stable', input: z.object({}), output: z.object({}), handler: async () => ({}) } } });",
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/api/get-user.ts',
14
- code: "export const get = defineApi({ handler: async (input, ctx) => { return await ctx.fetch('https://example.com'); } });",
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/api/get-user.ts',
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/api/get-user.ts',
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/api/get-user.ts',
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/api/charge.ts',
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 { defineApi } from '@mettlecast/domain-runtime';" },
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 { defineApi } from '@mettlecast/domain-runtime';" },
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/api/me.ts',
13
+ filename: 'domains/auth/actions/me.ts',
14
14
  code: `
15
15
  import { ok, err } from '@mettlecast/domain-runtime';
16
- export const me = defineApi({
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/api/me.ts',
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/api/me.ts',
34
+ filename: 'domains/auth/actions/me.ts',
35
35
  code: `
36
36
  import { ok, err, notFound } from '@mettlecast/domain-runtime';
37
- export const me = defineApi({
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/api/me.ts',
48
+ filename: 'domains/auth/actions/me.ts',
49
49
  code: `
50
- export const me = defineApi({
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/api/list-orgs.ts',
58
+ filename: 'domains/orgs/actions/list-orgs.ts',
59
59
  code: `
60
- export const listOrgs = defineApi({
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/api/create-tenant.ts',
69
+ filename: 'domains/tenants/actions/create-tenant.ts',
70
70
  code: `
71
- export const createTenant = defineApi({
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 myApi = defineApi({ id: 'test', path: '/test' });" },
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 DOMAIN_API_FILE_PATTERN = /[/\\]domains[/\\][^/\\]+[/\\]api[/\\][^/\\]+\.ts$/;
6
- function extractFixtureIdFromDefineApi(node) {
7
- const arg = node.arguments[0];
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 isDefineApiCall(node) {
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 === 'defineApi';
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 defineApi({...}) in domains/{x}/api/*.ts must have a matching __tests__/{id}.fixture.json sibling file.',
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 handler "{{name}}" is missing a __tests__/{{name}}.fixture.json sibling file.\n fixHint: Create `domains/<domain>/api/__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',
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 || !DOMAIN_API_FILE_PATTERN.test(filename))
56
+ if (!filename || !DOMAIN_ACTION_FILE_PATTERN.test(filename))
45
57
  return {};
46
- const apiDir = dirname(filename);
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' && isDefineApiCall(d.init)) {
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' && isDefineApiCall(decl)) {
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 = extractFixtureIdFromDefineApi(callNode);
84
+ const id = extractFixtureIdFromApiAction(callNode);
73
85
  if (!id)
74
86
  return;
75
- const fixturePath = join(apiDir, '__tests__', `${id}.fixture.json`);
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 (defineApi, defineWebhook) are transport-agnostic — the runtime handles HTTP binding.\n kNodeRef: K:convention:tier-1-foundations',
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
- 'defineApi', 'defineWebhook', 'defineSubscriber', 'defineSchedule',
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 defineApi(), defineWebhook(), defineSubscriber(), etc.\n fixHint: Wrap the file\'s export in a define*() call (e.g., `export const handler = defineApi({...})`). See domain.config.ts for the list of available primitives.\n kNodeRef: K:runbook:add-domain',
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
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mettlecast/eslint-plugin-domain-module",
3
- "version": "0.2.60",
3
+ "version": "0.2.62",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"