@acmekit/acmekit 2.13.89 → 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.
- package/dist/commands/plugin/develop.d.ts.map +1 -1
- package/dist/commands/plugin/develop.js +92 -68
- package/dist/commands/plugin/develop.js.map +1 -1
- package/dist/templates/app/.claude/rules/admin-components.md +131 -111
- package/dist/templates/app/.claude/rules/admin-data.md +2 -0
- package/dist/templates/app/.claude/rules/admin-patterns.md +39 -31
- package/dist/templates/app/.claude/rules/admin-ui.md +28 -0
- package/dist/templates/app/.claude/rules/api-routes.md +30 -9
- package/dist/templates/app/.claude/rules/modules.md +119 -2
- package/dist/templates/app/.claude/skills/build-feature/SKILL.md +2 -0
- package/dist/templates/app/CLAUDE.md +1 -0
- package/dist/templates/plugin/.claude/rules/admin-components.md +131 -111
- package/dist/templates/plugin/.claude/rules/admin-data.md +2 -0
- package/dist/templates/plugin/.claude/rules/admin-patterns.md +39 -31
- package/dist/templates/plugin/.claude/rules/admin-ui.md +28 -0
- package/dist/templates/plugin/.claude/rules/api-routes.md +30 -9
- package/dist/templates/plugin/.claude/rules/modules.md +118 -1
- package/dist/templates/plugin/.claude/skills/build-feature/SKILL.md +2 -0
- package/dist/templates/plugin/CLAUDE.md +1 -0
- package/package.json +39 -39
|
@@ -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:
|
|
455
|
-
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
|
|
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
|
-
<
|
|
480
|
-
<
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
</
|
|
514
|
-
</RouteDrawer.
|
|
515
|
-
<
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
<
|
|
597
|
-
<
|
|
598
|
-
|
|
599
|
-
<
|
|
600
|
-
<div
|
|
601
|
-
<
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
</
|
|
647
|
-
</
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
<
|
|
929
|
-
<
|
|
930
|
-
<
|
|
931
|
-
|
|
932
|
-
{form.formState.errors.currencies
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
<
|
|
938
|
-
<DataTable
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
<
|
|
946
|
-
<
|
|
947
|
-
<
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
-
-
|
|
536
|
-
-
|
|
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 —
|
|
741
|
-
import { z } from "zod" // ❌
|
|
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
|
|
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
|
```
|