@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
@@ -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
- **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, route paths, validators, and response shapes
14
- 3. Identify the correct test tier (HTTP, app, 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. **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
 
@@ -450,29 +464,23 @@ describe("MyProvider", () => {
450
464
 
451
465
  ---
452
466
 
453
- ## Asserting Domain Events (module mode)
454
-
455
- ```typescript
456
- import { MockEventBusService } from "@acmekit/test-utils"
457
-
458
- let eventBusSpy: jest.SpyInstance
467
+ ## Asserting Domain Events
459
468
 
460
- beforeEach(() => {
461
- eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit")
462
- })
469
+ `MockEventBusService` accumulates emitted events — use `.getEmittedEvents()` directly, no spy needed.
463
470
 
464
- afterEach(() => {
465
- eventBusSpy.mockClear()
466
- })
471
+ ```typescript
472
+ import { Modules } from "@acmekit/framework/utils"
467
473
 
468
474
  it("should emit post.created event", async () => {
469
475
  await service.createPosts([{ title: "Event Test" }])
470
476
 
471
- const events = eventBusSpy.mock.calls[0][0]
477
+ // Access events directly from the mock event bus — no spy needed
478
+ const eventBus = getContainer().resolve(Modules.EVENT_BUS)
479
+ const events = eventBus.getEmittedEvents()
472
480
  expect(events).toEqual(
473
481
  expect.arrayContaining([
474
482
  expect.objectContaining({
475
- name: "post.created",
483
+ name: "post.created", // NOTE: property is "name", NOT "eventName"
476
484
  data: expect.objectContaining({ id: expect.any(String) }),
477
485
  }),
478
486
  ])
@@ -480,6 +488,8 @@ it("should emit post.created event", async () => {
480
488
  })
481
489
  ```
482
490
 
491
+ > `emitEventStep` maps `eventName` to `name` internally — always assert on `event.name`.
492
+
483
493
  ---
484
494
 
485
495
  ## What to Test
@@ -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" })