@riducms/admin 0.1.5 → 0.2.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 (66) hide show
  1. package/package.json +11 -9
  2. package/src/app.css +63 -4
  3. package/src/app.svelte +12 -14
  4. package/src/components/ui/date-value-control/date-value-control-loader.svelte +15 -4
  5. package/src/components/ui/date-value-control/date-value-control.svelte +17 -17
  6. package/src/components/ui/select/select-trigger.svelte +1 -1
  7. package/src/core/forms/embedded-fields.ts +132 -0
  8. package/src/core/forms/embedded-schema-draft.svelte.ts +335 -0
  9. package/src/core/forms/field-editor-authoring.ts +47 -0
  10. package/src/core/forms/field-editor-binding.ts +228 -0
  11. package/src/core/forms/field-issue-focus.ts +42 -0
  12. package/src/core/forms/field-occurrence.ts +122 -0
  13. package/src/core/forms/form-controller.svelte.ts +475 -29
  14. package/src/core/forms/form-issue-correlation.ts +159 -0
  15. package/src/core/forms/form-schema.ts +211 -17
  16. package/src/core/forms/form-validation.ts +183 -17
  17. package/src/core/forms/live-validation.svelte.ts +317 -0
  18. package/src/core/forms/plugin-field-authoring.ts +160 -0
  19. package/src/core/forms/plugin-field-binding.ts +258 -0
  20. package/src/core/forms/plugin-field-write.ts +57 -0
  21. package/src/core/i18n/admin-i18n.svelte.ts +8 -1
  22. package/src/core/i18n/localized-schema.ts +33 -10
  23. package/src/core/plugins/field-registry.ts +29 -49
  24. package/src/core/plugins/row-label-registry.ts +25 -5
  25. package/src/core/runtime/admin-runtime.svelte.ts +25 -13
  26. package/src/features/auth/account-route.svelte +10 -5
  27. package/src/features/auth/account-security-route.svelte +1 -1
  28. package/src/features/collections/collection-list-bulk-editor.svelte +6 -6
  29. package/src/features/collections/collection-list-controller.svelte.ts +5 -5
  30. package/src/features/collections/collection-list-filter-popover.svelte +6 -6
  31. package/src/features/collections/collection-list-route.svelte +13 -14
  32. package/src/features/collections/list-workspace.ts +22 -8
  33. package/src/features/documents/document-controller.svelte.ts +8 -2
  34. package/src/features/documents/document-route.svelte +36 -43
  35. package/src/features/reference-browser/reference-browser-workflow.svelte.ts +30 -15
  36. package/src/features/reference-browser/reference-browser.svelte +215 -189
  37. package/src/features/uploads/upload-metadata-fields.svelte +6 -6
  38. package/src/features/versions/version-history-route.svelte +1 -1
  39. package/src/fields/embedded-schema-draft-editor.svelte +74 -0
  40. package/src/fields/embedded-schema-fields.svelte +44 -0
  41. package/src/fields/field-clipboard.ts +76 -16
  42. package/src/fields/field-collapsible.svelte +49 -0
  43. package/src/fields/field-layout.svelte +2 -18
  44. package/src/fields/field-messages.svelte +13 -18
  45. package/src/fields/field-renderer.svelte +53 -14
  46. package/src/fields/join/join-field.svelte +8 -1
  47. package/src/fields/live-validation-feedback.svelte +21 -0
  48. package/src/fields/local-field-editor.svelte +52 -0
  49. package/src/fields/nested/nested-field.svelte +159 -91
  50. package/src/fields/nested/scoped-field.ts +7 -9
  51. package/src/fields/plugin-field.svelte +156 -0
  52. package/src/fields/point/point-field.svelte +12 -2
  53. package/src/fields/primitive-list/primitive-list-field.svelte +170 -0
  54. package/src/fields/primitive-list/primitive-number-input.ts +6 -0
  55. package/src/fields/radio/radio-field.svelte +13 -9
  56. package/src/fields/relationship/relationship-field.svelte +36 -12
  57. package/src/fields/relationship/relationship-query.ts +4 -2
  58. package/src/fields/relationship/relationship-quick-picker.svelte +38 -10
  59. package/src/fields/scalar/date-control-value.ts +5 -5
  60. package/src/fields/scalar/date-value.ts +5 -5
  61. package/src/fields/scalar/scalar-field.svelte +47 -41
  62. package/src/fields/select/select-field.svelte +24 -24
  63. package/src/fields/select/select-value.ts +3 -3
  64. package/src/fields/text/slug-field.svelte +24 -8
  65. package/src/fields/text/text-field.svelte +8 -6
  66. package/src/index.ts +4 -12
package/package.json CHANGED
@@ -9,11 +9,11 @@
9
9
  "@hvniel/svelte-router": "^0.0.6",
10
10
  "@iconify/json": "^2.2.509",
11
11
  "@internationalized/date": "^3.12.3",
12
- "@riducms/plugin": "0.1.5",
13
- "@riducms/protocol": "0.1.5",
14
- "@riducms/sdk": "0.1.5",
15
- "@riducms/translations": "0.1.5",
16
- "@riducms/ui": "0.1.5",
12
+ "@riducms/plugin": "0.2.0",
13
+ "@riducms/protocol": "0.2.0",
14
+ "@riducms/sdk": "0.2.0",
15
+ "@riducms/translations": "0.2.0",
16
+ "@riducms/ui": "0.2.0",
17
17
  "@types/prismjs": "^1.26.5",
18
18
  "bits-ui": "^2.18.1",
19
19
  "prismjs": "^1.30.0",
@@ -22,7 +22,7 @@
22
22
  "description": "Framework-owned Svelte 5 authoring application embedded by Ridu servers.",
23
23
  "devDependencies": {
24
24
  "@hvniel/lexical-svelte": "^0.1.1",
25
- "@riducms/build": "0.1.5",
25
+ "@riducms/build": "0.2.0",
26
26
  "svelte": "^5.56.8"
27
27
  },
28
28
  "exports": {
@@ -51,12 +51,14 @@
51
51
  },
52
52
  "scripts": {
53
53
  "build": "vite build",
54
- "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.test.json",
54
+ "check": "svelte-check --tsconfig ./tsconfig.app.json && svelte-check --tsconfig ./tsconfig.test.json",
55
55
  "dev": "vite",
56
56
  "lint": "tsc -p tsconfig.node.json && bun ../scripts/check-admin-ui-policy.ts",
57
57
  "preview": "vite preview",
58
- "test": "bun test"
58
+ "test": "bun run test:unit && bun run test:components",
59
+ "test:components": "vitest run --config vitest.config.ts",
60
+ "test:unit": "bun test tests"
59
61
  },
60
62
  "type": "module",
61
- "version": "0.1.5"
63
+ "version": "0.2.0"
62
64
  }
package/src/app.css CHANGED
@@ -232,15 +232,23 @@ a {
232
232
  outline-offset: 2px;
233
233
  }
234
234
 
235
- [data-slot="input"]:focus-visible,
236
- [data-slot="textarea"]:focus-visible,
237
- [data-slot="command-input"]:focus-visible,
238
- select:focus-visible {
235
+ [data-slot="input"]:not([aria-invalid="true"]):focus-visible,
236
+ [data-slot="textarea"]:not([aria-invalid="true"]):focus-visible,
237
+ [data-slot="command-input"]:not([aria-invalid="true"]):focus-visible,
238
+ select:not([aria-invalid="true"]):focus-visible {
239
239
  border-color: color-mix(in oklab, var(--ring) 78%, transparent);
240
240
  box-shadow: none;
241
241
  outline: none;
242
242
  }
243
243
 
244
+ [data-slot="input"][aria-invalid="true"]:focus-visible,
245
+ [data-slot="textarea"][aria-invalid="true"]:focus-visible,
246
+ [data-slot="command-input"][aria-invalid="true"]:focus-visible,
247
+ select[aria-invalid="true"]:focus-visible {
248
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--destructive) 65%, transparent);
249
+ outline: none;
250
+ }
251
+
244
252
  .font-serif {
245
253
  font-family: var(--font-serif);
246
254
  }
@@ -308,6 +316,57 @@ select:focus-visible {
308
316
  line-height: 1.5;
309
317
  }
310
318
 
319
+ /*
320
+ * Validation feedback is a status surface, not an interactive tooltip. Keep it
321
+ * anchored to the control without covering its hit target or replacing help copy.
322
+ */
323
+ .ridu-field-error-tooltip {
324
+ position: absolute;
325
+ z-index: 20;
326
+ inset-inline-end: 0;
327
+ inset-block-end: calc(100% + 0.4rem);
328
+ max-inline-size: min(80%, 42rem);
329
+ margin: 0;
330
+ border-radius: calc(var(--radius) - 0.0625rem);
331
+ padding: 0.32rem 0.55rem;
332
+ background: var(--destructive);
333
+ box-shadow: 0 0.3rem 1rem oklch(0 0 0 / 18%);
334
+ color: var(--destructive-foreground);
335
+ font-size: 0.8125rem;
336
+ font-weight: 500;
337
+ line-height: 1.35;
338
+ list-style: none;
339
+ overflow-wrap: anywhere;
340
+ pointer-events: none;
341
+ }
342
+
343
+ .ridu-field-error-tooltip::after {
344
+ position: absolute;
345
+ inset-block-start: 100%;
346
+ inset-inline-end: 0.7rem;
347
+ inline-size: 0;
348
+ block-size: 0;
349
+ border: 0.32rem solid transparent;
350
+ border-block-start-color: var(--destructive);
351
+ content: "";
352
+ }
353
+
354
+ .ridu-field-error-tooltip > :where(ul, ol) {
355
+ margin: 0;
356
+ padding: 0;
357
+ list-style: none;
358
+ }
359
+
360
+ .ridu-field-error-tooltip li + li {
361
+ margin-block-start: 0.2rem;
362
+ }
363
+
364
+ @media (max-width: 34rem) {
365
+ .ridu-field-error-tooltip {
366
+ max-inline-size: calc(100% - 0.5rem);
367
+ }
368
+ }
369
+
311
370
  /*
312
371
  * Tone lives in the text and the wash, matching `banner`: no rails, chips, or icons.
313
372
  * `--normal-*` are sonner's own theming variables; its dark-theme rules paint the
package/src/app.svelte CHANGED
@@ -1,7 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { createBrowserRouter, RouterProvider } from "@hvniel/svelte-router";
3
- import { setAdminI18n, type AdminPlugin, type FieldPlugin } from "@riducms/plugin";
4
- import type { TranslationLanguage } from "@riducms/translations";
3
+ import { setAdminI18n } from "@riducms/plugin";
5
4
  import { TooltipProvider } from "@riducms/ui";
6
5
  import { untrack } from "svelte";
7
6
 
@@ -17,25 +16,24 @@
17
16
  import type { AdminClient } from "@admin/core/api/admin-client";
18
17
  import DevelopmentTools from "@admin/features/development/development-tools.svelte";
19
18
 
19
+ import type { AdminConfig } from "@riducms/plugin/admin";
20
20
  interface Props {
21
+ adminConfig?: AdminConfig;
21
22
  clientFactory: () => AdminClient;
22
23
  adminBasePath?: string;
23
- plugins?: readonly AdminPlugin[];
24
- fieldPlugins?: readonly FieldPlugin[];
25
- languages?: readonly TranslationLanguage[];
26
24
  }
27
-
28
- let {
29
- clientFactory,
30
- adminBasePath = "/admin",
31
- plugins = [],
32
- fieldPlugins = [],
33
- languages,
34
- }: Props = $props();
25
+ let { clientFactory, adminBasePath = "/admin", adminConfig = {} }: Props = $props();
35
26
  // The runtime owns the client and plugin registry selected when this admin instance is mounted.
36
27
  // svelte-ignore state_referenced_locally
37
28
  const runtime = setAdminRuntime(
38
- new AdminRuntime(clientFactory(), plugins, fieldPlugins, languages)
29
+ new AdminRuntime(
30
+ clientFactory(),
31
+ adminConfig.plugins,
32
+ adminConfig.languages,
33
+ undefined,
34
+ adminConfig.fields,
35
+ adminConfig
36
+ )
39
37
  );
40
38
  setAdminI18n(runtime.i18n);
41
39
  const notifications = setNotificationCenter(new NotificationCenter());
@@ -1,19 +1,20 @@
1
1
  <script lang="ts">
2
- import type { SchemaDatePickerAppearance } from "@riducms/protocol";
2
+ import type { SchemaDateFormat } from "@riducms/protocol";
3
+ import { fieldControlARIA } from "@riducms/ui";
3
4
  import type { Component } from "svelte";
4
5
 
5
6
  type ControlSize = "field" | "compact" | "toolbar";
6
7
  type DateValueControlProps = {
7
8
  id: string;
8
9
  name?: string;
9
- appearance?: SchemaDatePickerAppearance;
10
+ appearance?: SchemaDateFormat;
10
11
  value?: string;
11
12
  disabled?: boolean;
12
13
  readonly?: boolean;
13
14
  required?: boolean;
14
15
  invalid?: boolean;
16
+ hasDescription?: boolean;
15
17
  label?: string;
16
- describedBy?: string;
17
18
  class?: string;
18
19
  size?: ControlSize;
19
20
  onValueChange: (value: string) => void;
@@ -21,6 +22,9 @@
21
22
 
22
23
  let props: DateValueControlProps = $props();
23
24
  let Control = $state<Component<DateValueControlProps> | undefined>(undefined);
25
+ const controlARIA = $derived(
26
+ fieldControlARIA(props.id, props.hasDescription ?? false, props.invalid ?? false)
27
+ );
24
28
 
25
29
  void import("@admin/components/ui/date-value-control/date-value-control.svelte").then(
26
30
  ({ default: component }) => (Control = component)
@@ -30,7 +34,14 @@
30
34
  {#if Control !== undefined}
31
35
  <Control {...props} />
32
36
  {:else}
33
- <div id={props.id} class={props.class} role="status" aria-label={props.label} aria-busy="true">
37
+ <div
38
+ id={props.id}
39
+ class={props.class}
40
+ role="status"
41
+ aria-label={props.label}
42
+ aria-busy="true"
43
+ {...controlARIA}
44
+ >
34
45
  Loading date control…
35
46
  </div>
36
47
  {/if}
@@ -1,13 +1,13 @@
1
1
  <script lang="ts">
2
2
  import type { DateValue, Time } from "@internationalized/date";
3
- import type { SchemaDatePickerAppearance } from "@riducms/protocol";
3
+ import type { SchemaDateFormat } from "@riducms/protocol";
4
4
  import { DateField, TimeField } from "bits-ui";
5
5
  import CalendarIcon from "~icons/lucide/calendar";
6
6
 
7
7
  import { buttonVariants } from "@admin/components/ui/button";
8
8
  import DateValueCalendar from "@admin/components/ui/date-value-control/date-value-calendar.svelte";
9
9
  import { Popover, PopoverContent, PopoverTrigger } from "@admin/components/ui/popover";
10
- import { cn } from "@riducms/ui";
10
+ import { cn, fieldControlARIA } from "@riducms/ui";
11
11
  import { getAdminI18n } from "@riducms/plugin";
12
12
  import {
13
13
  datePickerFormValue,
@@ -20,28 +20,28 @@
20
20
  let {
21
21
  id,
22
22
  name,
23
- appearance = "dayOnly",
23
+ appearance = "date",
24
24
  value = "",
25
25
  disabled = false,
26
26
  readonly = false,
27
27
  required = false,
28
28
  invalid = false,
29
+ hasDescription = false,
29
30
  label,
30
- describedBy,
31
31
  class: className,
32
32
  size = "field",
33
33
  onValueChange,
34
34
  }: {
35
35
  id: string;
36
36
  name?: string;
37
- appearance?: SchemaDatePickerAppearance;
37
+ appearance?: SchemaDateFormat;
38
38
  value?: string;
39
39
  disabled?: boolean;
40
40
  readonly?: boolean;
41
41
  required?: boolean;
42
42
  invalid?: boolean;
43
+ hasDescription?: boolean;
43
44
  label?: string;
44
- describedBy?: string;
45
45
  class?: string;
46
46
  size?: ControlSize;
47
47
  onValueChange: (value: string) => void;
@@ -50,7 +50,7 @@
50
50
 
51
51
  const controlClass = $derived(
52
52
  cn(
53
- "flex w-full items-center rounded-lg border border-control-border bg-control text-foreground-strong transition-[background-color,border-color,color] duration-150 hover:border-control-border-hover focus-within:border-primary/65 aria-invalid:border-destructive/65 data-[disabled]:cursor-not-allowed data-[disabled]:border-control-border-disabled data-[disabled]:bg-control-disabled data-[disabled]:opacity-45 data-[readonly]:border-control-border-disabled data-[readonly]:bg-control-disabled",
53
+ "flex w-full items-center rounded-lg border border-control-border bg-control text-foreground-strong transition-[background-color,border-color,color] duration-150 hover:border-control-border-hover focus-within:border-primary/65 aria-invalid:!border-destructive/65 data-[disabled]:cursor-not-allowed data-[disabled]:border-control-border-disabled data-[disabled]:bg-control-disabled data-[disabled]:opacity-45 data-[readonly]:border-control-border-disabled data-[readonly]:bg-control-disabled",
54
54
  size === "field" && "h-10.5 px-3.25 text-[14.5px]",
55
55
  size === "compact" && "h-9 px-3 text-[13px]",
56
56
  size === "toolbar" && "h-[34px] px-2.5 text-[12.5px]",
@@ -58,9 +58,11 @@
58
58
  )
59
59
  );
60
60
  const selectedDate = $derived(datePickerValue(value, appearance, i18n.timeZone));
61
+ const controlARIA = $derived(fieldControlARIA(id, hasDescription, invalid));
61
62
  let open = $state(false);
62
63
 
63
64
  function updateDate(next: DateValue | undefined) {
65
+ if (disabled || readonly) return;
64
66
  onValueChange(datePickerFormValue(next, appearance, i18n.timeZone));
65
67
  }
66
68
 
@@ -70,18 +72,19 @@
70
72
  }
71
73
 
72
74
  function updateTime(next: Time | undefined) {
75
+ if (disabled || readonly) return;
73
76
  onValueChange(timeFieldFormValue(next));
74
77
  }
75
78
  </script>
76
79
 
77
- {#if appearance === "timeOnly"}
80
+ {#if appearance === "time"}
78
81
  <TimeField.Root
79
82
  value={timeFieldValue(value)}
80
83
  locale={i18n.language}
81
84
  {disabled}
82
85
  {readonly}
83
86
  {required}
84
- errorMessageId={invalid ? describedBy : undefined}
87
+ errorMessageId={controlARIA["aria-errormessage"]}
85
88
  onValueChange={updateTime}
86
89
  >
87
90
  <TimeField.Input
@@ -90,10 +93,8 @@
90
93
  class={cn(controlClass, "gap-0.5 select-none")}
91
94
  data-disabled={disabled ? "" : undefined}
92
95
  data-readonly={readonly ? "" : undefined}
93
- aria-invalid={invalid}
96
+ {...controlARIA}
94
97
  aria-label={label}
95
- aria-describedby={describedBy}
96
- aria-errormessage={invalid ? describedBy : undefined}
97
98
  >
98
99
  {#snippet children({ segments })}
99
100
  {#each segments as { part, value: segmentValue }, index (`${part}:${index}`)}
@@ -113,26 +114,25 @@
113
114
  <DateField.Root
114
115
  value={selectedDate}
115
116
  locale={i18n.language}
116
- granularity={appearance === "dayAndTime" ? "minute" : "day"}
117
+ granularity={appearance === "date-time" ? "minute" : "day"}
117
118
  {disabled}
118
119
  {readonly}
119
120
  {required}
120
- errorMessageId={invalid ? describedBy : undefined}
121
+ errorMessageId={controlARIA["aria-errormessage"]}
121
122
  onValueChange={updateDate}
122
123
  >
123
124
  <div
124
125
  class={controlClass}
125
126
  data-disabled={disabled ? "" : undefined}
126
127
  data-readonly={readonly ? "" : undefined}
127
- aria-invalid={invalid}
128
+ aria-invalid={controlARIA["aria-invalid"]}
128
129
  >
129
130
  <DateField.Input
130
131
  {id}
131
132
  {name}
132
133
  class="flex min-w-0 flex-1 items-center gap-0.5 select-none"
133
134
  aria-label={label}
134
- aria-describedby={describedBy}
135
- aria-errormessage={invalid ? describedBy : undefined}
135
+ {...controlARIA}
136
136
  >
137
137
  {#snippet children({ segments })}
138
138
  {#each segments as { part, value: segmentValue }, index (`${part}:${index}`)}
@@ -19,7 +19,7 @@
19
19
  data-slot="select-trigger"
20
20
  data-size={size}
21
21
  class={cn(
22
- "gap-2 rounded-[3px] border border-control-border bg-control px-3 text-[13.5px] text-foreground-strong transition-[background-color,border-color,color] duration-150 hover:border-control-border-hover focus-visible:border-primary/65 aria-invalid:border-destructive/65 data-[placeholder]:text-foreground-placeholder data-[size=field]:h-10 data-[size=compact]:h-9 data-[size=toolbar]:h-[34px] data-[size=toolbar]:px-2.5 data-[size=toolbar]:text-[12.5px] *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-3.5 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:border-control-border-disabled disabled:bg-control-disabled disabled:opacity-45 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
22
+ "gap-2 rounded-[3px] border border-control-border bg-control px-3 text-[13.5px] text-foreground-strong transition-[background-color,border-color,color] duration-150 hover:border-control-border-hover focus-visible:border-primary/65 aria-invalid:!border-destructive/65 data-[placeholder]:text-foreground-placeholder data-[size=field]:h-10 data-[size=compact]:h-9 data-[size=toolbar]:h-[34px] data-[size=toolbar]:px-2.5 data-[size=toolbar]:text-[12.5px] *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-3.5 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:border-control-border-disabled disabled:bg-control-disabled disabled:opacity-45 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
23
23
  className
24
24
  )}
25
25
  {...restProps}
@@ -0,0 +1,132 @@
1
+ import { resolveBlockTypes } from "@riducms/protocol";
2
+ import type {
3
+ SchemaBlockType,
4
+ SchemaEmbeddedTree,
5
+ SchemaEmbeddedTreeCase,
6
+ SchemaField,
7
+ ValidationIssue,
8
+ } from "@riducms/protocol";
9
+
10
+ export interface EmbeddedOccurrence {
11
+ tree: SchemaEmbeddedTree;
12
+ case: SchemaEmbeddedTreeCase;
13
+ block: SchemaBlockType;
14
+ payload: Record<string, unknown>;
15
+ path: string;
16
+ identity: string | undefined;
17
+ }
18
+
19
+ /** Only descriptor-owned tree edges are visited. Payloads return to ordinary field traversal. */
20
+ export function embeddedOccurrences(field: SchemaField, value: unknown, path = field.path) {
21
+ const occurrences: EmbeddedOccurrence[] = [];
22
+ const issues: ValidationIssue[] = [];
23
+ let work = 0;
24
+ const fail = (path: string, message: string, code = "invalid_embedded") =>
25
+ issues.push({ path, message, code });
26
+ if (value === null || value === undefined) return { occurrences, issues };
27
+ for (const tree of field.plugin?.embeddedTrees ?? []) {
28
+ if (tree.version !== 1) {
29
+ fail(path, `Unsupported embedded tree metadata version ${tree.version}`);
30
+ continue;
31
+ }
32
+ let root: unknown = value;
33
+ let rootPath = path;
34
+ for (const segment of tree.root) {
35
+ root = isEmbeddedRecord(root) ? root[segment] : undefined;
36
+ rootPath += `.${segment}`;
37
+ }
38
+ const keys = new Set<string>();
39
+ const visit = (node: unknown, nodePath: string, depth: number) => {
40
+ if (++work > 10_000 || depth > 64) {
41
+ fail(nodePath, "Embedded field traversal budget exceeded", "embedded_budget");
42
+ return;
43
+ }
44
+ if (!isEmbeddedRecord(node)) {
45
+ fail(nodePath, "Embedded tree node must be an object");
46
+ return;
47
+ }
48
+ const branch = tree.cases.find((candidate) => candidate.tagValue === node[tree.tag]);
49
+ if (branch !== undefined) {
50
+ const payload = node[branch.payload];
51
+ const payloadPath = `${nodePath}.${branch.payload}`;
52
+ if (!isEmbeddedRecord(payload)) {
53
+ fail(payloadPath, "Embedded payload must be an object");
54
+ } else {
55
+ const block = resolveBlockTypes(branch).find(
56
+ (candidate) => candidate.slug === payload[branch.discriminator]
57
+ );
58
+ const identity = payload[branch.identity];
59
+ if (identity !== undefined && (typeof identity !== "string" || identity.trim() === ""))
60
+ fail(
61
+ `${payloadPath}.${branch.identity}`,
62
+ "Embedded identity must be a non-empty string",
63
+ "invalid_key"
64
+ );
65
+ if (typeof identity === "string") {
66
+ if (keys.has(identity))
67
+ fail(
68
+ `${payloadPath}.${branch.identity}`,
69
+ "Embedded identity must be unique",
70
+ "duplicate_key"
71
+ );
72
+ keys.add(identity);
73
+ }
74
+ if (block === undefined)
75
+ fail(
76
+ `${payloadPath}.${branch.discriminator}`,
77
+ "Restore the missing embedded schema or migrate the document before saving.",
78
+ "unknown_block_schema"
79
+ );
80
+ else
81
+ occurrences.push({
82
+ tree,
83
+ case: branch,
84
+ block,
85
+ payload,
86
+ path: payloadPath,
87
+ identity: typeof identity === "string" ? identity : undefined,
88
+ });
89
+ }
90
+ }
91
+ const children = node[tree.children];
92
+ if (children === undefined || children === null) return;
93
+ if (!Array.isArray(children)) {
94
+ fail(`${nodePath}.${tree.children}`, "Embedded children must be an array");
95
+ return;
96
+ }
97
+ for (const [index, child] of children.entries()) {
98
+ if (work > 10_000) break;
99
+ visit(child, `${nodePath}.${tree.children}.${index}`, depth + 1);
100
+ }
101
+ };
102
+ if (Array.isArray(root)) {
103
+ for (const [index, node] of root.entries()) {
104
+ if (work > 10_000) break;
105
+ visit(node, `${rootPath}.${index}`, tree.root.length + 1);
106
+ }
107
+ } else visit(root, rootPath, tree.root.length + 1);
108
+ }
109
+ return { occurrences, issues };
110
+ }
111
+
112
+ /** Mutates only schema-owned payloads in an already detached value. */
113
+ export function transformEmbeddedPayloads(
114
+ field: SchemaField,
115
+ value: unknown,
116
+ path: string,
117
+ transform: (occurrence: EmbeddedOccurrence) => Record<string, unknown>
118
+ ) {
119
+ const result = embeddedOccurrences(field, value, path);
120
+ if (result.issues.length > 0)
121
+ throw new Error(`${result.issues[0]?.path}: ${result.issues[0]?.message}`);
122
+ for (const occurrence of result.occurrences) {
123
+ const transformed = { ...transform(occurrence) };
124
+ for (const key of Object.keys(occurrence.payload)) delete occurrence.payload[key];
125
+ Object.assign(occurrence.payload, transformed);
126
+ }
127
+ return value;
128
+ }
129
+
130
+ export function isEmbeddedRecord(value: unknown): value is Record<string, unknown> {
131
+ return typeof value === "object" && value !== null && !Array.isArray(value);
132
+ }