@acmekit/acmekit 2.13.88 → 2.13.90

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.
@@ -420,6 +420,8 @@ File: `src/admin/routes/custom/[id]/edit/page.tsx`
420
420
 
421
421
  **Critical structure**: Use `RouteDrawer.Form` wrapping `KeyboundForm` — this handles dirty form blocking on close and Cmd/Ctrl+Enter submit. The `KeyboundForm` wraps BOTH `RouteDrawer.Body` AND `RouteDrawer.Footer` so `type="submit"` works. Use `useRouteModal()` for `handleSuccess` to close the modal after success.
422
422
 
423
+ **IMPORTANT**: `useRouteModal()` must be called in a **child component** rendered inside `<RouteDrawer>`, not in the same component that renders it. The `RouteModalProvider` context is created inside `RouteDrawer`, so calling the hook in the parent component crashes with "useRouteModal must be used within a RouteModalProvider".
424
+
423
425
  ```tsx
424
426
  import { Button, RouteDrawer, Form, Heading, Input, Textarea, toast, KeyboundForm, useRouteModal } from "@acmekit/ui"
425
427
  import { useForm } from "react-hook-form"
@@ -434,39 +436,54 @@ const EditSchema = zod.object({
434
436
  description: zod.string().optional(),
435
437
  })
436
438
 
439
+ // Page component — renders RouteDrawer, delegates form to child
437
440
  const EditPostPage = () => {
438
441
  const { id } = useParams()
439
- const queryClient = useQueryClient()
440
- const { handleSuccess } = useRouteModal()
441
442
 
442
443
  const { data, isLoading, isError, error } = useQuery({
443
444
  queryKey: ["posts", id],
444
445
  queryFn: () => sdk.admin.fetch("/posts/:id", { method: "GET", params: { id: id! } }),
445
446
  })
446
447
 
447
- // Throw query errors to error boundary
448
448
  if (isError) {
449
449
  throw error
450
450
  }
451
451
 
452
+ return (
453
+ <RouteDrawer>
454
+ <RouteDrawer.Header>
455
+ <Heading>Edit Post</Heading>
456
+ </RouteDrawer.Header>
457
+ {!isLoading && data?.post && (
458
+ <EditPostForm post={data.post} />
459
+ )}
460
+ </RouteDrawer>
461
+ )
462
+ }
463
+
464
+ // Form component — useRouteModal() is valid here (inside RouteDrawer)
465
+ const EditPostForm = ({ post }: { post: { id: string; title: string; description?: string } }) => {
466
+ const queryClient = useQueryClient()
467
+ const { handleSuccess } = useRouteModal()
468
+
452
469
  const form = useForm<zod.infer<typeof EditSchema>>({
453
470
  defaultValues: {
454
- title: data?.post?.title ?? "",
455
- description: data?.post?.description ?? "",
471
+ title: post.title ?? "",
472
+ description: post.description ?? "",
456
473
  },
457
474
  resolver: zodResolver(EditSchema),
458
475
  })
459
476
 
460
477
  const { mutateAsync, isPending } = useMutation({
461
478
  mutationFn: (payload: zod.infer<typeof EditSchema>) =>
462
- sdk.admin.fetch("/posts/:id", { method: "POST", params: { id: id! }, body: payload }),
479
+ sdk.admin.fetch("/posts/:id", { method: "POST", params: { id: post.id }, body: payload }),
463
480
  })
464
481
 
465
482
  const handleSubmit = form.handleSubmit(async (values) => {
466
483
  await mutateAsync(values, {
467
484
  onSuccess: () => {
468
485
  queryClient.invalidateQueries({ queryKey: ["posts"] })
469
- queryClient.invalidateQueries({ queryKey: ["posts", id] })
486
+ queryClient.invalidateQueries({ queryKey: ["posts", post.id] })
470
487
  toast.success("Post updated")
471
488
  handleSuccess()
472
489
  },
@@ -475,57 +492,50 @@ const EditPostPage = () => {
475
492
  })
476
493
 
477
494
  return (
478
- <RouteDrawer>
479
- <RouteDrawer.Header>
480
- <Heading>Edit Post</Heading>
481
- </RouteDrawer.Header>
482
- {!isLoading && data?.post && (
483
- <RouteDrawer.Form form={form}>
484
- <KeyboundForm onSubmit={handleSubmit} className="flex flex-1 flex-col">
485
- <RouteDrawer.Body>
486
- <div className="flex flex-col gap-y-4">
487
- <Form.Field
488
- control={form.control}
489
- name="title"
490
- render={({ field }) => (
491
- <Form.Item>
492
- <Form.Label>Title</Form.Label>
493
- <Form.Control>
494
- <Input autoComplete="off" {...field} />
495
- </Form.Control>
496
- <Form.ErrorMessage />
497
- </Form.Item>
498
- )}
499
- />
500
- <Form.Field
501
- control={form.control}
502
- name="description"
503
- render={({ field }) => (
504
- <Form.Item>
505
- <Form.Label optional>Description</Form.Label>
506
- <Form.Control>
507
- <Textarea {...field} />
508
- </Form.Control>
509
- <Form.ErrorMessage />
510
- </Form.Item>
511
- )}
512
- />
513
- </div>
514
- </RouteDrawer.Body>
515
- <RouteDrawer.Footer>
516
- <div className="flex items-center justify-end gap-x-2">
517
- <RouteDrawer.Close asChild>
518
- <Button size="small" variant="secondary">Cancel</Button>
519
- </RouteDrawer.Close>
520
- <Button size="small" type="submit" isLoading={isPending}>
521
- Save
522
- </Button>
523
- </div>
524
- </RouteDrawer.Footer>
525
- </KeyboundForm>
526
- </RouteDrawer.Form>
527
- )}
528
- </RouteDrawer>
495
+ <RouteDrawer.Form form={form}>
496
+ <KeyboundForm onSubmit={handleSubmit} className="flex flex-1 flex-col">
497
+ <RouteDrawer.Body>
498
+ <div className="flex flex-col gap-y-4">
499
+ <Form.Field
500
+ control={form.control}
501
+ name="title"
502
+ render={({ field }) => (
503
+ <Form.Item>
504
+ <Form.Label>Title</Form.Label>
505
+ <Form.Control>
506
+ <Input autoComplete="off" {...field} />
507
+ </Form.Control>
508
+ <Form.ErrorMessage />
509
+ </Form.Item>
510
+ )}
511
+ />
512
+ <Form.Field
513
+ control={form.control}
514
+ name="description"
515
+ render={({ field }) => (
516
+ <Form.Item>
517
+ <Form.Label optional>Description</Form.Label>
518
+ <Form.Control>
519
+ <Textarea {...field} />
520
+ </Form.Control>
521
+ <Form.ErrorMessage />
522
+ </Form.Item>
523
+ )}
524
+ />
525
+ </div>
526
+ </RouteDrawer.Body>
527
+ <RouteDrawer.Footer>
528
+ <div className="flex items-center justify-end gap-x-2">
529
+ <RouteDrawer.Close asChild>
530
+ <Button size="small" variant="secondary">Cancel</Button>
531
+ </RouteDrawer.Close>
532
+ <Button size="small" type="submit" isLoading={isPending}>
533
+ Save
534
+ </Button>
535
+ </div>
536
+ </RouteDrawer.Footer>
537
+ </KeyboundForm>
538
+ </RouteDrawer.Form>
529
539
  )
530
540
  }
531
541
 
@@ -553,6 +563,8 @@ File: `src/admin/routes/custom/create/page.tsx`
553
563
 
554
564
  **Critical structure**: Use `RouteFocusModal.Form` wrapping `KeyboundForm` — this handles dirty form blocking on close and Cmd/Ctrl+Enter submit. The `KeyboundForm` wraps Header + Body + Footer. Use `useRouteModal()` for `handleSuccess` to close the modal after success.
555
565
 
566
+ **IMPORTANT**: `useRouteModal()` must be called in a **child component** rendered inside `<RouteFocusModal>`, not in the same component that renders it. The `RouteModalProvider` context is created inside `RouteFocusModal`, so calling the hook in the parent component crashes with "useRouteModal must be used within a RouteModalProvider".
567
+
556
568
  ```tsx
557
569
  import { Button, RouteFocusModal, Form, Heading, Input, Text, Textarea, toast, KeyboundForm, useRouteModal } from "@acmekit/ui"
558
570
  import { useForm } from "react-hook-form"
@@ -566,7 +578,17 @@ const CreateSchema = zod.object({
566
578
  description: zod.string().optional(),
567
579
  })
568
580
 
581
+ // Page component — renders RouteFocusModal, delegates form to child
569
582
  const CreatePostPage = () => {
583
+ return (
584
+ <RouteFocusModal>
585
+ <CreatePostForm />
586
+ </RouteFocusModal>
587
+ )
588
+ }
589
+
590
+ // Form component — useRouteModal() is valid here (inside RouteFocusModal)
591
+ const CreatePostForm = () => {
570
592
  const queryClient = useQueryClient()
571
593
  const { handleSuccess } = useRouteModal()
572
594
 
@@ -592,61 +614,59 @@ const CreatePostPage = () => {
592
614
  })
593
615
 
594
616
  return (
595
- <RouteFocusModal>
596
- <RouteFocusModal.Form form={form}>
597
- <KeyboundForm onSubmit={handleSubmit} className="flex flex-1 flex-col overflow-hidden">
598
- <RouteFocusModal.Header />
599
- <RouteFocusModal.Body className="flex flex-1 flex-col items-center overflow-y-auto py-16">
600
- <div className="flex w-full max-w-[720px] flex-col gap-y-8">
601
- <div>
602
- <Heading>Create Post</Heading>
603
- <Text size="small" className="text-ui-fg-subtle">
604
- Add a new blog post.
605
- </Text>
606
- </div>
607
- <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
608
- <Form.Field
609
- control={form.control}
610
- name="title"
611
- render={({ field }) => (
612
- <Form.Item>
613
- <Form.Label>Title</Form.Label>
614
- <Form.Control>
615
- <Input autoComplete="off" {...field} />
616
- </Form.Control>
617
- <Form.ErrorMessage />
618
- </Form.Item>
619
- )}
620
- />
621
- <Form.Field
622
- control={form.control}
623
- name="description"
624
- render={({ field }) => (
625
- <Form.Item>
626
- <Form.Label optional>Description</Form.Label>
627
- <Form.Control>
628
- <Textarea {...field} />
629
- </Form.Control>
630
- <Form.ErrorMessage />
631
- </Form.Item>
632
- )}
633
- />
634
- </div>
617
+ <RouteFocusModal.Form form={form}>
618
+ <KeyboundForm onSubmit={handleSubmit} className="flex flex-1 flex-col overflow-hidden">
619
+ <RouteFocusModal.Header />
620
+ <RouteFocusModal.Body className="flex flex-1 flex-col items-center overflow-y-auto py-16">
621
+ <div className="flex w-full max-w-[720px] flex-col gap-y-8">
622
+ <div>
623
+ <Heading>Create Post</Heading>
624
+ <Text size="small" className="text-ui-fg-subtle">
625
+ Add a new blog post.
626
+ </Text>
635
627
  </div>
636
- </RouteFocusModal.Body>
637
- <RouteFocusModal.Footer>
638
- <div className="flex items-center justify-end gap-x-2">
639
- <RouteFocusModal.Close asChild>
640
- <Button size="small" variant="secondary">Cancel</Button>
641
- </RouteFocusModal.Close>
642
- <Button size="small" type="submit" isLoading={isPending}>
643
- Create
644
- </Button>
628
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
629
+ <Form.Field
630
+ control={form.control}
631
+ name="title"
632
+ render={({ field }) => (
633
+ <Form.Item>
634
+ <Form.Label>Title</Form.Label>
635
+ <Form.Control>
636
+ <Input autoComplete="off" {...field} />
637
+ </Form.Control>
638
+ <Form.ErrorMessage />
639
+ </Form.Item>
640
+ )}
641
+ />
642
+ <Form.Field
643
+ control={form.control}
644
+ name="description"
645
+ render={({ field }) => (
646
+ <Form.Item>
647
+ <Form.Label optional>Description</Form.Label>
648
+ <Form.Control>
649
+ <Textarea {...field} />
650
+ </Form.Control>
651
+ <Form.ErrorMessage />
652
+ </Form.Item>
653
+ )}
654
+ />
645
655
  </div>
646
- </RouteFocusModal.Footer>
647
- </KeyboundForm>
648
- </RouteFocusModal.Form>
649
- </RouteFocusModal>
656
+ </div>
657
+ </RouteFocusModal.Body>
658
+ <RouteFocusModal.Footer>
659
+ <div className="flex items-center justify-end gap-x-2">
660
+ <RouteFocusModal.Close asChild>
661
+ <Button size="small" variant="secondary">Cancel</Button>
662
+ </RouteFocusModal.Close>
663
+ <Button size="small" type="submit" isLoading={isPending}>
664
+ Create
665
+ </Button>
666
+ </div>
667
+ </RouteFocusModal.Footer>
668
+ </KeyboundForm>
669
+ </RouteFocusModal.Form>
650
670
  )
651
671
  }
652
672
 
@@ -313,6 +313,7 @@ const confirmed = await prompt({
313
313
  **Imports & dependencies:**
314
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
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
+ - Installing `@hookform/resolvers@5.x` — this version requires Zod v4 (`zod/v4/core`), but AcmeKit ships Zod v3. Causes `Could not resolve "zod/v4/core"` at build time. Never install your own version — use what AcmeKit provides
316
317
 
317
318
  **Form context:**
318
319
  - 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`
@@ -326,6 +327,7 @@ const confirmed = await prompt({
326
327
  - Using `onClick={handleSubmit}` on submit button — use `type="submit"` inside a `KeyboundForm` tag
327
328
  - Missing `className="flex flex-1 flex-col"` on the `KeyboundForm` tag (breaks layout)
328
329
  - Missing `overflow-hidden` on RouteFocusModal's `KeyboundForm` tag
330
+ - Calling `useRouteModal()` in the same component that renders `RouteFocusModal`/`RouteDrawer` — the hook must be in a **child component** rendered inside the modal/drawer (the provider mounts inside it)
329
331
  - Using `navigate("..")` to close overlays instead of `handleSuccess()` from `useRouteModal()`
330
332
  - Putting footer buttons directly in Footer — wrap in `<div className="flex items-center justify-end gap-x-2">`
331
333
  - Using grid layout in Drawer forms — drawers are narrow, use single column `flex flex-col gap-y-4`
@@ -867,7 +867,17 @@ import { sdk } from "../../../../lib/sdk"
867
867
  type Currency = { code: string; name: string }
868
868
  const columnHelper = createDataTableColumnHelper<Currency>()
869
869
 
870
+ // Page component — renders RouteFocusModal, delegates form to child
870
871
  const AddCurrenciesPage = () => {
872
+ return (
873
+ <RouteFocusModal>
874
+ <AddCurrenciesForm />
875
+ </RouteFocusModal>
876
+ )
877
+ }
878
+
879
+ // Form component — useRouteModal() is valid here (inside RouteFocusModal)
880
+ const AddCurrenciesForm = () => {
871
881
  const queryClient = useQueryClient()
872
882
  const { handleSuccess } = useRouteModal()
873
883
 
@@ -924,37 +934,35 @@ const AddCurrenciesPage = () => {
924
934
  })
925
935
 
926
936
  return (
927
- <RouteFocusModal>
928
- <RouteFocusModal.Form form={form}>
929
- <KeyboundForm onSubmit={handleSubmit} className="flex h-full flex-col overflow-hidden">
930
- <RouteFocusModal.Header>
931
- <div className="flex flex-1 items-center justify-between">
932
- {form.formState.errors.currencies && (
933
- <Hint variant="error">{form.formState.errors.currencies.message}</Hint>
934
- )}
935
- </div>
936
- </RouteFocusModal.Header>
937
- <RouteFocusModal.Body className="flex flex-1 flex-col overflow-hidden">
938
- <DataTable instance={table}>
939
- <DataTable.Table emptyState={{
940
- empty: { heading: "No currencies available" },
941
- }} />
942
- <DataTable.Pagination />
943
- </DataTable>
944
- </RouteFocusModal.Body>
945
- <RouteFocusModal.Footer>
946
- <div className="flex items-center justify-end gap-x-2">
947
- <RouteFocusModal.Close asChild>
948
- <Button size="small" variant="secondary">Cancel</Button>
949
- </RouteFocusModal.Close>
950
- <Button size="small" type="submit" isLoading={isPending}>
951
- Save
952
- </Button>
953
- </div>
954
- </RouteFocusModal.Footer>
955
- </KeyboundForm>
956
- </RouteFocusModal.Form>
957
- </RouteFocusModal>
937
+ <RouteFocusModal.Form form={form}>
938
+ <KeyboundForm onSubmit={handleSubmit} className="flex h-full flex-col overflow-hidden">
939
+ <RouteFocusModal.Header>
940
+ <div className="flex flex-1 items-center justify-between">
941
+ {form.formState.errors.currencies && (
942
+ <Hint variant="error">{form.formState.errors.currencies.message}</Hint>
943
+ )}
944
+ </div>
945
+ </RouteFocusModal.Header>
946
+ <RouteFocusModal.Body className="flex flex-1 flex-col overflow-hidden">
947
+ <DataTable instance={table}>
948
+ <DataTable.Table emptyState={{
949
+ empty: { heading: "No currencies available" },
950
+ }} />
951
+ <DataTable.Pagination />
952
+ </DataTable>
953
+ </RouteFocusModal.Body>
954
+ <RouteFocusModal.Footer>
955
+ <div className="flex items-center justify-end gap-x-2">
956
+ <RouteFocusModal.Close asChild>
957
+ <Button size="small" variant="secondary">Cancel</Button>
958
+ </RouteFocusModal.Close>
959
+ <Button size="small" type="submit" isLoading={isPending}>
960
+ Save
961
+ </Button>
962
+ </div>
963
+ </RouteFocusModal.Footer>
964
+ </KeyboundForm>
965
+ </RouteFocusModal.Form>
958
966
  )
959
967
  }
960
968
 
@@ -422,6 +422,34 @@ const SettingsPage = () => {
422
422
  }
423
423
  ```
424
424
 
425
+ ### Never Install react-hook-form, @hookform/resolvers, or zod Directly
426
+
427
+ These packages are **provided by AcmeKit** at specific pinned versions. Installing your own version causes version conflicts:
428
+
429
+ - `@hookform/resolvers@5.x` requires `zod/v4/core` (Zod v4) — AcmeKit ships Zod v3 → **build fails** with `Could not resolve "zod/v4/core"`
430
+ - `react-hook-form` dual instances → `useFormContext()` returns `null` → form fields crash
431
+ - Bare `zod` import → **build fails** in plugins with `Could not resolve "zod"`
432
+
433
+ **For plugins**: `react-hook-form` and `@hookform/resolvers` go in `peerDependencies` + `devDependencies` only (never `dependencies`). Match the host app's versions.
434
+
435
+ **For apps**: Do not add these packages at all — they come from `@acmekit/ui`.
436
+
437
+ **Imports**:
438
+ - Admin UI: `import * as zod from "@acmekit/deps/zod"` — never bare `"zod"`
439
+ - Backend: `import { z } from "@acmekit/framework/zod"` — never bare `"zod"`
440
+ - `useForm` from `react-hook-form` and `zodResolver` from `@hookform/resolvers/zod` — these are provided by the host
441
+
442
+ ### Vite "Outdated Optimize Dep" 504 Errors
443
+
444
+ After changing plugin dependencies, Vite's pre-bundled dependency cache becomes stale. The browser shows `504 (Outdated Optimize Dep)` for multiple modules.
445
+
446
+ **Fix**: Stop the dev server, delete `.vite/deps/` in the host app's `node_modules/`, and restart:
447
+
448
+ ```bash
449
+ rm -rf node_modules/.vite/deps
450
+ # restart dev server
451
+ ```
452
+
425
453
  ---
426
454
 
427
455
  ## Key Rules
@@ -42,7 +42,7 @@ Co-locate validation, query config, response schemas, rate limiting, and RBAC po
42
42
  When extending generated CRUD routes, keep the existing `query-config.ts` / `helpers.ts` structure and only adjust fields, filters, workflow calls, or refetch logic.
43
43
 
44
44
  ```typescript
45
- import { z } from "zod"
45
+ import { z } from "@acmekit/framework/zod"
46
46
  import {
47
47
  defineRouteConfig,
48
48
  AuthenticatedAcmeKitTypedRequest,
@@ -532,8 +532,9 @@ createProductsWorkflow.hooks.productsCreated(
532
532
 
533
533
  ## Zod Import Note
534
534
 
535
- - In route files (`defineRouteConfig`): `import { z } from "zod"` works
536
- - In `middlewares.ts` (`additionalDataValidator`): use `import { z } from "@acmekit/framework/zod"`
535
+ - **Always** use `import { z } from "@acmekit/framework/zod"` — never bare `"zod"`
536
+ - Bare `"zod"` may resolve in apps but **fails in plugins** (zod is not a direct dependency)
537
+ - This applies to both route files and `middlewares.ts`
537
538
 
538
539
  ## Common Imports
539
540
 
@@ -546,8 +547,7 @@ import {
546
547
  defineMiddlewares,
547
548
  AcmeKitNextFunction,
548
549
  } from "@acmekit/framework/http"
549
- import { z } from "zod"
550
- import { z as frameworkZod } from "@acmekit/framework/zod" // for middlewares.ts
550
+ import { z } from "@acmekit/framework/zod"
551
551
  import { ContainerRegistrationKeys, AcmeKitError } from "@acmekit/framework/utils"
552
552
  ```
553
553
 
@@ -737,11 +737,10 @@ export const POST = async (req, res) => {
737
737
  // RIGHT — use :param format
738
738
  { matcher: "/custom/:id", middlewares: [...] } // ✅
739
739
 
740
- // WRONG — using bare zod in middlewares.ts
741
- import { z } from "zod" // ❌ in middlewares.ts
742
- additionalDataValidator: { brand: z.string() }
740
+ // WRONG — bare "zod" import (fails in plugins, not a direct dependency)
741
+ import { z } from "zod" // ❌ never use bare zod
743
742
 
744
- // RIGHT — use framework zod in middlewares.ts
743
+ // RIGHT — always use framework re-export
745
744
  import { z } from "@acmekit/framework/zod" // ✅
746
745
 
747
746
  // WRONG — no .strict() on body schema (allows extra fields through)
@@ -772,4 +771,26 @@ const { enabled, ...filters } = req.validatedQuery // ❌ crashes if request ha
772
771
 
773
772
  // RIGHT — always provide fallback when destructuring validatedQuery
774
773
  const { enabled, ...filters } = req.validatedQuery ?? {} // ✅ safe when no query params
774
+
775
+ // WRONG — seeding/syncing data inside a route handler (runs on every request)
776
+ export const GET = async (req, res) => {
777
+ const service = req.scope.resolve(MY_MODULE)
778
+ await service.syncProviders() // ❌ data mutation on every GET — slow, side-effectful
779
+ const data = await query.graph({ entity: "my_entity" })
780
+ res.json({ items: data })
781
+ }
782
+
783
+ // RIGHT — routes are pure query/mutation handlers, no seeding or syncing
784
+ // Seed data via: createProvidersLoader onRegistered, onPluginStart, or scripts
785
+ export const GET = async (req, res) => {
786
+ const data = await query.graph({ entity: "my_entity" })
787
+ res.json({ items: data }) // ✅ just reads data
788
+ }
789
+
790
+ // WRONG — importing service class directly in route file for type
791
+ import type MyModuleService from "../../../modules/my/service" // ❌ tight coupling
792
+ const svc = req.scope.resolve<MyModuleService>(MY_MODULE)
793
+
794
+ // RIGHT — resolve via module constant, type-cast loosely if needed
795
+ const svc: any = req.scope.resolve(MY_MODULE) // ✅
775
796
  ```
@@ -192,7 +192,7 @@ export default class MyModuleService {
192
192
 
193
193
  **When to use loaders:** Register external connections at startup; validate required options (throw on missing to prevent startup). Loaders are idempotent — check `container.hasRegistration("key")` before re-registering.
194
194
 
195
- **Don't use loaders for:** Scheduled tasks → use jobs. Event-triggered logic → use subscribers.
195
+ **Don't use loaders for:** Scheduled tasks → use jobs. Event-triggered logic → use subscribers. Database mutations (INSERT/UPDATE/DELETE) → use scripts, subscribers, or seed data (exception: `onRegistered` in `createProvidersLoader` is designed for DB seeding via service methods). Never resolve `PG_CONNECTION` for raw SQL in loaders — use service methods instead.
196
196
 
197
197
  ## Module Options Validation
198
198
 
@@ -262,6 +262,73 @@ export default Module(RELAYER_MODULE, {
262
262
  })
263
263
  ```
264
264
 
265
+ ### createProvidersLoader — Full API
266
+
267
+ `createProvidersLoader` accepts a `ProvidersConfig` object with these options:
268
+
269
+ | Option | Required | Default | Purpose |
270
+ |--------|----------|---------|---------|
271
+ | `prefix` | Yes | — | Container registration key prefix (e.g., `"pp_"`, `"cr_"`) |
272
+ | `identifiersKey` | Yes | — | Key for tracking registered provider IDs |
273
+ | `moduleName` | No | `"module"` | Label for log/error messages |
274
+ | `keyFormat` | No | `"prefixId"` | `"prefixId"` → key=`{prefix}{id}`, or `"prefixIdentifierId"` → key=`{prefix}{identifier}_{id}` |
275
+ | `defaults` | No | `[]` | Built-in providers shipped with the module (see below) |
276
+ | `defaultProviderKey` | No | — | Container key to register the elected default provider ID |
277
+ | `requireDefault` | No | `false` | Throw at startup if no default provider is found |
278
+ | `onRegistered` | No | — | Async hook called after all providers are registered |
279
+
280
+ **`defaults`** — each entry is a `DefaultProviderConfig`:
281
+
282
+ ```typescript
283
+ {
284
+ service: ProviderClass, // must have static `identifier`
285
+ id: string, // registration ID
286
+ options?: Record | ((moduleOptions) => Record), // static or derived from module options
287
+ condition?: (moduleOptions) => boolean, // skip this default if returns false
288
+ }
289
+ ```
290
+
291
+ **`onRegistered`** — receives `{ container, providers, registeredDefaults }`:
292
+
293
+ ```typescript
294
+ onRegistered: async ({ container, providers, registeredDefaults }) => {
295
+ // container: module-scoped AcmeKitContainer (module's own service is resolvable here)
296
+ // providers: user-configured ModuleProvider[] from config
297
+ // registeredDefaults: Array<{ id, options }> — only defaults that passed condition
298
+ }
299
+ ```
300
+
301
+ **Example with defaults and onRegistered** (from core modules):
302
+
303
+ ```typescript
304
+ createProvidersLoader({
305
+ prefix: PAYMENT_PROVIDER_PREFIX,
306
+ identifiersKey: PAYMENT_PROVIDER_IDENTIFIER_KEY,
307
+ moduleName: PAYMENT_MODULE,
308
+ defaults: [
309
+ {
310
+ service: SystemProvider,
311
+ id: "system",
312
+ condition: (opts) => opts.enableSystemProvider !== false,
313
+ },
314
+ ],
315
+ onRegistered: async ({ container, registeredDefaults }) => {
316
+ // Sync DB rows for registered providers via service methods
317
+ const service: any = container.resolve(PAYMENT_MODULE)
318
+ for (const provider of registeredDefaults) {
319
+ const existing = await service.listPaymentProviders({ id: [provider.id] })
320
+ if (!existing.length) {
321
+ await service.createPaymentProviders([
322
+ { id: provider.id, is_enabled: true },
323
+ ])
324
+ }
325
+ }
326
+ },
327
+ })
328
+ ```
329
+
330
+ **Important**: Even inside `onRegistered`, always use service methods for DB mutations — never raw SQL via `PG_CONNECTION`.
331
+
265
332
  ### Service (resolving providers)
266
333
 
267
334
  Use the leading `TProvider` generic to get a typed `this.providers` registry.
@@ -767,4 +834,54 @@ AcmeKitService({ providers: { prefix: "cr_", identifiersKey: "chain_relayer_prov
767
834
 
768
835
  // RIGHT — define constants once in constants.ts, import everywhere
769
836
  import { CHAIN_RELAYER_PROVIDER_PREFIX, CHAIN_RELAYER_IDENTIFIER_KEY } from "./constants"
837
+
838
+ // WRONG — using raw SQL / PG_CONNECTION for data mutations
839
+ const pgConnection = container.resolve(ContainerRegistrationKeys.PG_CONNECTION)
840
+ await pgConnection.raw(
841
+ `INSERT INTO payment_provider (id) VALUES (?)`, // ❌ bypasses ORM, service layer, events
842
+ [id]
843
+ )
844
+
845
+ // RIGHT — use the module's service methods for all data mutations
846
+ const service = container.resolve(PAYMENT_MODULE)
847
+ await service.createPaymentProviders([{ id: "pp_system", is_enabled: true }]) // ✅
848
+
849
+ // WRONG — using raw SQL in onRegistered to seed provider rows
850
+ onRegistered: async ({ container, registeredDefaults }) => {
851
+ const pg = container.resolve(ContainerRegistrationKeys.PG_CONNECTION)
852
+ await pg.raw(`INSERT INTO ...`, [id]) // ❌ bypasses ORM
853
+ }
854
+
855
+ // RIGHT — use service methods in onRegistered
856
+ onRegistered: async ({ container, registeredDefaults }) => {
857
+ const service: any = container.resolve(MY_MODULE)
858
+ for (const provider of registeredDefaults) {
859
+ const existing = await service.listMyProviders({ id: [provider.id] })
860
+ if (!existing.length) {
861
+ await service.createMyProviders([{ id: provider.id, is_enabled: true }]) // ✅
862
+ }
863
+ }
864
+ }
865
+
866
+ // WRONG — lazy-syncing / seeding data inside a GET route handler
867
+ export const GET = async (req, res) => {
868
+ const service = req.scope.resolve(PAYMENT_MODULE)
869
+ await service.syncRegisteredProviders() // ❌ runs on EVERY request
870
+ const { data } = await query.graph({ entity: "payment_provider" })
871
+ res.json({ items: data })
872
+ }
873
+
874
+ // RIGHT — seed data once at startup (onPluginStart) or via script, route just queries
875
+ export const GET = async (req, res) => {
876
+ const { data } = await query.graph({ entity: "payment_provider" })
877
+ res.json({ items: data }) // ✅ pure query, no side effects
878
+ }
879
+
880
+ // WRONG — importing service class directly in route file
881
+ import type PaymentModuleService from "../../../modules/payment/service" // ❌
882
+ const service = req.scope.resolve<PaymentModuleService>(PAYMENT_MODULE)
883
+
884
+ // RIGHT — resolve with module constant, cast to interface if needed
885
+ const service: any = req.scope.resolve(PAYMENT_MODULE) // ✅
886
+ // or use the generated interface type from @types if available
770
887
  ```