@acmekit/acmekit 2.13.87 → 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.
- package/dist/templates/app/.claude/agents/code-reviewer.md +18 -0
- package/dist/templates/app/.claude/agents/test-writer.md +18 -4
- package/dist/templates/app/.claude/rules/admin-components.md +18 -4
- package/dist/templates/app/.claude/rules/admin-data.md +73 -0
- package/dist/templates/app/.claude/rules/admin-patterns.md +20 -0
- package/dist/templates/app/.claude/rules/api-routes.md +25 -1
- package/dist/templates/app/.claude/rules/modules.md +169 -0
- package/dist/templates/app/.claude/rules/testing.md +43 -1
- package/dist/templates/app/.claude/rules/workflows.md +2 -0
- package/dist/templates/app/.claude/skills/admin-customization/SKILL.md +10 -0
- package/dist/templates/app/.claude/skills/write-test/SKILL.md +7 -0
- package/dist/templates/app/CLAUDE.md +2 -1
- package/dist/templates/plugin/.claude/agents/code-reviewer.md +18 -0
- package/dist/templates/plugin/.claude/agents/test-writer.md +18 -4
- package/dist/templates/plugin/.claude/rules/admin-components.md +18 -4
- package/dist/templates/plugin/.claude/rules/admin-data.md +73 -0
- package/dist/templates/plugin/.claude/rules/admin-patterns.md +20 -0
- package/dist/templates/plugin/.claude/rules/api-routes.md +25 -1
- package/dist/templates/plugin/.claude/rules/modules.md +169 -0
- package/dist/templates/plugin/.claude/rules/testing.md +53 -1
- package/dist/templates/plugin/.claude/rules/workflows.md +2 -0
- package/dist/templates/plugin/.claude/skills/admin-customization/SKILL.md +10 -0
- package/dist/templates/plugin/.claude/skills/write-test/SKILL.md +8 -0
- package/dist/templates/plugin/CLAUDE.md +2 -1
- package/package.json +39 -39
|
@@ -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. Generate comprehensive integration tests using the unified `integrationTestRunner` with the correct mode and patterns.
|
|
10
10
|
|
|
11
|
-
**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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. **Verify migrations are current.** If model files changed, run `npx acmekit db:generate <module>` and `npx acmekit db:migrate`. `mode: "app"` runs real migrations — missing migration files cause `TableNotFoundException`.
|
|
16
|
+
|
|
17
|
+
3. **Verify jest config buckets.** Read `jest.config.js` and confirm the test bucket you need exists:
|
|
18
|
+
- `integration:http` → `integration-tests/http/`
|
|
19
|
+
- `integration:app` → `integration-tests/app/`
|
|
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, route paths, validators, response shapes, and error handling (throws vs returns).
|
|
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 not implement every real service method.
|
|
27
|
+
|
|
28
|
+
6. **Identify the correct test tier** (HTTP, app, module, or unit).
|
|
15
29
|
|
|
16
30
|
---
|
|
17
31
|
|
|
@@ -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
|
```
|
|
@@ -396,6 +396,149 @@ Options flow: host config `providers[].options` → constructor second argument
|
|
|
396
396
|
|
|
397
397
|
Available domain abstracts: `AbstractNotificationProviderService`, `AbstractFileProviderService`, `AbstractAuthModuleProvider`, `AbstractAnalyticsProviderService`, `AbstractModuleProvider<TConfig>` (generic base).
|
|
398
398
|
|
|
399
|
+
### Provider Container Scoping
|
|
400
|
+
|
|
401
|
+
**Providers are module-scoped.** The `container` passed to a provider constructor is the **parent module's** awilix cradle — not the application root container. This is by design: modules are isolated, self-contained units.
|
|
402
|
+
|
|
403
|
+
**Providers CAN access:**
|
|
404
|
+
- Sibling registrations within the parent module (e.g., `logger`, services registered by the module's loaders)
|
|
405
|
+
- Their own `this.options_` (from `providers[].options` in host config)
|
|
406
|
+
|
|
407
|
+
**Providers CANNOT access:**
|
|
408
|
+
- Other modules — a provider under `RELAYER_MODULE` cannot resolve `TRON_MODULE`, `Modules.USER`, or any module outside its parent
|
|
409
|
+
- Application-level container registrations not propagated to the module scope
|
|
410
|
+
|
|
411
|
+
**Attempting to resolve another module from the provider's container silently returns `undefined` or throws at runtime** — there is no compile-time error.
|
|
412
|
+
|
|
413
|
+
#### Cradle vs AcmeKitContainer — Two Different Objects
|
|
414
|
+
|
|
415
|
+
AcmeKit has two container representations. Confusing them causes silent `undefined` at runtime.
|
|
416
|
+
|
|
417
|
+
| | **Cradle** (awilix proxy) | **AcmeKitContainer** |
|
|
418
|
+
|---|---|---|
|
|
419
|
+
| What is it | Proxy object — property access triggers resolution | Full container with `.resolve()` method |
|
|
420
|
+
| Access pattern | `container.logger`, `container[MODULE]` | `container.resolve(MODULE)` |
|
|
421
|
+
| Who gets it | Provider constructors, service constructors | Workflow steps (`{ container }`), `req.scope` |
|
|
422
|
+
| Type | `Record<string, unknown>` (or typed cradle) | `AcmeKitContainer` from `@acmekit/framework/types` |
|
|
423
|
+
|
|
424
|
+
```typescript
|
|
425
|
+
// Provider constructor — receives CRADLE (property access)
|
|
426
|
+
constructor(container: Cradle, options: Options) {
|
|
427
|
+
this.logger_ = container.logger // ✅ property access
|
|
428
|
+
this.logger_ = container.resolve("logger") // ❌ cradle has no .resolve()
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Workflow step — receives ACMEKIT CONTAINER (.resolve() method)
|
|
432
|
+
async (input, { container }) => {
|
|
433
|
+
const svc = container.resolve(MY_MODULE) // ✅ .resolve() method
|
|
434
|
+
const svc = container[MY_MODULE] // ❌ undefined — not a cradle
|
|
435
|
+
}
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
**When passing the application container to a provider method, the provider must use `.resolve()` — not bracket access:**
|
|
439
|
+
|
|
440
|
+
```typescript
|
|
441
|
+
import type { AcmeKitContainer } from "@acmekit/framework/types"
|
|
442
|
+
|
|
443
|
+
class MyProvider {
|
|
444
|
+
async doWork(request: Request, appContainer?: AcmeKitContainer) {
|
|
445
|
+
// appContainer is from the workflow step — use .resolve()
|
|
446
|
+
const otherService = appContainer?.resolve<IOtherService>(OTHER_MODULE) // ✅
|
|
447
|
+
const otherService = appContainer?.[OTHER_MODULE] // ❌ undefined!
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
#### Simple Providers — No Cross-Module Dependencies
|
|
453
|
+
|
|
454
|
+
When a provider's job is a single external operation (send email, upload file, call API), it needs only `this.options_` and sibling registrations. No special handling required.
|
|
455
|
+
|
|
456
|
+
```typescript
|
|
457
|
+
class SendGridNotification extends AbstractNotificationProviderService {
|
|
458
|
+
static identifier = "sendgrid"
|
|
459
|
+
|
|
460
|
+
async send(notification) {
|
|
461
|
+
// Only needs this.options_.apiKey — no other modules
|
|
462
|
+
return this.client_.send({ to: notification.to, template: notification.template })
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
#### Complex Providers — Cross-Module Dependencies via Method Parameters
|
|
468
|
+
|
|
469
|
+
Some providers need other modules to do their job — e.g., a chain relayer provider that calls external APIs but also manages chain-specific DB state in a companion module. The provider can't resolve the companion module from `this.container_` (module-scoped), and splitting into workflow steps isn't always possible (the calling workflow belongs to a different plugin/package).
|
|
470
|
+
|
|
471
|
+
**The correct pattern: the workflow step passes the `AcmeKitContainer` through the parent module's service method to the provider. The provider uses `.resolve()` on the passed container — never bracket access.**
|
|
472
|
+
|
|
473
|
+
```typescript
|
|
474
|
+
import type { AcmeKitContainer } from "@acmekit/framework/types"
|
|
475
|
+
|
|
476
|
+
// 1. Workflow step — passes the full application container
|
|
477
|
+
export const executeOnChainStep = createStep(
|
|
478
|
+
{ name: "execute-on-chain-step", noCompensation: true },
|
|
479
|
+
async ({ request }, { container }) => {
|
|
480
|
+
const relayerService = container.resolve<IRelayerModuleService>(RELAYER_MODULE)
|
|
481
|
+
// Pass container (AcmeKitContainer) — provider will .resolve() what it needs
|
|
482
|
+
const result = await relayerService.relay(request, container)
|
|
483
|
+
return new StepResponse(result)
|
|
484
|
+
},
|
|
485
|
+
async () => {}
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
// 2. Parent module service — forwards AcmeKitContainer to the provider
|
|
489
|
+
class RelayerModuleService {
|
|
490
|
+
@InjectManager()
|
|
491
|
+
async relay(
|
|
492
|
+
request: RelayRequest,
|
|
493
|
+
appContainer?: AcmeKitContainer,
|
|
494
|
+
@AcmeKitContext() sharedContext: Context = {}
|
|
495
|
+
) {
|
|
496
|
+
return await this.relay_(request, appContainer, sharedContext)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
@InjectTransactionManager()
|
|
500
|
+
protected async relay_(
|
|
501
|
+
request: RelayRequest,
|
|
502
|
+
appContainer?: AcmeKitContainer,
|
|
503
|
+
@AcmeKitContext() sharedContext: Context = {}
|
|
504
|
+
) {
|
|
505
|
+
const provider = this.resolveChainRelayer(request.chain)
|
|
506
|
+
const wallet = await this.selectWallet_(request.chain, sharedContext)
|
|
507
|
+
return await provider.relay({ ...request, wallet }, appContainer)
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// 3. Provider — uses .resolve() on the passed AcmeKitContainer
|
|
512
|
+
class TronChainRelayer implements IChainRelayer {
|
|
513
|
+
static identifier = "tron-chain-relayer"
|
|
514
|
+
|
|
515
|
+
async relay(request: RelayRequest, appContainer?: AcmeKitContainer): Promise<RelayResult> {
|
|
516
|
+
// .resolve() with optional chaining — NOT bracket access
|
|
517
|
+
const tronService = appContainer?.resolve<ITronModuleService>(TRON_MODULE)
|
|
518
|
+
const delegation = await tronService.createDelegationRecords([...])
|
|
519
|
+
// this.options_ for own config (module-scoped — always available)
|
|
520
|
+
const client = new TronWeb({ fullHost: this.options_.fullNodeUrl })
|
|
521
|
+
const txResult = await client.broadcast(request.payload)
|
|
522
|
+
await tronService.reconcileDelegation(delegation.id, txResult)
|
|
523
|
+
return txResult
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
**Key constraints:**
|
|
529
|
+
- Provider constructor container (`this.container_`) is a **cradle** — property access only, ONLY for sibling registrations
|
|
530
|
+
- The `AcmeKitContainer` passed as a method parameter uses **`.resolve()`** — never bracket access
|
|
531
|
+
- The `appContainer` parameter is optional (`?`) — the provider interface uses the looser `Record<string, unknown>` type; implementations narrow to `AcmeKitContainer`
|
|
532
|
+
- The workflow step is the only place that has the full `AcmeKitContainer`
|
|
533
|
+
- Import: `import type { AcmeKitContainer } from "@acmekit/framework/types"`
|
|
534
|
+
|
|
535
|
+
**When to use which tier:**
|
|
536
|
+
|
|
537
|
+
| Provider type | Cross-module deps? | Pattern |
|
|
538
|
+
|---|---|---|
|
|
539
|
+
| File storage, notifications, SMS | No | Use `this.options_` and `this.container_` only |
|
|
540
|
+
| Payment processors, chain relayers | Yes — companion modules for state/records | Receive deps as method params from workflow step |
|
|
541
|
+
|
|
399
542
|
### Module() vs ModuleProvider()
|
|
400
543
|
|
|
401
544
|
| | `Module()` | `ModuleProvider()` |
|
|
@@ -590,6 +733,32 @@ class MyProvider {
|
|
|
590
733
|
}
|
|
591
734
|
}
|
|
592
735
|
|
|
736
|
+
// WRONG — provider resolving another module from its own container (cradle)
|
|
737
|
+
// Provider container is scoped to the PARENT module — other modules are not visible
|
|
738
|
+
class TronRelayer implements IChainRelayer {
|
|
739
|
+
async relay(request: RelayRequest) {
|
|
740
|
+
const tronService = this.container_[TRON_MODULE] // ❌ undefined — not in module scope
|
|
741
|
+
await tronService.createDelegationRecords([...]) // crashes
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// WRONG — using bracket access on AcmeKitContainer (it's NOT a cradle)
|
|
746
|
+
class TronRelayer implements IChainRelayer {
|
|
747
|
+
async relay(request: RelayRequest, appContainer?: AcmeKitContainer) {
|
|
748
|
+
const svc = appContainer[TRON_MODULE] // ❌ undefined — wrong access pattern!
|
|
749
|
+
const svc2 = appContainer[TRON_MODULE] as any // ❌ same bug, hidden by cast
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// RIGHT — use .resolve() on AcmeKitContainer passed from workflow step
|
|
754
|
+
class TronRelayer implements IChainRelayer {
|
|
755
|
+
async relay(request: RelayRequest, appContainer?: AcmeKitContainer) {
|
|
756
|
+
const tronService = appContainer?.resolve<ITronModuleService>(TRON_MODULE) // ✅
|
|
757
|
+
await tronService.createDelegationRecords([...])
|
|
758
|
+
return await this.client_.broadcast(request.payload)
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
593
762
|
// WRONG — hardcoding prefix/identifiersKey inline in multiple files
|
|
594
763
|
// A typo between createProvidersLoader and AcmeKitService silently breaks resolution
|
|
595
764
|
createProvidersLoader({ prefix: "cr_", identifiersKey: "chain_relayer_providers_identifier" })
|
|
@@ -8,7 +8,30 @@ paths:
|
|
|
8
8
|
|
|
9
9
|
# Testing Rules
|
|
10
10
|
|
|
11
|
-
##
|
|
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
|
|
|
@@ -931,6 +954,25 @@ Routes under `/client/*` use `AcmeKitTypedRequest` (non-authenticated), but they
|
|
|
931
954
|
|
|
932
955
|
---
|
|
933
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
|
+
|
|
934
976
|
## Anti-Patterns — NEVER Do These
|
|
935
977
|
|
|
936
978
|
```typescript
|
|
@@ -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
|