@acmekit/acmekit 2.13.86 → 2.13.88

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 (25) hide show
  1. package/dist/templates/app/.claude/agents/code-reviewer.md +18 -0
  2. package/dist/templates/app/.claude/agents/test-writer.md +28 -18
  3. package/dist/templates/app/.claude/rules/admin-components.md +18 -4
  4. package/dist/templates/app/.claude/rules/admin-data.md +73 -0
  5. package/dist/templates/app/.claude/rules/admin-patterns.md +20 -0
  6. package/dist/templates/app/.claude/rules/api-routes.md +25 -1
  7. package/dist/templates/app/.claude/rules/modules.md +169 -0
  8. package/dist/templates/app/.claude/rules/testing.md +96 -16
  9. package/dist/templates/app/.claude/rules/workflows.md +2 -0
  10. package/dist/templates/app/.claude/skills/admin-customization/SKILL.md +10 -0
  11. package/dist/templates/app/.claude/skills/write-test/SKILL.md +7 -0
  12. package/dist/templates/app/CLAUDE.md +2 -1
  13. package/dist/templates/plugin/.claude/agents/code-reviewer.md +18 -0
  14. package/dist/templates/plugin/.claude/agents/test-writer.md +30 -23
  15. package/dist/templates/plugin/.claude/rules/admin-components.md +18 -4
  16. package/dist/templates/plugin/.claude/rules/admin-data.md +73 -0
  17. package/dist/templates/plugin/.claude/rules/admin-patterns.md +20 -0
  18. package/dist/templates/plugin/.claude/rules/api-routes.md +25 -1
  19. package/dist/templates/plugin/.claude/rules/modules.md +169 -0
  20. package/dist/templates/plugin/.claude/rules/testing.md +187 -20
  21. package/dist/templates/plugin/.claude/rules/workflows.md +2 -0
  22. package/dist/templates/plugin/.claude/skills/admin-customization/SKILL.md +10 -0
  23. package/dist/templates/plugin/.claude/skills/write-test/SKILL.md +8 -0
  24. package/dist/templates/plugin/CLAUDE.md +2 -1
  25. package/package.json +39 -39
@@ -8,7 +8,30 @@ paths:
8
8
 
9
9
  # Testing Rules
10
10
 
11
- ## Critical Read Before Writing Any Test
11
+ ## Pre-Flight Checklist (MANDATORY before writing any test)
12
+
13
+ Complete these steps IN ORDER before writing a single line of test code:
14
+
15
+ 1. **Verify migrations are current.** Run `npx acmekit db:generate <module>` and `npx acmekit db:migrate` if you changed any model. `mode: "app"` runs real migrations — custom modules MUST have migration files or you get `TableNotFoundException`.
16
+
17
+ 2. **Verify jest config buckets.** Read `jest.config.js` and confirm the test type you need exists:
18
+ ```
19
+ integration:http → integration-tests/http/
20
+ integration:app → integration-tests/app/
21
+ integration:modules → src/modules/*/__tests__/
22
+ unit → src/**/__tests__/*.unit.spec.ts
23
+ ```
24
+ If the bucket is missing, add the jest config entry BEFORE writing the test. A test file in a directory with no matching jest config is silently ignored.
25
+
26
+ 3. **Read the source code under test.** Read service methods, route handlers, workflow steps, subscriber handlers — understand actual method signatures, return types, and error paths.
27
+
28
+ 4. **Read mock interfaces.** When your test depends on auto-injected mocks (`MockEventBusService`, `MockLockingService`, `MockSecretsService`), read the mock source in `@acmekit/test-utils` to verify method names and signatures. Do NOT assume mock methods match the real service 1:1 — mocks have convenience methods (e.g., `setSecret()`, `getEmittedEvents()`) and may omit advanced features.
29
+
30
+ 5. **Identify the correct test tier.** Match your test to the right mode and file location (see Test Runner Selection below).
31
+
32
+ ---
33
+
34
+ ## Critical Rules
12
35
 
13
36
  **Single unified runner.** Use `integrationTestRunner` from `@acmekit/test-utils` with a `mode` parameter. The old names (`acmekitIntegrationTestRunner`, `moduleIntegrationTestRunner`) are deprecated aliases — NEVER use them.
14
37
 
@@ -520,29 +543,18 @@ expect(response.data).toEqual({
520
543
 
521
544
  ## Asserting Domain Events
522
545
 
523
- Both runners inject `MockEventBusService` under `Modules.EVENT_BUS` in module mode. Spy on the **prototype**, not an instance.
546
+ `MockEventBusService` accumulates all emitted events. Use `.getEmittedEvents()` no spy needed.
524
547
 
525
548
  ```typescript
526
- import { MockEventBusService } from "@acmekit/test-utils"
527
-
528
- let eventBusSpy: jest.SpyInstance
529
-
530
- beforeEach(() => {
531
- eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit")
532
- })
533
-
534
- afterEach(() => {
535
- eventBusSpy.mockClear()
536
- })
537
-
538
549
  it("should emit post.created event", async () => {
539
550
  await service.createPosts([{ title: "Event Test" }])
540
551
 
541
- const events = eventBusSpy.mock.calls[0][0]
552
+ const eventBus = getContainer().resolve(Modules.EVENT_BUS)
553
+ const events = eventBus.getEmittedEvents()
542
554
  expect(events).toEqual(
543
555
  expect.arrayContaining([
544
556
  expect.objectContaining({
545
- name: "post.created",
557
+ name: "post.created", // NOTE: property is "name", NOT "eventName"
546
558
  data: expect.objectContaining({ id: expect.any(String) }),
547
559
  }),
548
560
  ])
@@ -550,6 +562,8 @@ it("should emit post.created event", async () => {
550
562
  })
551
563
  ```
552
564
 
565
+ > `emitEventStep` maps `eventName` to `name` internally — always assert on `event.name`.
566
+
553
567
  ---
554
568
 
555
569
  ## Waiting for Subscribers
@@ -904,6 +918,61 @@ await expect(provider.process(badInput)).rejects.toThrow("validation")
904
918
 
905
919
  ---
906
920
 
921
+ ## Auto-Injected Infrastructure Mocks
922
+
923
+ The test runner automatically injects mock implementations for infrastructure modules in plugin and module modes. You do NOT need to manually inject these:
924
+
925
+ | Module | Mock class | Behavior |
926
+ |---|---|---|
927
+ | `Modules.EVENT_BUS` | `MockEventBusService` | Accumulates events — call `.getEmittedEvents()` to inspect |
928
+ | `Modules.LOCKING` | `MockLockingService` | In-memory locking — `execute()` runs job immediately, acquire/release tracked |
929
+ | `Modules.SECRETS` | `MockSecretsService` | In-memory store — pre-populate with `.setSecret(id, value)` |
930
+
931
+ Override any mock via `injectedDependencies` if needed — user overrides take precedence.
932
+
933
+ ### Asserting emitted events (no spy needed)
934
+
935
+ ```typescript
936
+ const eventBus = getContainer().resolve(Modules.EVENT_BUS)
937
+
938
+ await createPostWorkflow(getContainer()).run({ input: { title: "Test" } })
939
+
940
+ // Inspect emitted events directly — no jest.spyOn needed
941
+ const events = eventBus.getEmittedEvents()
942
+ const postEvent = events.find((e: any) => e.name === "post.created")
943
+ expect(postEvent).toBeDefined()
944
+ expect(postEvent.data).toEqual(expect.objectContaining({ id: expect.any(String) }))
945
+ ```
946
+
947
+ > Event objects have `{ name, data, metadata?, options? }`. The property is `name`, NOT `eventName` — `emitEventStep` maps `eventName` to `name` internally.
948
+
949
+ ---
950
+
951
+ ## Client Routes Require API Key
952
+
953
+ Routes under `/client/*` use `AcmeKitTypedRequest` (non-authenticated), but they are NOT fully public. AcmeKit middleware enforces a client API key header on all `/client/*` routes. "Public" means "no user authentication" — a client API key is still required.
954
+
955
+ ---
956
+
957
+ ## Known Environment Issues
958
+
959
+ ### Database Teardown Permission Errors
960
+
961
+ **Symptom:** `permission denied for schema public` or `cannot drop schema` during test teardown (afterAll).
962
+
963
+ **Cause:** PostgreSQL user lacks `CREATE`/`DROP` privileges on the `public` schema, or concurrent test processes hold locks.
964
+
965
+ **Fix:**
966
+ ```bash
967
+ # Grant full privileges to the test user (run once)
968
+ psql -U postgres -c "GRANT ALL ON SCHEMA public TO <your_user>;"
969
+ psql -U postgres -c "ALTER USER <your_user> CREATEDB;"
970
+ ```
971
+
972
+ This is an environment setup issue, not a test code issue. If teardown fails but all tests passed, the test results are still valid.
973
+
974
+ ---
975
+
907
976
  ## Anti-Patterns — NEVER Do These
908
977
 
909
978
  ```typescript
@@ -1068,4 +1137,15 @@ const re = new RegExp("^(\\*|[0-9]+)(\\/[0-9]+)?$")
1068
1137
  const result = await provider.process(bad)
1069
1138
  expect(result.success).toBe(false) // ❌ actually throws
1070
1139
  // RIGHT — read implementation first to check if it throws or returns
1140
+
1141
+ // WRONG — using jest.spyOn to inspect emitted events
1142
+ jest.spyOn(MockEventBusService.prototype, "emit") // ❌ fragile, complex extraction
1143
+ // RIGHT — use built-in event accumulation
1144
+ const eventBus = getContainer().resolve(Modules.EVENT_BUS)
1145
+ const events = eventBus.getEmittedEvents()
1146
+
1147
+ // WRONG — checking event.eventName (emitEventStep maps eventName → name internally)
1148
+ expect(event.eventName).toBe("post.created") // ❌ property doesn't exist
1149
+ // RIGHT
1150
+ expect(event.name).toBe("post.created")
1071
1151
  ```
@@ -502,6 +502,8 @@ dismissRemoteLinkStep([{
502
502
 
503
503
  Always resolve services fresh from `container`. Never capture from closure in compensation.
504
504
 
505
+ **Workflow steps have the full application container** — they can resolve ANY module. Module providers and services are scoped to their parent module and cannot resolve other modules. When a provider needs cross-module dependencies, the step resolves them and passes them through the service to the provider as method parameters (see `modules.md` → Provider Container Scoping).
506
+
505
507
  ```typescript
506
508
  // Typed resolution (preferred)
507
509
  const service = container.resolve<IBlogModuleService>(BLOG_MODULE)
@@ -376,6 +376,16 @@ Each widget file exports exactly one default component and one `config`.
376
376
 
377
377
  Widgets receive no props. Fetch all data internally using hooks.
378
378
 
379
+ ### Admin Dependencies
380
+
381
+ Form-related packages are provided by acmekit — do NOT install them separately:
382
+
383
+ - `react-hook-form` — import `useForm` from `"react-hook-form"` (available transitively)
384
+ - `@hookform/resolvers` — import `zodResolver` from `"@hookform/resolvers/zod"` (available transitively)
385
+ - `zod` — import as `import * as zod from "@acmekit/deps/zod"` (NOT `from "zod"`)
386
+
387
+ **For plugins:** Add `react-hook-form` and `@hookform/resolvers` to `peerDependencies` (+ `devDependencies` mirror), never just `devDependencies`.
388
+
379
389
  ### Prefer Shared SDK Over Raw Fetch
380
390
 
381
391
  Use `src/admin/lib/sdk.ts` for backend calls so auth, path prefixes, and route typings stay consistent:
@@ -15,6 +15,13 @@ Generate tests for AcmeKit applications using `integrationTestRunner` with the c
15
15
  - HTTP integration tests: !`ls integration-tests/http/*.spec.ts 2>/dev/null || echo "(none)"`
16
16
  - App integration tests: !`ls integration-tests/app/*.spec.ts 2>/dev/null || echo "(none)"`
17
17
 
18
+ ## Pre-Flight Checklist (MANDATORY — do these BEFORE writing any test)
19
+
20
+ 1. **Verify migrations are current.** If model files changed, run `npx acmekit db:generate <module>` + `npx acmekit db:migrate`. Missing migrations cause `TableNotFoundException`.
21
+ 2. **Verify jest config buckets.** Read `jest.config.js` — confirm the bucket you need exists (`integration:http`, `integration:app`, `integration:modules`, `unit`). If missing, add the entry and create the directory first.
22
+ 3. **Read source code under test.** Understand method signatures, return types, error handling.
23
+ 4. **Read mock interfaces.** If using auto-injected mocks, read their source to verify method names. Don't guess from real service interfaces.
24
+
18
25
  ## Critical Gotchas — Every Test Must Get These Right
19
26
 
20
27
  1. **Unified runner only.** `import { integrationTestRunner } from "@acmekit/test-utils"`. NEVER use `acmekitIntegrationTestRunner` or `moduleIntegrationTestRunner` — those are deprecated.
@@ -8,7 +8,8 @@ You are a senior AcmeKit framework developer. Build features using code generato
8
8
 
9
9
  - Edit files in `src/types/generated/` — overwritten by `npx acmekit generate types`
10
10
  - Import from `.acmekit/types/` or `src/types/generated/` directly — use `InferTypeOf` or barrel exports
11
- - Import `zod`, `awilix`, or `pg` directly — use `@acmekit/framework/zod`, `@acmekit/framework/awilix`, `@acmekit/framework/pg`
11
+ - Import `zod`, `awilix`, or `pg` directly — backend: use `@acmekit/framework/zod`, `@acmekit/framework/awilix`, `@acmekit/framework/pg`; admin UI: use `@acmekit/deps/zod`
12
+ - Add `react-hook-form` or `@hookform/resolvers` as dependencies — they are provided by acmekit
12
13
  - Use string literals for container keys — use `ContainerRegistrationKeys.*` and `Modules.*`
13
14
  - Call services directly for mutations in routes — use workflows
14
15
  - Put business logic in workflow steps — steps are thin wrappers; service methods own orchestration
@@ -76,8 +76,11 @@ For each match: if the method name has no `_` suffix it's a public method — wr
76
76
  ```
77
77
  grep -rn "Module(" src/modules/ | grep -v "ModuleProvider\|AcmeKitService"
78
78
  grep -rn "static identifier" src/modules/
79
+ grep -rn "this\.container_\[" src/modules/
80
+ grep -rn "container_\." src/modules/ | grep -v "logger\|options\|container_:"
79
81
  ```
80
82
  Check: any file with `ModuleProvider` that uses `Module()` instead → wrong export format.
83
+ Check: providers resolving other modules from `this.container_` → module-scoped container can't see other modules. Cross-module deps must be passed as method params from the workflow step through the service.
81
84
 
82
85
  **If hasWorkflows:**
83
86
  ```
@@ -93,7 +96,9 @@ grep -rn "res\.json.*result\b" src/api/
93
96
  grep -rn "validators\.ts" src/api/
94
97
  grep -rn "import.*from.*\/service\"" src/api/
95
98
  grep -rn "validatedBody as any" src/api/
99
+ grep -rn "req\.validatedQuery[^?]" src/api/
96
100
  ```
101
+ For `req.validatedQuery`: verify every destructuring uses `?? {}` fallback (e.g., `req.validatedQuery ?? {}`). Without it, requests with no query string crash.
97
102
 
98
103
  **If hasSubscribers:**
99
104
  ```
@@ -109,7 +114,12 @@ grep -rn "await.*service\." src/jobs/
109
114
  ```
110
115
  grep -rn "^export default function " src/admin/
111
116
  grep -rn "zone:.*\`\|zone:.*\+" src/admin/
117
+ grep -rn "from [\"']zod[\"']" src/admin/
118
+ grep -rn "<SectionRow.*>" src/admin/ | grep "children\|>" | grep -v "/>"
119
+ grep -rn "react-hook-form\|@hookform" package.json
120
+ grep -rn "useDataTable" src/admin/
112
121
  ```
122
+ For each `useDataTable` match with `filtering` config: check that the companion `useQuery` includes filter/search state in `queryKey` AND passes filter params in `queryFn`. A static `queryKey` like `["items"]` with filtering enabled means filters do nothing (server-side filtering, `manualFiltering: true`).
113
123
 
114
124
  ### Step 5 — Read matched files
115
125
  For every file with a hit, read it fully to confirm the issue in context.
@@ -155,6 +165,7 @@ Only work through sections for detected features. Explicitly skip and note secti
155
165
  - [ ] `PREFIX` and `IDENTIFIER_KEY` constants defined once in `constants.ts`, never hardcoded inline
156
166
  - [ ] `createProvidersLoader` used for modules accepting providers — no hand-rolled provider loops
157
167
  - [ ] Service resolves providers via `this.providers.resolve(id)` — NOT `(this as any).__container__`
168
+ - [ ] Providers do NOT resolve other modules from `this.container_` (constructor or property access) — provider containers are module-scoped. Cross-module deps must be received as method parameters, resolved by the workflow step and passed through the parent module's service
158
169
 
159
170
  ### Workflows — only if hasWorkflows
160
171
  - [ ] Steps in `src/workflows/steps/<name>.ts` — one per file, not in workflow files
@@ -180,6 +191,7 @@ Only work through sections for detected features. Explicitly skip and note secti
180
191
  - [ ] `defineRouteConfig` with Zod body (`.strict()`) and/or query schemas
181
192
  - [ ] No `validators.ts` for new routes
182
193
  - [ ] `req.validatedBody`, `req.validatedQuery`, `req.queryConfig` — not `req.remoteQueryConfig`
194
+ - [ ] `req.validatedQuery` destructured with `?? {}` fallback — undefined when request has no query string
183
195
  - [ ] Every method that uses `req.queryConfig` has BOTH `query` and `queryConfig` in its config — `queryConfig` without `query` is silently ignored
184
196
  - [ ] `additional_data` destructured without `as any` — `validatedBody` is already typed
185
197
  - [ ] Mutations: workflow → `query.graph()` refetch → return refetched data
@@ -217,6 +229,12 @@ Only work through sections for detected features. Explicitly skip and note secti
217
229
  - [ ] `usePrompt()` before destructive actions
218
230
  - [ ] API calls via shared SDK from `src/admin/lib/sdk.ts`
219
231
  - [ ] `useRouteModal().handleSuccess()` for navigation after create/edit
232
+ - [ ] Zod imported from `@acmekit/deps/zod` — NOT bare `"zod"`
233
+ - [ ] `SectionRow` uses only `title`, `value`, `actions` props — no `children` (use `value` for custom JSX)
234
+ - [ ] `onRowClick` callback uses `row.original.id` — NOT `row.id` (runtime is TanStack `Row<TData>` despite type)
235
+ - [ ] DataTable with `filtering` config: `useQuery` queryKey includes filter/search/pagination state — NOT static like `["items"]`
236
+ - [ ] DataTable with `filtering` config: `queryFn` passes filter state as query params to API — NOT fetching unfiltered data
237
+ - [ ] `react-hook-form` and `@hookform/resolvers` in `peerDependencies` (not only `devDependencies`) — they are provided by acmekit
220
238
 
221
239
  ### Middlewares — only if hasMiddlewares
222
240
  - [ ] Every middleware calls `next()`
@@ -8,10 +8,24 @@ maxTurns: 20
8
8
 
9
9
  You are an AcmeKit test engineer for plugins. Generate comprehensive integration tests using the unified `integrationTestRunner` with the correct mode and patterns.
10
10
 
11
- **BEFORE writing any test:**
12
- 1. Read `.claude/rules/testing.md` — it contains anti-patterns, fixtures, lifecycle, and error handling rules you MUST follow
13
- 2. Read the source code you're testing — understand service methods, models, workflows, and subscribers
14
- 3. Identify the correct test tier (HTTP, plugin container, module, or unit)
11
+ **MANDATORY PRE-FLIGHT CHECKLIST — complete ALL steps before writing any test code:**
12
+
13
+ 1. **Read `.claude/rules/testing.md`**contains anti-patterns, fixtures, lifecycle, and error handling rules you MUST follow.
14
+
15
+ 2. **Build the plugin.** Run `pnpm build` (or verify `pnpm dev` is running). Stale `.acmekit/server/` output causes cryptic errors (`__joinerConfig is not a function`, missing services). The test runner loads compiled JS from `.acmekit/server/`, not TypeScript source.
16
+
17
+ 3. **Verify jest config buckets.** Read `jest.config.js` and confirm the test bucket you need exists:
18
+ - `integration:plugin` → `integration-tests/plugin/`
19
+ - `integration:http` → `integration-tests/http/`
20
+ - `integration:modules` → `src/modules/*/__tests__/`
21
+ - `unit` → `src/**/__tests__/*.unit.spec.ts`
22
+ If the bucket is missing, add it to `jest.config.js` and create the directory BEFORE writing the test.
23
+
24
+ 4. **Read the source code under test** — understand service methods, models, workflows, subscribers, route paths, validators, response shapes, and error handling.
25
+
26
+ 5. **Read mock interfaces.** If your test uses auto-injected mocks (`MockEventBusService`, `MockLockingService`, `MockSecretsService`), read their actual source to verify exact method names and signatures. Do NOT guess — mocks have convenience methods (e.g., `setSecret()`, `getEmittedEvents()`) not on real services, and may omit some real service methods.
27
+
28
+ 6. **Identify the correct test tier** (HTTP, plugin container, module, or unit).
15
29
 
16
30
  ---
17
31
 
@@ -471,31 +485,22 @@ describe("MyProvider", () => {
471
485
 
472
486
  ## Asserting Domain Events (container-only mode)
473
487
 
474
- Plugin mode (without HTTP) injects `MockEventBusService`. Spy on the **prototype**, not an instance.
488
+ `MockEventBusService` accumulates emitted events use `.getEmittedEvents()` directly, no spy needed.
475
489
 
476
490
  ```typescript
477
- import { MockEventBusService } from "@acmekit/test-utils"
478
-
479
- let eventBusSpy: jest.SpyInstance
480
-
481
- beforeEach(() => {
482
- eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit")
483
- })
484
-
485
- afterEach(() => {
486
- eventBusSpy.mockClear()
487
- })
491
+ import { Modules } from "@acmekit/framework/utils"
488
492
 
489
493
  it("should emit greeting.created event", async () => {
490
494
  const service: any = container.resolve(GREETING_MODULE)
491
495
  await service.createGreetings([{ message: "Event Test" }])
492
496
 
493
- // MockEventBusService.emit receives an ARRAY of events
494
- const events = eventBusSpy.mock.calls[0][0]
497
+ // Access events directly from the mock event bus — no spy needed
498
+ const eventBus = container.resolve(Modules.EVENT_BUS)
499
+ const events = eventBus.getEmittedEvents()
495
500
  expect(events).toEqual(
496
501
  expect.arrayContaining([
497
502
  expect.objectContaining({
498
- name: "greeting.created",
503
+ name: "greeting.created", // NOTE: property is "name", NOT "eventName"
499
504
  data: expect.objectContaining({ id: expect.any(String) }),
500
505
  }),
501
506
  ])
@@ -503,6 +508,8 @@ it("should emit greeting.created event", async () => {
503
508
  })
504
509
  ```
505
510
 
511
+ > `emitEventStep` maps `eventName` to `name` internally — always assert on `event.name`.
512
+
506
513
  ---
507
514
 
508
515
  ## What to Test
@@ -538,8 +545,8 @@ it("should emit greeting.created event", async () => {
538
545
  - Auth: 401 without JWT, 400 without client API key
539
546
 
540
547
  **Events (container mode):**
541
- - Spy on `MockEventBusService.prototype.emit` (prototype, not instance)
542
- - Access events via `eventBusSpy.mock.calls[0][0]` (array of event objects)
548
+ - Use `container.resolve(Modules.EVENT_BUS).getEmittedEvents()` no spy needed
549
+ - Event objects have `{ name, data }` property is `name`, NOT `eventName`
543
550
 
544
551
  ---
545
552
 
@@ -576,8 +583,8 @@ it("should emit greeting.created event", async () => {
576
583
  - Use realistic test data ("Launch Announcement", "Quarterly Report") not "test", "foo"
577
584
  - Pass body directly: `api.post(url, body, headers)` — NOT `{ body: {...} }`
578
585
  - Runners handle DB setup/teardown — no manual cleanup needed
579
- - Spy on `MockEventBusService.prototype` — not an instance
580
- - `jest.restoreAllMocks()` in `afterEach` when spying
586
+ - Use `eventBus.getEmittedEvents()` for event assertions no spy ceremony
587
+ - Event property is `name`, NOT `eventName` (`emitEventStep` maps internally)
581
588
  - NEVER use JSDoc blocks or type casts in test files
582
589
  - **Always `beforeEach(() => jest.clearAllMocks())`** in unit tests — mock state leaks between describes
583
590
  - **Never reference file-level `const`/`let` inside `jest.mock()` factories** — TDZ error
@@ -36,10 +36,18 @@ useDataTable({
36
36
  filtering: { state: DataTableFilteringState, onFilteringChange: (v) => void },
37
37
  pagination: { state: DataTablePaginationState, onPaginationChange: (v) => void },
38
38
  rowSelection: { state: DataTableRowSelectionState, onRowSelectionChange: (v) => void, enableRowSelection?: boolean | ((row) => boolean) },
39
- onRowClick: (event, row) => void,
39
+ onRowClick: (event, row) => void, // see note below
40
40
  })
41
41
  ```
42
42
 
43
+ **`onRowClick` row parameter:** The TypeScript signature declares `row: TData`, but at runtime `row` is a TanStack `Row<TData>` object. Access your data via `row.original`:
44
+
45
+ ```tsx
46
+ onRowClick: (_event, row) => navigate(row.original.id),
47
+ ```
48
+
49
+ This is a known type mismatch in `useDataTable`. TypeScript may report an error on `row.original` — cast if needed: `(row as any).original.id`.
50
+
43
51
  State types: `DataTablePaginationState`, `DataTableSortingState`, `DataTableFilteringState`, `DataTableRowSelectionState` — all from `@acmekit/ui`.
44
52
 
45
53
  **`DataTable.Table` emptyState shape:**
@@ -274,6 +282,8 @@ const useFilters = () => {
274
282
 
275
283
  **Best practice**: For API-loaded filter options, fetch with `{ limit: 1000 }` and conditionally add the filter only when data is available.
276
284
 
285
+ **CRITICAL**: `useDataTable` uses server-side filtering (`manualFiltering: true`). Defining filters alone does NOT filter data — you MUST wire filter state to the API query. See the "DataTable Server-Side Filtering" section in the admin-data rules for the complete pattern (building `queryParams` from filter/search state, including them in `queryKey`, and passing as URL query params in `queryFn`).
286
+
277
287
  ---
278
288
 
279
289
  ## Forms — Form.Field Compound Component
@@ -284,7 +294,11 @@ const useFilters = () => {
284
294
  import { Form, Input, Textarea, Select, Switch } from "@acmekit/ui"
285
295
  import { useForm } from "react-hook-form"
286
296
  import { zodResolver } from "@hookform/resolvers/zod"
287
- import * as zod from "zod"
297
+ import * as zod from "@acmekit/deps/zod"
298
+
299
+ // NOTE: react-hook-form and @hookform/resolvers are provided by acmekit.
300
+ // In plugins: list them in peerDependencies (NOT devDependencies).
301
+ // In apps: they are available transitively — no extra dependency needed.
288
302
 
289
303
  const Schema = zod.object({
290
304
  title: zod.string().min(1, "Title is required"),
@@ -412,7 +426,7 @@ import { useForm } from "react-hook-form"
412
426
  import { zodResolver } from "@hookform/resolvers/zod"
413
427
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
414
428
  import { useParams } from "react-router-dom"
415
- import * as zod from "zod"
429
+ import * as zod from "@acmekit/deps/zod"
416
430
  import { sdk } from "../../../../lib/sdk"
417
431
 
418
432
  const EditSchema = zod.object({
@@ -544,7 +558,7 @@ import { Button, RouteFocusModal, Form, Heading, Input, Text, Textarea, toast, K
544
558
  import { useForm } from "react-hook-form"
545
559
  import { zodResolver } from "@hookform/resolvers/zod"
546
560
  import { useMutation, useQueryClient } from "@tanstack/react-query"
547
- import * as zod from "zod"
561
+ import * as zod from "@acmekit/deps/zod"
548
562
  import { sdk } from "../../../lib/sdk"
549
563
 
550
564
  const CreateSchema = zod.object({
@@ -75,6 +75,69 @@ const { data, isLoading } = useQuery({
75
75
  })
76
76
  ```
77
77
 
78
+ ### DataTable Server-Side Filtering
79
+
80
+ **`useDataTable` uses `manualFiltering: true`** — it does NOT filter data client-side. Filter/search/pagination state must be sent to the API as query parameters. If you don't wire state to the query, changing filters has no effect.
81
+
82
+ ```tsx
83
+ const [search, setSearch] = useState("")
84
+ const [filtering, setFiltering] = useState<DataTableFilteringState>({})
85
+ const [pagination, setPagination] = useState<DataTablePaginationState>({
86
+ pageIndex: 0,
87
+ pageSize: 20,
88
+ })
89
+
90
+ // Build query params from filter/search/pagination state
91
+ const queryParams = useMemo(() => {
92
+ const params: Record<string, string> = {}
93
+
94
+ Object.entries(filtering).forEach(([key, value]) => {
95
+ if (value == null) return
96
+ if (Array.isArray(value) && value.length > 0) {
97
+ params[key] = value.join(",")
98
+ } else if (!Array.isArray(value)) {
99
+ params[key] = String(value)
100
+ }
101
+ })
102
+
103
+ if (search) {
104
+ params.q = search
105
+ }
106
+
107
+ return params
108
+ }, [filtering, search])
109
+
110
+ // Include queryParams in queryKey so React Query refetches on filter changes
111
+ const { data, isLoading } = useQuery({
112
+ queryKey: ["wallets", queryParams],
113
+ queryFn: () => {
114
+ const qs = new URLSearchParams(queryParams).toString()
115
+ return sdk.admin.fetch(`/wallets${qs ? `?${qs}` : ""}`, {
116
+ method: "GET",
117
+ })
118
+ },
119
+ })
120
+
121
+ const table = useDataTable({
122
+ data: data?.wallets ?? [],
123
+ columns,
124
+ filters,
125
+ getRowId: (row) => row.id,
126
+ rowCount: data?.count ?? data?.wallets?.length ?? 0,
127
+ isLoading,
128
+ search: { state: search, onSearchChange: setSearch },
129
+ filtering: { state: filtering, onFilteringChange: setFiltering },
130
+ pagination: { state: pagination, onPaginationChange: setPagination },
131
+ })
132
+ ```
133
+
134
+ **Key rules:**
135
+ - `queryKey` MUST include filter/search/pagination state — otherwise React Query won't refetch when filters change
136
+ - `queryFn` MUST pass state as query parameters to the API
137
+ - Multiselect filters produce `string[]` — serialize as comma-separated for query params
138
+ - Radio filters produce `string` — pass directly
139
+ - The API route must handle these params (see `api-routes.md` for array filter handling)
140
+
78
141
  ---
79
142
 
80
143
  ## Action Menus — ActionMenu Component
@@ -247,6 +310,10 @@ const confirmed = await prompt({
247
310
  **Entity types:**
248
311
  - Hand-writing `type Payment = { id: string; status: string; created_at: string }` instead of inferring from SDK. Hand-written types drift from response schemas: date fields should be `string | Date` (from `z.union`), nullable fields need `| null`, enum fields are `string` (not the user enum). Always use `type Payment = Awaited<ReturnType<typeof sdk.admin.fetch<"/admin/payments", "GET">>>["items"][0]`
249
312
 
313
+ **Imports & dependencies:**
314
+ - Importing `zod` from `"zod"` in admin code — use `import * as zod from "@acmekit/deps/zod"` to share the same zod instance as acmekit
315
+ - Adding `react-hook-form` or `@hookform/resolvers` to `devDependencies` — they are provided by acmekit. In plugins: list them in `peerDependencies` (+ `devDependencies` mirror). In apps: they are available transitively, no extra dependency needed
316
+
250
317
  **Form context:**
251
318
  - Using `Form.Field`, `Form.Label`, `SwitchBox`, or any `Form.*` sub-component without a form provider — they call `useFormContext()` and crash with "Cannot destructure property 'getFieldState' of 'useFormContext(...)' as it is null". `RouteDrawer.Form` / `RouteFocusModal.Form` provide this automatically; standalone pages must wrap with `<Form {...form}>` from `@acmekit/ui`
252
319
  - Importing `FormProvider` from `react-hook-form` directly — causes dual-instance context mismatch. Always use `Form` from `@acmekit/ui` (it IS the FormProvider re-export)
@@ -265,6 +332,8 @@ const confirmed = await prompt({
265
332
  - Not using `size="small"` on ALL buttons (header actions, form buttons, table actions)
266
333
 
267
334
  **DataTable:**
335
+ - **Static `queryKey` with filters** — using `queryKey: ["posts"]` when the table has filters/search/pagination. The `queryKey` MUST include filter/search/pagination state so React Query refetches when they change: `queryKey: ["posts", queryParams]`
336
+ - **Not passing filter state to the API** — `useDataTable` uses `manualFiltering: true` (server-side). If the `queryFn` doesn't send filter values as query parameters, changing filters has zero effect on the displayed data
268
337
  - Passing `columns`, `filters`, `heading`, `emptyState` as props to `<DataTable>` — it only accepts `instance`. Use sub-components: `DataTable.Toolbar`, `DataTable.Table`, etc.
269
338
  - Using boolean flags like `enablePagination: true` in `useDataTable` — pass state objects: `pagination: { state, onPaginationChange }`
270
339
  - Using `table.getState().rowSelection` — use `table.getRowSelection()` instead
@@ -272,6 +341,7 @@ const confirmed = await prompt({
272
341
  - Passing `commands` as a prop to `<DataTable>` — pass to `useDataTable` hook and render `<DataTable.CommandBar>`
273
342
  - Using `Container className="divide-y p-0"` for DataTable pages — use `Container className="flex flex-col overflow-hidden p-0"` (DataTable needs flex layout)
274
343
  - Rendering a `DataTable.*` sub-component without its matching state in `useDataTable` — causes runtime crash. `.Search` requires `search`, `.SortingMenu` requires `sorting`, `.FilterMenu` requires `filters`, `.CommandBar` requires `commands` + `rowSelection`. Never add a sub-component without wiring its state.
344
+ - Using `row.id` in `onRowClick` — `row` is a TanStack `Row<TData>` at runtime (despite the `TData` type signature). Access data via `row.original.id`. Cast if needed: `(row as any).original.id`.
275
345
 
276
346
  **Error handling:**
277
347
  - Using `toast.error` for query errors — throw them (`if (isError) throw error`) for error boundary
@@ -299,6 +369,9 @@ const confirmed = await prompt({
299
369
  - Not breaking large detail pages into section components — if a detail page has 3+ sections, each should be its own component in `components/`
300
370
  - Putting delete logic inline instead of extracting a reusable `useDelete*Action` hook when the same delete is used in multiple places
301
371
 
372
+ **SectionRow:**
373
+ - Passing `children` to `SectionRow` — it does NOT accept children. Use the `value` prop for custom JSX content (badges, copy buttons, formatted text). `SectionRow` only accepts `title`, `value`, and `actions`.
374
+
302
375
  **Wrong component for context:**
303
376
  - Using `StatusBadge` in DataTable columns — use `StatusCell` instead (dot indicator, truncation-safe)
304
377
  - Using raw `Switch` + manual label/description instead of `SwitchBox` for toggle settings
@@ -535,6 +535,26 @@ import { SectionRow } from "@acmekit/ui"
535
535
 
536
536
  **Props**: `title: string`, `value?: ReactNode | string | null`, `actions?: ReactNode`.
537
537
 
538
+ **SectionRow does NOT accept `children`.** Pass custom JSX through the `value` prop instead:
539
+
540
+ ```tsx
541
+ // WRONG — children is not a valid prop (TypeScript error)
542
+ <SectionRow title="Address">
543
+ <div className="font-mono">{wallet.address}</div>
544
+ </SectionRow>
545
+
546
+ // RIGHT — use value prop for custom JSX content
547
+ <SectionRow
548
+ title="Address"
549
+ value={
550
+ <div className="group flex items-center gap-x-2">
551
+ <Text size="small" className="font-mono">{wallet.address}</Text>
552
+ <Copy content={wallet.address} className="hidden group-hover:block" />
553
+ </div>
554
+ }
555
+ />
556
+ ```
557
+
538
558
  ---
539
559
 
540
560
  ## Component Organization — Scaling Beyond page.tsx
@@ -145,6 +145,24 @@ query: z.object({
145
145
  })
146
146
  ```
147
147
 
148
+ **Query schemas — multiselect filter params:**
149
+ - Admin DataTable multiselect filters send array values as comma-separated strings (e.g., `?status=active,draft`)
150
+ - Use `z.preprocess` to split comma-separated strings into arrays:
151
+ ```typescript
152
+ status: z.preprocess(
153
+ (v) => (typeof v === "string" ? v.split(",") : v),
154
+ z.array(z.string()).optional()
155
+ ),
156
+ ```
157
+ - For enum arrays, chain with `z.enum()` inside the array:
158
+ ```typescript
159
+ network: z.preprocess(
160
+ (v) => (typeof v === "string" ? v.split(",") : v),
161
+ z.array(z.enum(["mainnet", "testnet"])).optional()
162
+ ),
163
+ ```
164
+ - In the handler, pass non-empty arrays to the service filter: `if (status?.length) listFilters.status = status`
165
+
148
166
  **Body schemas:**
149
167
  - `.strict()` on create/update bodies to reject unknown fields
150
168
  - `.nullish()` on fields that can be explicitly cleared to null (not just `.optional()`)
@@ -346,7 +364,7 @@ res.status(200).json({ id, object: "blog_post", deleted: true })
346
364
  | Property | Available on | Description |
347
365
  |---|---|---|
348
366
  | `req.validatedBody` | POST routes with `body` config | Validated request body |
349
- | `req.validatedQuery` | Routes with `query` config | Validated query params (all params) |
367
+ | `req.validatedQuery` | Routes with `query` config | Validated query params (all params). **Can be `undefined` when request has no query string** — always destructure with `?? {}` fallback |
350
368
  | `req.filterableFields` | Routes with `query` config | Query params minus pagination/fields/order |
351
369
  | `req.queryConfig.fields` | Routes with `queryConfig` | Array of field strings to select |
352
370
  | `req.queryConfig.pagination` | Routes with `queryConfig` | `{ skip, take, order }` |
@@ -748,4 +766,10 @@ const { additional_data, ...body } = req.validatedBody as any // ❌ defeats al
748
766
 
749
767
  // RIGHT — validatedBody is already typed from the body schema; no cast needed
750
768
  const { additional_data, ...body } = req.validatedBody // ✅ type inferred from defineRouteConfig
769
+
770
+ // WRONG — destructuring req.validatedQuery without fallback (undefined when no query params sent)
771
+ const { enabled, ...filters } = req.validatedQuery // ❌ crashes if request has no query string
772
+
773
+ // RIGHT — always provide fallback when destructuring validatedQuery
774
+ const { enabled, ...filters } = req.validatedQuery ?? {} // ✅ safe when no query params
751
775
  ```