@asteby/metacore-runtime-react 9.1.0 → 10.0.0

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 (51) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/dist/addon-layout-context.d.ts +49 -0
  3. package/dist/addon-layout-context.d.ts.map +1 -0
  4. package/dist/addon-layout-context.js +94 -0
  5. package/dist/addon-loader.d.ts +14 -2
  6. package/dist/addon-loader.d.ts.map +1 -1
  7. package/dist/addon-loader.js +7 -1
  8. package/dist/dynamic-form-schema.d.ts +5 -0
  9. package/dist/dynamic-form-schema.d.ts.map +1 -1
  10. package/dist/dynamic-form-schema.js +34 -0
  11. package/dist/dynamic-form.d.ts.map +1 -1
  12. package/dist/dynamic-form.js +18 -2
  13. package/dist/dynamic-relation.d.ts.map +1 -1
  14. package/dist/dynamic-relation.js +59 -22
  15. package/dist/hotswap-reload-policy.d.ts +155 -0
  16. package/dist/hotswap-reload-policy.d.ts.map +1 -0
  17. package/dist/hotswap-reload-policy.js +227 -0
  18. package/dist/index.d.ts +6 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +6 -0
  21. package/dist/manifest-hotswap-subscriber.d.ts +83 -0
  22. package/dist/manifest-hotswap-subscriber.d.ts.map +1 -0
  23. package/dist/manifest-hotswap-subscriber.js +104 -0
  24. package/dist/metadata-cache.d.ts +35 -0
  25. package/dist/metadata-cache.d.ts.map +1 -1
  26. package/dist/metadata-cache.js +55 -0
  27. package/dist/types.d.ts +20 -0
  28. package/dist/types.d.ts.map +1 -1
  29. package/dist/use-options-resolver.d.ts +87 -0
  30. package/dist/use-options-resolver.d.ts.map +1 -0
  31. package/dist/use-options-resolver.js +147 -0
  32. package/dist/use-org-config-bridge.d.ts +28 -0
  33. package/dist/use-org-config-bridge.d.ts.map +1 -0
  34. package/dist/use-org-config-bridge.js +50 -0
  35. package/package.json +4 -4
  36. package/src/__tests__/hotswap-reload-policy.test.ts +249 -0
  37. package/src/__tests__/manifest-hotswap-subscriber.test.ts +179 -0
  38. package/src/__tests__/use-options-resolver.test.ts +127 -0
  39. package/src/__tests__/wasm-client-sri.test.ts +82 -0
  40. package/src/addon-layout-context.tsx +137 -0
  41. package/src/addon-loader.tsx +21 -1
  42. package/src/dynamic-form-schema.ts +36 -0
  43. package/src/dynamic-form.tsx +40 -2
  44. package/src/dynamic-relation.tsx +55 -20
  45. package/src/hotswap-reload-policy.ts +360 -0
  46. package/src/index.ts +43 -0
  47. package/src/manifest-hotswap-subscriber.ts +164 -0
  48. package/src/metadata-cache.ts +86 -0
  49. package/src/types.ts +24 -0
  50. package/src/use-options-resolver.ts +232 -0
  51. package/src/use-org-config-bridge.ts +60 -0
@@ -0,0 +1,82 @@
1
+ // Wasm-client SRI smoke tests — lives in runtime-react because the SDK
2
+ // package does not (yet) carry a vitest setup of its own. We import the
3
+ // helper through the workspace specifier so the test exercises the same
4
+ // public boundary downstream apps see.
5
+ //
6
+ // The SRI logic is decoupled from WebAssembly instantiation by design, so
7
+ // these tests run in plain Node without needing a real `.wasm` module.
8
+
9
+ import { describe, it, expect } from 'vitest'
10
+ import {
11
+ verifyIntegrity,
12
+ WasmIntegrityError,
13
+ } from '@asteby/metacore-sdk'
14
+
15
+ function bytes(input: string): Uint8Array {
16
+ return new TextEncoder().encode(input)
17
+ }
18
+
19
+ async function digestB64(algo: 'SHA-256' | 'SHA-384' | 'SHA-512', input: string) {
20
+ const buf = await crypto.subtle.digest(algo, bytes(input))
21
+ const view = new Uint8Array(buf)
22
+ let bin = ''
23
+ for (let i = 0; i < view.byteLength; i++) bin += String.fromCharCode(view[i]!)
24
+ return btoa(bin)
25
+ }
26
+
27
+ describe('verifyIntegrity', () => {
28
+ it('accepts a matching sha384 digest', async () => {
29
+ const payload = 'hello, metacore wasm world'
30
+ const hash = await digestB64('SHA-384', payload)
31
+ await expect(
32
+ verifyIntegrity(bytes(payload), `sha384-${hash}`),
33
+ ).resolves.toBeUndefined()
34
+ })
35
+
36
+ it('accepts a matching sha256 digest', async () => {
37
+ const payload = '{"hello":"world"}'
38
+ const hash = await digestB64('SHA-256', payload)
39
+ await expect(
40
+ verifyIntegrity(bytes(payload), `sha256-${hash}`),
41
+ ).resolves.toBeUndefined()
42
+ })
43
+
44
+ it('throws WasmIntegrityError on digest mismatch', async () => {
45
+ const payload = 'hello'
46
+ const wrong = await digestB64('SHA-384', 'tampered')
47
+ await expect(
48
+ verifyIntegrity(bytes(payload), `sha384-${wrong}`),
49
+ ).rejects.toBeInstanceOf(WasmIntegrityError)
50
+ })
51
+
52
+ it('passes when ANY space-separated token matches', async () => {
53
+ const payload = 'multi-hash'
54
+ const bad = await digestB64('SHA-384', 'other')
55
+ const good = await digestB64('SHA-256', payload)
56
+ await expect(
57
+ verifyIntegrity(bytes(payload), `sha384-${bad} sha256-${good}`),
58
+ ).resolves.toBeUndefined()
59
+ })
60
+
61
+ it('is a no-op when integrity is the empty string', async () => {
62
+ await expect(verifyIntegrity(bytes('anything'), '')).resolves.toBeUndefined()
63
+ })
64
+
65
+ it('rejects malformed integrity tokens', async () => {
66
+ await expect(
67
+ verifyIntegrity(bytes('x'), 'this-has-no-algo'),
68
+ ).rejects.toBeInstanceOf(WasmIntegrityError)
69
+ })
70
+
71
+ it('rejects unsupported algorithms', async () => {
72
+ await expect(
73
+ verifyIntegrity(bytes('x'), 'md5-deadbeef'),
74
+ ).rejects.toBeInstanceOf(WasmIntegrityError)
75
+ })
76
+
77
+ it('handles length-mismatched digests as a clean mismatch', async () => {
78
+ await expect(
79
+ verifyIntegrity(bytes('x'), 'sha256-short'),
80
+ ).rejects.toBeInstanceOf(WasmIntegrityError)
81
+ })
82
+ })
@@ -0,0 +1,137 @@
1
+ // AddonLayoutContext — broadcast the active addon entry's layout selection
2
+ // (`shell` vs `immersive`) up to the host so it can hide/show its chrome
3
+ // (Sidebar, Topbar, breadcrumbs) when an immersive addon is mounted.
4
+ //
5
+ // Why a context rather than a prop on the host shell:
6
+ //
7
+ // 1. The host shell is rendered ABOVE the addon route in the tree, but the
8
+ // decision about what layout the addon wants comes from the addon itself
9
+ // (manifest.frontend.layout) which the AddonLoader knows about at mount
10
+ // time. A bottom-up signal via context inverts the dependency cleanly.
11
+ //
12
+ // 2. Addon entries can swap layouts at runtime (think a kiosk-mode toggle
13
+ // inside a POS). A context value reactively updates the host without
14
+ // asking each route to wire props.
15
+ //
16
+ // 3. When the user navigates AWAY from an immersive addon, the AddonLoader
17
+ // unmounts, its layout context updater fires `setLayout("shell")` from
18
+ // a cleanup effect, and the chrome restores automatically.
19
+ //
20
+ // Host integration (starter-core, ops, …):
21
+ //
22
+ // function AppShell({ children }) {
23
+ // const layout = useAddonLayout()
24
+ // const chrome = layout !== "immersive"
25
+ // return (
26
+ // <div className={chrome ? "grid grid-cols-[280px_1fr]" : "h-dvh w-dvw"}>
27
+ // {chrome && <Sidebar />}
28
+ // <main>{chrome && <Topbar />}{children}</main>
29
+ // </div>
30
+ // )
31
+ // }
32
+ //
33
+ // The context defaults to `"shell"`, so apps that never mount an
34
+ // `<AddonLayoutProvider>` keep the legacy behaviour.
35
+
36
+ import {
37
+ createContext,
38
+ useCallback,
39
+ useContext,
40
+ useEffect,
41
+ useMemo,
42
+ useState,
43
+ } from 'react'
44
+ import type { AddonLayout } from '@asteby/metacore-sdk'
45
+
46
+ export type { AddonLayout }
47
+
48
+ interface AddonLayoutState {
49
+ /** Active layout. `"shell"` (default) or `"immersive"`. */
50
+ layout: AddonLayout
51
+ /**
52
+ * Imperative setter for the host or an addon-loader to mutate the active
53
+ * layout. Exposed for advanced use; most callers should use
54
+ * `useDeclareAddonLayout(layout)` from a route component, which scopes the
55
+ * change to the route's mount lifetime.
56
+ */
57
+ setLayout: (layout: AddonLayout) => void
58
+ }
59
+
60
+ const defaultState: AddonLayoutState = {
61
+ layout: 'shell',
62
+ setLayout: () => {
63
+ /* noop — provider missing; consumers degrade to legacy "shell" */
64
+ },
65
+ }
66
+
67
+ const AddonLayoutContext = createContext<AddonLayoutState>(defaultState)
68
+
69
+ export interface AddonLayoutProviderProps {
70
+ /** Initial layout — usually `"shell"`. */
71
+ initial?: AddonLayout
72
+ children: React.ReactNode
73
+ }
74
+
75
+ /**
76
+ * Wrap the host app once, above the router outlet. The provider keeps the
77
+ * currently-active layout in state; addon-loader and `useDeclareAddonLayout`
78
+ * mutate it from below.
79
+ */
80
+ export function AddonLayoutProvider({
81
+ initial = 'shell',
82
+ children,
83
+ }: AddonLayoutProviderProps) {
84
+ const [layout, setLayout] = useState<AddonLayout>(initial)
85
+ const value = useMemo<AddonLayoutState>(
86
+ () => ({ layout, setLayout }),
87
+ [layout],
88
+ )
89
+ return (
90
+ <AddonLayoutContext.Provider value={value}>
91
+ {children}
92
+ </AddonLayoutContext.Provider>
93
+ )
94
+ }
95
+
96
+ /**
97
+ * Read the currently-active layout. The host shell calls this and decides
98
+ * whether to render its chrome. Returns `"shell"` when no provider is
99
+ * mounted, so apps that have not adopted immersive addons keep working.
100
+ */
101
+ export function useAddonLayout(): AddonLayout {
102
+ return useContext(AddonLayoutContext).layout
103
+ }
104
+
105
+ /**
106
+ * Imperative API — the value returned mirrors `useAddonLayout()` but also
107
+ * exposes the setter for hosts that need to flip the layout outside of a
108
+ * route lifecycle (e.g. a hotkey forcing kiosk mode). Most addon entries do
109
+ * NOT need this; prefer `useDeclareAddonLayout`.
110
+ */
111
+ export function useAddonLayoutControl(): AddonLayoutState {
112
+ return useContext(AddonLayoutContext)
113
+ }
114
+
115
+ /**
116
+ * Declare the layout from the addon side. Mounts the value, restores
117
+ * `"shell"` on unmount. Skip when `layout` is undefined so route components
118
+ * can pass `manifest.frontend?.layout` directly without branching.
119
+ *
120
+ * function PosEntry({ manifest }: { manifest: Manifest }) {
121
+ * useDeclareAddonLayout(manifest.frontend?.layout)
122
+ * return <PosScreen />
123
+ * }
124
+ */
125
+ export function useDeclareAddonLayout(layout: AddonLayout | undefined): void {
126
+ const { setLayout } = useAddonLayoutControl()
127
+ // useCallback so the effect only re-runs on a real layout change, not on
128
+ // every render of the consumer that happens to forward an inline literal.
129
+ const apply = useCallback(setLayout, [setLayout])
130
+ useEffect(() => {
131
+ if (!layout || layout === 'shell') return
132
+ apply(layout)
133
+ return () => {
134
+ apply('shell')
135
+ }
136
+ }, [layout, apply])
137
+ }
@@ -2,7 +2,8 @@
2
2
  // waits for the `window[scope]` container to initialize, then calls the
3
3
  // addon's `register(api)` export with the AddonAPI injected by the host.
4
4
  import { useEffect, useRef, useState } from 'react'
5
- import type { AddonAPI } from '@asteby/metacore-sdk'
5
+ import type { AddonAPI, AddonLayout } from '@asteby/metacore-sdk'
6
+ import { useDeclareAddonLayout } from './addon-layout-context'
6
7
 
7
8
  declare global {
8
9
  interface Window {
@@ -27,6 +28,18 @@ export interface AddonLoaderProps {
27
28
  onReady?: () => void
28
29
  /** Called if loading fails. */
29
30
  onError?: (err: Error) => void
31
+ /**
32
+ * Layout the host shell should render the addon under, mirroring
33
+ * `manifest.frontend.layout`. Default (undefined / `"shell"`) keeps the
34
+ * legacy chrome (Sidebar, Topbar, breadcrumbs). `"immersive"` flips the
35
+ * shared {@link useAddonLayout} context so the host shell hides chrome
36
+ * while the addon is mounted and restores it on unmount.
37
+ *
38
+ * Hosts that consume the context (see `useAddonLayout` /
39
+ * `<AddonLayoutProvider>`) do NOT need to branch on this prop themselves
40
+ * — the loader sets the context value via {@link useDeclareAddonLayout}.
41
+ */
42
+ layout?: AddonLayout
30
43
  children?: React.ReactNode
31
44
  }
32
45
 
@@ -75,12 +88,19 @@ export function AddonLoader({
75
88
  fallback = null,
76
89
  onReady,
77
90
  onError,
91
+ layout,
78
92
  children,
79
93
  }: AddonLoaderProps) {
80
94
  const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
81
95
  const [error, setError] = useState<Error | null>(null)
82
96
  const didRegister = useRef(false)
83
97
 
98
+ // Propagate the addon's preferred layout to the host shell via context.
99
+ // No-op when `layout` is undefined or `"shell"` (legacy default). Cleanup
100
+ // restores `"shell"` automatically when the loader unmounts, so chrome
101
+ // returns as soon as the user navigates away from an immersive addon.
102
+ useDeclareAddonLayout(layout)
103
+
84
104
  useEffect(() => {
85
105
  let cancelled = false
86
106
  ;(async () => {
@@ -3,6 +3,37 @@
3
3
  // metacore-ui primitives.
4
4
  import { z, type ZodTypeAny } from 'zod'
5
5
  import type { ActionFieldDef, FieldValidation } from './types'
6
+ import { resolveValidatorToken } from './use-org-config-bridge'
7
+
8
+ /**
9
+ * Built-in validators the SDK knows how to apply by symbolic name. Apps
10
+ * that wire `OrgConfigProvider` map `$org.<key>` references to one of
11
+ * these slugs (or to a custom slug they register). Unknown slugs are a
12
+ * no-op so unresolved $org references degrade to "no extra check"
13
+ * rather than a runtime crash — matches the kernel's pass-through
14
+ * semantics for unresolved references.
15
+ */
16
+ const builtinValidators: Record<string, (s: z.ZodString) => z.ZodString> = {
17
+ // The SDK ships ZERO fiscal vocabulary by default. Apps register
18
+ // their own validators (mx.rfc, co.nit, pe.ruc, etc.) via
19
+ // `registerValidator` so kernel/SDK stay region-agnostic.
20
+ }
21
+
22
+ /**
23
+ * Apps register validator implementations by slug. The slug is the value
24
+ * `OrgConfig.validators[<key>]` returns for a $org.<key> reference.
25
+ */
26
+ export function registerValidator(slug: string, fn: (s: z.ZodString) => z.ZodString): void {
27
+ builtinValidators[slug] = fn
28
+ }
29
+
30
+ function applyCustomValidator(s: z.ZodString, customToken: string | undefined): z.ZodString {
31
+ if (!customToken) return s
32
+ const resolved = resolveValidatorToken(customToken)
33
+ if (!resolved) return s
34
+ const fn = builtinValidators[resolved]
35
+ return fn ? fn(s) : s
36
+ }
6
37
 
7
38
  // Builds a zod object schema from an ActionFieldDef[]. Required fields stay
8
39
  // non-empty; optional fields accept undefined / "". Validation rules
@@ -44,6 +75,11 @@ function fieldToZod(field: ActionFieldDef): ZodTypeAny {
44
75
  if (field.type === 'email') s = s.email('Email inválido')
45
76
  if (field.type === 'url') s = s.url('URL inválida')
46
77
 
78
+ // Custom validator: a literal slug (`mx.rfc`) OR a `$org.<key>`
79
+ // reference resolved through the OrgConfigProvider. Unknown slugs
80
+ // pass through as no-ops so apps never crash on missing config.
81
+ s = applyCustomValidator(s, v.custom)
82
+
47
83
  if (field.required) {
48
84
  return s.min(Math.max(typeof v.min === 'number' ? v.min : 1, 1), `${field.label} es requerido`)
49
85
  }
@@ -16,6 +16,7 @@ import {
16
16
  } from '@asteby/metacore-ui/primitives'
17
17
  import type { ActionFieldDef } from './types'
18
18
  import { buildZodSchema, resolveWidget } from './dynamic-form-schema'
19
+ import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
19
20
 
20
21
  export { buildZodSchema, resolveWidget }
21
22
 
@@ -81,7 +82,11 @@ export function DynamicForm({
81
82
  {field.label}
82
83
  {field.required && <span className="text-red-500 ml-1">*</span>}
83
84
  </Label>
84
- {renderField(field, values[field.key], (v: any) => update(field.key, v))}
85
+ <FieldRenderer
86
+ field={field}
87
+ value={values[field.key]}
88
+ onChange={(v: any) => update(field.key, v)}
89
+ />
85
90
  {errors[field.key] && (
86
91
  <span className="text-red-500 text-sm" role="alert">{errors[field.key]}</span>
87
92
  )}
@@ -99,8 +104,21 @@ export function DynamicForm({
99
104
  )
100
105
  }
101
106
 
102
- function renderField(field: ActionFieldDef, value: any, onChange: (v: any) => void) {
107
+ interface FieldRendererProps {
108
+ field: ActionFieldDef
109
+ value: any
110
+ onChange: (v: any) => void
111
+ }
112
+
113
+ function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
103
114
  const widget = resolveWidget(field)
115
+ // Ref-driven select: hook into useOptionsResolver so the canonical
116
+ // /api/options/<ref>?field=id endpoint feeds the dropdown. This is
117
+ // the path the kernel auto-derives for FK columns; legacy callers
118
+ // shipping inline `options` keep working in the branch below.
119
+ if (widget === 'select' && field.ref) {
120
+ return <RefSelect field={field} value={value} onChange={onChange} />
121
+ }
104
122
  switch (widget) {
105
123
  case 'textarea':
106
124
  return <Textarea id={field.key} value={value || ''} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)} placeholder={field.placeholder} />
@@ -131,3 +149,23 @@ function renderField(field: ActionFieldDef, value: any, onChange: (v: any) => vo
131
149
  }
132
150
  }
133
151
 
152
+ function RefSelect({ field, value, onChange }: FieldRendererProps) {
153
+ const { options, loading } = useOptionsResolver({
154
+ modelKey: '', // unused — `ref` drives the URL
155
+ fieldKey: 'id',
156
+ ref: field.ref,
157
+ })
158
+ return (
159
+ <Select value={value || ''} onValueChange={onChange} disabled={loading}>
160
+ <SelectTrigger>
161
+ <SelectValue placeholder={loading ? 'Cargando…' : (field.placeholder || 'Seleccionar...')} />
162
+ </SelectTrigger>
163
+ <SelectContent>
164
+ {options.map((opt: ResolvedOption) => (
165
+ <SelectItem key={String(opt.id)} value={String(opt.id)}>{opt.label}</SelectItem>
166
+ ))}
167
+ </SelectContent>
168
+ </Select>
169
+ )
170
+ }
171
+
@@ -25,6 +25,7 @@ import { Plus, Trash2, Pencil } from 'lucide-react'
25
25
  import { useApi } from './api-context'
26
26
  import { useMetadataCache } from './metadata-cache'
27
27
  import { DynamicForm } from './dynamic-form'
28
+ import { useOptionsResolver } from './use-options-resolver'
28
29
  import type { ApiResponse, TableMetadata } from './types'
29
30
  import {
30
31
  buildCreatePayload,
@@ -380,7 +381,13 @@ function ManyToManyRelation({
380
381
 
381
382
  const refKey = referencesKey || `${references}_id`
382
383
  const pivotPath = pivotEndpoint || `/data/${through}`
383
- const targetPath = referencesEndpoint || `/data/${references}`
384
+ // referencesEndpoint is preserved as a legacy escape hatch — when set
385
+ // we keep the old `/data/<references>` raw fetch path (so apps that
386
+ // depend on a custom server route do not break). When unset we use
387
+ // the canonical `/api/options/:references` endpoint via
388
+ // useOptionsResolver, which is what the kernel auto-derives Ref to.
389
+ const useResolver = !referencesEndpoint
390
+ const legacyTargetPath = referencesEndpoint || `/data/${references}`
384
391
 
385
392
  const cachedTargetMeta = getMetadata(references)
386
393
  const [targetMeta, setTargetMeta] = useState<TableMetadata | null>(cachedTargetMeta || null)
@@ -389,41 +396,65 @@ function ManyToManyRelation({
389
396
  const [loading, setLoading] = useState(true)
390
397
  const [syncing, setSyncing] = useState(false)
391
398
 
392
- const fetchAll = useCallback(async () => {
399
+ // Canonical path: SDK options resolver. Only fires when no legacy
400
+ // override is set. The hook is a no-op when `useResolver` is false.
401
+ const resolved = useOptionsResolver({
402
+ modelKey: '',
403
+ fieldKey: 'id',
404
+ ref: useResolver ? references : undefined,
405
+ enabled: useResolver,
406
+ })
407
+
408
+ const fetchPivotAndMeta = useCallback(async () => {
393
409
  setLoading(true)
394
410
  try {
395
411
  const params = buildRelationFilterParams(foreignKey, parentId)
396
- const [metaRes, pivotRes, targetRes] = await Promise.all([
397
- targetMeta ? Promise.resolve(null) : api.get(`/metadata/table/${references}`),
412
+ const tasks: Promise<unknown>[] = [
398
413
  api.get(pivotPath, { params }),
399
- api.get(targetPath),
400
- ])
401
- if (metaRes && (metaRes as any).data?.success) {
402
- const fresh = (metaRes as { data: ApiResponse<TableMetadata> }).data.data
403
- setTargetMeta(fresh)
404
- cacheMetadata(references, fresh)
414
+ ]
415
+ if (!targetMeta) tasks.push(api.get(`/metadata/table/${references}`))
416
+ // Legacy fallback path: the resolver is disabled, fetch the
417
+ // target rows the old way so callers that depend on a custom
418
+ // route keep working.
419
+ if (!useResolver) tasks.push(api.get(legacyTargetPath))
420
+ const results = await Promise.all(tasks)
421
+ const pivotRes = results[0] as { data: ApiResponse<any[]> }
422
+ if (pivotRes.data.success) setPivotRows(pivotRes.data.data || [])
423
+ let cursor = 1
424
+ if (!targetMeta) {
425
+ const metaRes = results[cursor++] as { data: ApiResponse<TableMetadata> }
426
+ if (metaRes.data?.success) {
427
+ setTargetMeta(metaRes.data.data)
428
+ cacheMetadata(references, metaRes.data.data)
429
+ }
430
+ }
431
+ if (!useResolver) {
432
+ const targetRes = results[cursor++] as { data: ApiResponse<any[]> }
433
+ if (targetRes.data.success) setTargetRows(targetRes.data.data || [])
405
434
  }
406
- const pivotList = (pivotRes as { data: ApiResponse<any[]> }).data
407
- if (pivotList.success) setPivotRows(pivotList.data || [])
408
- const targetList = (targetRes as { data: ApiResponse<any[]> }).data
409
- if (targetList.success) setTargetRows(targetList.data || [])
410
435
  } catch (err) {
411
436
  console.error('DynamicRelation m2m fetch error', err)
412
437
  } finally {
413
438
  setLoading(false)
414
439
  }
415
- }, [api, pivotPath, targetPath, foreignKey, parentId, references, targetMeta, cacheMetadata])
440
+ }, [api, pivotPath, foreignKey, parentId, references, targetMeta, cacheMetadata, useResolver, legacyTargetPath])
416
441
 
417
- useEffect(() => { fetchAll() }, [fetchAll])
442
+ useEffect(() => { fetchPivotAndMeta() }, [fetchPivotAndMeta])
418
443
 
419
444
  const options = useMemo(() => {
445
+ if (useResolver) {
446
+ return resolved.options.map((o) => ({
447
+ value: String(o.id),
448
+ label: o.label,
449
+ }))
450
+ }
420
451
  return targetRows
421
452
  .filter(r => r && r.id !== undefined && r.id !== null && r.id !== '')
422
453
  .map(r => ({
423
454
  value: String(r.id),
424
455
  label: pickOptionLabel(r, displayKey, targetMeta?.columns),
425
456
  }))
426
- }, [targetRows, displayKey, targetMeta])
457
+ }, [useResolver, resolved.options, targetRows, displayKey, targetMeta])
427
458
 
428
459
  const selectedIds = useMemo(
429
460
  () => extractSelectedTargetIds(pivotRows, refKey),
@@ -454,14 +485,18 @@ function ManyToManyRelation({
454
485
  const res = await api.delete(`${pivotPath}/${pivotId}`)
455
486
  if (!(res as any).data?.success) throw new Error('detach failed')
456
487
  }
457
- await fetchAll()
488
+ await fetchPivotAndMeta()
489
+ // Refresh resolver-driven options when active so newly attached
490
+ // targets reflect immediately. Refetching the pivot rows alone
491
+ // is enough when the resolver branch is off.
492
+ if (useResolver) resolved.refetch()
458
493
  onChange?.()
459
494
  } catch (err) {
460
495
  console.error('DynamicRelation m2m sync error', err)
461
496
  } finally {
462
497
  setSyncing(false)
463
498
  }
464
- }, [api, canCreate, canDelete, fetchAll, foreignKey, onChange, parentId, pivotIndex, pivotPath, refKey, selectedIds, syncing])
499
+ }, [api, canCreate, canDelete, fetchPivotAndMeta, useResolver, resolved, foreignKey, onChange, parentId, pivotIndex, pivotPath, refKey, selectedIds, syncing])
465
500
 
466
501
  return (
467
502
  <div
@@ -476,7 +511,7 @@ function ManyToManyRelation({
476
511
  </div>
477
512
  )}
478
513
 
479
- {loading ? (
514
+ {(loading || (useResolver && resolved.loading)) ? (
480
515
  <Skeleton className="h-10 w-full" />
481
516
  ) : options.length === 0 ? (
482
517
  <div className="text-center text-sm text-muted-foreground py-8 border rounded-md bg-muted/30">