@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
@@ -1,4 +1,7 @@
1
+ import { resolveBlockTypes } from "@riducms/protocol";
2
+ import { embeddedOccurrences } from "@admin/core/forms/embedded-fields";
1
3
  import type { AdminI18n } from "@riducms/plugin";
4
+ import { createAdminI18n } from "@riducms/translations";
2
5
  import type { SchemaField, ValidationIssue } from "@riducms/protocol";
3
6
 
4
7
  import { initialFormValues, type FormValues } from "@admin/core/forms/form-schema";
@@ -92,7 +95,7 @@ function resolveFieldLabel(
92
95
  ),
93
96
  ]
94
97
  : labels;
95
- const blockFields = field.blocks.types.flatMap((block) => block.fields);
98
+ const blockFields = resolveBlockTypes(field.blocks).flatMap((block) => block.fields);
96
99
  return resolveFieldLabel(
97
100
  blockFields,
98
101
  isIndex(index) ? childSegments : remaining,
@@ -126,8 +129,14 @@ function validateFields(
126
129
  const defaults = requireMissing ? initialFormValues([field]) : {};
127
130
  if (Object.hasOwn(defaults, field.name)) {
128
131
  validateFields([field], defaults, prefix, true, issues, include, i18n);
129
- } else if (requireMissing && field.required) {
132
+ } else if (requireMissing && field.required && !field.dynamicDefault) {
130
133
  issues.push(requiredIssue(field, path, i18n));
134
+ } else if (
135
+ requireMissing &&
136
+ !field.dynamicDefault &&
137
+ (field.type === "text-list" || field.type === "number-list")
138
+ ) {
139
+ validatePrimitiveList(field, [], path, issues, i18n);
131
140
  } else if (requireMissing && field.type === "array" && (field.nested?.minRows ?? 0) > 0) {
132
141
  issues.push(
133
142
  issue(
@@ -146,6 +155,8 @@ function validateFields(
146
155
  }
147
156
  if (value === null || value === undefined) {
148
157
  if (field.required) issues.push(requiredIssue(field, path, i18n));
158
+ else if (field.type === "text-list" || field.type === "number-list")
159
+ validatePrimitiveList(field, [], path, issues, i18n);
149
160
  else if (field.type === "array" && (field.nested?.minRows ?? 0) > 0) {
150
161
  issues.push(
151
162
  issue(
@@ -164,6 +175,10 @@ function validateFields(
164
175
  }
165
176
 
166
177
  switch (field.type) {
178
+ case "text-list":
179
+ case "number-list":
180
+ validatePrimitiveList(field, value, path, issues, i18n);
181
+ break;
167
182
  case "text":
168
183
  case "code":
169
184
  case "textarea":
@@ -242,14 +257,75 @@ function validateFields(
242
257
  case "blocks":
243
258
  validateBlocks(field, value, path, issues, include, i18n);
244
259
  break;
260
+ case "plugin": {
261
+ const embedded = embeddedOccurrences(field, value, path);
262
+ issues.push(...embedded.issues);
263
+ for (const occurrence of embedded.occurrences)
264
+ validateFields(
265
+ occurrence.block.fields,
266
+ occurrence.payload,
267
+ occurrence.path,
268
+ true,
269
+ issues,
270
+ include,
271
+ i18n
272
+ );
273
+ break;
274
+ }
245
275
  case "json":
246
- case "plugin":
247
- // Plugin validators and internal populated-document checks remain server-authoritative.
276
+ // Plugin envelope validators and populated-document checks remain server-authoritative.
248
277
  break;
249
278
  }
250
279
  }
251
280
  }
252
281
 
282
+ function validatePrimitiveList(
283
+ field: SchemaField,
284
+ value: unknown,
285
+ path: string,
286
+ issues: ValidationIssue[],
287
+ i18n: AdminI18n = createAdminI18n()
288
+ ) {
289
+ const label = localizedFieldLabel(field, i18n);
290
+ const report = (
291
+ code: string,
292
+ key: Parameters<AdminI18n["t"]>[0],
293
+ variables: Readonly<Record<string, string | number>> = {}
294
+ ) => issues.push(issue(code, path, i18n.t(key, { label, ...variables })));
295
+ if (!Array.isArray(value)) {
296
+ report("invalid_type", "errors:array");
297
+ return;
298
+ }
299
+ const minimum = Math.max(field.required ? 1 : 0, field.list?.minRows ?? 0);
300
+ if (value.length < minimum)
301
+ report(field.required && value.length === 0 ? "required" : "min_rows", "errors:listMinItems", {
302
+ count: minimum,
303
+ });
304
+ if (field.list?.maxRows && value.length > field.list.maxRows)
305
+ report("max_rows", "errors:listMaxItems", { count: field.list.maxRows });
306
+ for (const [index, item] of value.entries()) {
307
+ const number = index + 1;
308
+ if (field.type === "text-list") {
309
+ if (typeof item !== "string") {
310
+ report("invalid_type", "errors:listItemString", { number });
311
+ continue;
312
+ }
313
+ const length = Array.from(item).length;
314
+ if (length < (field.text?.minLength ?? 0))
315
+ report("min_length", "errors:listItemMinLength", { number, count: field.text!.minLength! });
316
+ if (field.text?.maxLength !== undefined && length > field.text.maxLength)
317
+ report("max_length", "errors:listItemMaxLength", { number, count: field.text.maxLength });
318
+ } else if (typeof item !== "number" || !Number.isFinite(item))
319
+ report("invalid_number", "errors:listItemNumber", { number });
320
+ else {
321
+ if (field.number?.min !== undefined && item < field.number.min)
322
+ report("min_value", "errors:listItemMin", { number, min: field.number.min });
323
+ if (field.number?.max !== undefined && item > field.number.max)
324
+ report("max_value", "errors:listItemMax", { number, max: field.number.max });
325
+ }
326
+ }
327
+ }
328
+
253
329
  function validateString(
254
330
  field: SchemaField,
255
331
  value: unknown,
@@ -330,16 +406,16 @@ function validateSelect(
330
406
  if (field.required) issues.push(requiredIssue(field, path, i18n));
331
407
  return;
332
408
  }
333
- if (!field.select?.choices.some((choice) => choice.value === value)) {
409
+ if (!field.select?.options.some((option) => option.value === value)) {
334
410
  issues.push(
335
411
  issue(
336
- "invalid_choice",
412
+ "invalid_option",
337
413
  path,
338
414
  translate(
339
415
  i18n,
340
- "errors:allowedChoice",
416
+ "errors:allowedOption",
341
417
  { label: localizedFieldLabel(field, i18n) },
342
- `${localizedFieldLabel(field, i18n)} is not an allowed choice`
418
+ `${localizedFieldLabel(field, i18n)} is not an allowed option`
343
419
  )
344
420
  )
345
421
  );
@@ -373,7 +449,7 @@ function validateSelectMany(
373
449
  return;
374
450
  }
375
451
 
376
- const choices = new Set((field.select?.choices ?? []).map((choice) => choice.value));
452
+ const options = new Set((field.select?.options ?? []).map((option) => option.value));
377
453
  const seen = new Set<string>();
378
454
  for (const [index, candidate] of value.entries()) {
379
455
  const candidatePath = `${path}.${index}`;
@@ -392,16 +468,16 @@ function validateSelectMany(
392
468
  );
393
469
  continue;
394
470
  }
395
- if (!choices.has(candidate)) {
471
+ if (!options.has(candidate)) {
396
472
  issues.push(
397
473
  issue(
398
- "invalid_choice",
474
+ "invalid_option",
399
475
  candidatePath,
400
476
  translate(
401
477
  i18n,
402
- "errors:allowedChoice",
478
+ "errors:allowedOption",
403
479
  { label: localizedFieldLabel(field, i18n) },
404
- `${localizedFieldLabel(field, i18n)} is not an allowed choice`
480
+ `${localizedFieldLabel(field, i18n)} is not an allowed option`
405
481
  )
406
482
  )
407
483
  );
@@ -409,13 +485,13 @@ function validateSelectMany(
409
485
  if (seen.has(candidate)) {
410
486
  issues.push(
411
487
  issue(
412
- "duplicate_choice",
488
+ "duplicate_option",
413
489
  candidatePath,
414
490
  translate(
415
491
  i18n,
416
- "errors:duplicateChoice",
492
+ "errors:duplicateOption",
417
493
  { label: localizedFieldLabel(field, i18n) },
418
- `${localizedFieldLabel(field, i18n)} must not contain duplicate choices`
494
+ `${localizedFieldLabel(field, i18n)} must not contain duplicate options`
419
495
  )
420
496
  )
421
497
  );
@@ -684,6 +760,37 @@ function validateBlocks(
684
760
  )
685
761
  );
686
762
  }
763
+ const minRows = field.blocks.minRows ?? 0;
764
+ const maxRows = field.blocks.maxRows ?? 0;
765
+ if (value.length < minRows && !(field.required && value.length === 0)) {
766
+ issues.push(
767
+ issue(
768
+ "min_rows",
769
+ path,
770
+ translate(
771
+ i18n,
772
+ "errors:minRows",
773
+ { label: localizedFieldLabel(field, i18n), count: minRows },
774
+ `${localizedFieldLabel(field, i18n)} must contain at least ${minRows} rows`
775
+ )
776
+ )
777
+ );
778
+ }
779
+ if (maxRows > 0 && value.length > maxRows) {
780
+ issues.push(
781
+ issue(
782
+ "max_rows",
783
+ path,
784
+ translate(
785
+ i18n,
786
+ "errors:maxRows",
787
+ { label: localizedFieldLabel(field, i18n), count: maxRows },
788
+ `${localizedFieldLabel(field, i18n)} must contain no more than ${maxRows} rows`
789
+ )
790
+ )
791
+ );
792
+ }
793
+
687
794
  for (const [index, row] of value.entries()) {
688
795
  const rowPath = `${path}.${index}`;
689
796
  if (!isRecord(row)) {
@@ -696,7 +803,9 @@ function validateBlocks(
696
803
  );
697
804
  continue;
698
805
  }
699
- const block = field.blocks.types.find((candidate) => candidate.key === row.blockType);
806
+ const block = resolveBlockTypes(field.blocks).find(
807
+ (candidate) => candidate.slug === row.blockType
808
+ );
700
809
  if (block === undefined) {
701
810
  issues.push(
702
811
  issue(
@@ -748,3 +857,60 @@ function translate(
748
857
  function localizedFieldLabel(field: SchemaField, i18n?: AdminI18n) {
749
858
  return i18n?.text(field.admin.label, field.admin.labelTranslations) ?? field.admin.label;
750
859
  }
860
+
861
+ /** Unknown stored variants require schema recovery even if a UI edit removed the row. */
862
+ export function unknownBlockIssues(
863
+ fields: readonly SchemaField[],
864
+ values: FormValues,
865
+ i18n?: AdminI18n,
866
+ prefix = ""
867
+ ): ValidationIssue[] {
868
+ const issues: ValidationIssue[] = [];
869
+ for (const field of fields) {
870
+ const value = values[field.name];
871
+ const path = joinPath(prefix, field.name);
872
+ if (field.type === "group" && isRecord(value))
873
+ issues.push(...unknownBlockIssues(field.nested?.fields ?? [], value, i18n, path));
874
+ if (field.type === "plugin") {
875
+ const embedded = embeddedOccurrences(field, value, path);
876
+ issues.push(...embedded.issues);
877
+ for (const occurrence of embedded.occurrences)
878
+ issues.push(
879
+ ...unknownBlockIssues(occurrence.block.fields, occurrence.payload, i18n, occurrence.path)
880
+ );
881
+ }
882
+ if (!Array.isArray(value)) continue;
883
+ if (field.type === "array") {
884
+ value.forEach((row, index) => {
885
+ if (isRecord(row))
886
+ issues.push(
887
+ ...unknownBlockIssues(field.nested?.fields ?? [], row, i18n, `${path}.${index}`)
888
+ );
889
+ });
890
+ } else if (field.type === "blocks") {
891
+ value.forEach((row, index) => {
892
+ const block = isRecord(row)
893
+ ? resolveBlockTypes(field.blocks).find((candidate) => candidate.slug === row.blockType)
894
+ : undefined;
895
+ if (block === undefined)
896
+ issues.push(
897
+ issue(
898
+ "unknown_block_schema",
899
+ `${path}.${index}.blockType`,
900
+ translate(
901
+ i18n,
902
+ "errors:unknownBlockRecovery",
903
+ {},
904
+ "Restore the missing block schema or migrate the document before saving."
905
+ )
906
+ )
907
+ );
908
+ else
909
+ issues.push(
910
+ ...unknownBlockIssues(block.fields, row as FormValues, i18n, `${path}.${index}`)
911
+ );
912
+ });
913
+ }
914
+ }
915
+ return issues;
916
+ }
@@ -0,0 +1,317 @@
1
+ import type { AdminClient } from "@admin/core/api/admin-client";
2
+ import type {
3
+ LiveValidationEnvelope,
4
+ LiveValidationRequest,
5
+ ValidationIssue,
6
+ } from "@riducms/protocol";
7
+ import type { FieldLiveValidation } from "@riducms/plugin";
8
+ import type { FormController } from "@admin/core/forms/form-controller.svelte";
9
+ import { indexFieldValues, correlateFormIssues } from "@admin/core/forms/form-issue-correlation";
10
+ import { captureFieldOccurrence } from "@admin/core/forms/field-occurrence";
11
+
12
+ export type LiveValidationTransport = (
13
+ input: LiveValidationRequest,
14
+ signal: AbortSignal
15
+ ) => Promise<LiveValidationEnvelope>;
16
+
17
+ type Feedback = {
18
+ path: string;
19
+ status: FieldLiveValidation["status"];
20
+ issues: ValidationIssue[];
21
+ };
22
+
23
+ /** One form owns advisory requests; save issues never enter this state. */
24
+ export class LiveValidationController {
25
+ #feedback = $state.raw<ReadonlyMap<string, Feedback>>(new Map());
26
+ #active = new Set<string>();
27
+ #unavailableInputs = new Map<
28
+ symbol,
29
+ { resolve: () => string | undefined; value: string | undefined }
30
+ >();
31
+ #generation = 0;
32
+ #request?: AbortController;
33
+ #timer?: ReturnType<typeof setTimeout>;
34
+ #deadline?: ReturnType<typeof setTimeout>;
35
+ #transport?: LiveValidationTransport;
36
+
37
+ constructor(private form: FormController) {}
38
+
39
+ configure(transport: LiveValidationTransport) {
40
+ this.reset();
41
+ this.#transport = transport;
42
+ }
43
+
44
+ request(input: LiveValidationRequest, signal: AbortSignal) {
45
+ if (!this.#transport) throw new Error("Live validation is unavailable in this form.");
46
+ return this.#transport(input, signal);
47
+ }
48
+
49
+ get available() {
50
+ return this.#transport !== undefined;
51
+ }
52
+ get inputAvailable(): boolean {
53
+ return (
54
+ this.#unavailableInputs.size === 0 &&
55
+ this.form.enclosingLiveInputAvailable &&
56
+ serializableInput(this.form.values) &&
57
+ indexFieldValues(this.form.schemaFields, this.form.values).every(({ schema, value }) => {
58
+ if (schema?.type !== "text-list" && schema?.type !== "number-list") return true;
59
+ if (value === null || value === undefined) return true;
60
+ // Numeric list controls retain unfinished text in the form itself. No
61
+ // activated check may mistake that buffer for a usable typed Root value.
62
+ return (
63
+ Array.isArray(value) &&
64
+ Array.from(value).every((item) =>
65
+ schema.type === "text-list"
66
+ ? typeof item === "string"
67
+ : typeof item === "number" && Number.isFinite(item)
68
+ )
69
+ );
70
+ })
71
+ );
72
+ }
73
+
74
+ /** A control's unfinished buffer is not its last successfully decoded value. */
75
+ inputUnavailable(path: string) {
76
+ let resolve: () => string | undefined;
77
+ try {
78
+ resolve = captureFieldOccurrence(this.form, path).resolve;
79
+ } catch {
80
+ return () => false;
81
+ }
82
+ const lease = Symbol("unfinished-input");
83
+ this.#unavailableInputs.set(lease, { resolve, value: JSON.stringify(this.form.get(path)) });
84
+ this.#schedule(path);
85
+ return () => this.#unavailableInputs.delete(lease);
86
+ }
87
+
88
+ get issues() {
89
+ return [...this.#feedback.values()].flatMap((state) => state.issues);
90
+ }
91
+
92
+ forField(path: string): FieldLiveValidation {
93
+ const status =
94
+ [...this.#feedback.values()].find((state) => state.path === path)?.status ?? "idle";
95
+ return { status, retry: () => this.flush(path) };
96
+ }
97
+
98
+ /** Any edit invalidates activated checks: their Go code can read any sibling/root value. */
99
+ changed(path?: string) {
100
+ for (const [lease, unavailable] of this.#unavailableInputs) {
101
+ const current = unavailable.resolve();
102
+ // An ancestor write can merely reorder a still-mounted unfinished control.
103
+ // Release only a removed occurrence or a real replacement of its value.
104
+ if (
105
+ current === undefined ||
106
+ path === current ||
107
+ JSON.stringify(this.form.get(current)) !== unavailable.value
108
+ )
109
+ this.#unavailableInputs.delete(lease);
110
+ }
111
+ this.#schedule(path);
112
+ }
113
+
114
+ #schedule(path?: string) {
115
+ if (!this.available || this.form.submitting || !this.form.editorScopeActive) return;
116
+ this.#cancel();
117
+ const locations = this.#locations();
118
+ const current = new Set(locations.map((location) => location.token));
119
+ this.#active = new Set([...this.#active].filter((token) => current.has(token)));
120
+ if (path !== undefined)
121
+ for (const location of locations)
122
+ if (related(path, location.path)) this.#active.add(location.token);
123
+ const status = this.inputAvailable ? "pending" : "skipped";
124
+ this.#feedback = new Map(
125
+ locations
126
+ .filter(({ token }) => this.#active.has(token))
127
+ .map(({ token, path }) => [token, { path, status, issues: [] }])
128
+ );
129
+ if (this.#active.size && status === "pending") this.#timer = setTimeout(() => this.#run(), 500);
130
+ }
131
+
132
+ flush(path: string) {
133
+ if (
134
+ [...this.#feedback.values()].some(
135
+ (state) =>
136
+ (state.status === "pending" || state.status === "failed") && related(state.path, path)
137
+ )
138
+ )
139
+ this.#run();
140
+ }
141
+
142
+ /** Saving cancels advisory work but retains activation for later user edits. */
143
+ suspend() {
144
+ this.#cancel();
145
+ this.#feedback = new Map();
146
+ }
147
+
148
+ reset() {
149
+ this.suspend();
150
+ this.#active.clear();
151
+ this.#unavailableInputs.clear();
152
+ }
153
+
154
+ /** Binding revocation can be discovered during a derived read. Abort synchronously. */
155
+ dispose() {
156
+ this.#cancel();
157
+ this.#active.clear();
158
+ const generation = this.#generation;
159
+ queueMicrotask(() => {
160
+ if (generation === this.#generation) this.#feedback = new Map();
161
+ });
162
+ }
163
+
164
+ #cancel() {
165
+ this.#generation += 1;
166
+ this.#request?.abort();
167
+ this.#request = undefined;
168
+ if (this.#timer !== undefined) clearTimeout(this.#timer);
169
+ this.#timer = undefined;
170
+ if (this.#deadline !== undefined) clearTimeout(this.#deadline);
171
+ this.#deadline = undefined;
172
+ }
173
+
174
+ #locations() {
175
+ return indexFieldValues(this.form.schemaFields, this.form.values).filter(
176
+ ({ schema, path }) =>
177
+ schema?.liveValidation &&
178
+ !schema.admin.readOnly &&
179
+ this.form.includesLiveValidation(path, schema.path)
180
+ );
181
+ }
182
+
183
+ async #run() {
184
+ if (
185
+ !this.available ||
186
+ this.form.submitting ||
187
+ !this.form.editorScopeActive ||
188
+ !this.inputAvailable
189
+ )
190
+ return;
191
+ this.#cancel();
192
+ const generation = this.#generation;
193
+ const request = new AbortController();
194
+ this.#request = request;
195
+ const locations = this.#locations().filter(({ token }) => this.#active.has(token));
196
+ if (!locations.length) return;
197
+ this.#feedback = new Map(
198
+ locations.map(({ token, path }) => [token, { path, status: "pending", issues: [] }])
199
+ );
200
+ const current = () =>
201
+ generation === this.#generation &&
202
+ !request.signal.aborted &&
203
+ !this.form.submitting &&
204
+ this.form.editorScopeActive;
205
+ try {
206
+ const values = this.form.snapshot();
207
+ let data: LiveValidationRequest["data"];
208
+ try {
209
+ data = this.form.liveValidationData();
210
+ } catch {
211
+ // A malformed plugin envelope has no usable submitted representation yet.
212
+ this.#feedback = new Map(
213
+ [...this.#feedback].map(([token, state]) => [
214
+ token,
215
+ { ...state, status: "skipped", issues: [] },
216
+ ])
217
+ );
218
+ return;
219
+ }
220
+ // Keep every request within the server's bounded field count, including large forms.
221
+ for (let offset = 0; offset < locations.length; offset += 64) {
222
+ const batch = locations.slice(offset, offset + 64);
223
+ // Leave room above the server's five-second evaluation bound for transport.
224
+ this.#deadline = setTimeout(() => {
225
+ if (!current()) return;
226
+ this.#cancel();
227
+ this.#failPending();
228
+ }, 8_000);
229
+ const result = await this.request(
230
+ {
231
+ ...(this.form.resource?.id === undefined ? {} : { id: this.form.resource.id }),
232
+ data,
233
+ fields: batch.map(({ path }) => path),
234
+ },
235
+ request.signal
236
+ );
237
+ if (!current()) return;
238
+ if (this.#deadline !== undefined) clearTimeout(this.#deadline);
239
+ this.#deadline = undefined;
240
+ const feedback = new Map(this.#feedback);
241
+ for (const location of batch) {
242
+ const evaluation = result.evaluations.find((item) => item.path === location.path);
243
+ // A missing evaluation cannot silently become a successful check.
244
+ feedback.set(location.token, {
245
+ path: location.path,
246
+ status: evaluation?.status ?? "failed",
247
+ issues:
248
+ evaluation === undefined
249
+ ? []
250
+ : correlateFormIssues(
251
+ this.form.schemaFields,
252
+ values,
253
+ this.form.values,
254
+ evaluation.issues
255
+ ),
256
+ });
257
+ }
258
+ this.#feedback = feedback;
259
+ }
260
+ } catch {
261
+ if (!current()) return;
262
+ this.#failPending();
263
+ } finally {
264
+ if (this.#request === request) {
265
+ this.#request = undefined;
266
+ if (this.#deadline !== undefined) clearTimeout(this.#deadline);
267
+ this.#deadline = undefined;
268
+ }
269
+ }
270
+ }
271
+
272
+ #failPending() {
273
+ this.#feedback = new Map(
274
+ [...this.#feedback].map(([token, state]) => [
275
+ token,
276
+ state.status === "pending" ? { ...state, status: "failed", issues: [] } : state,
277
+ ])
278
+ );
279
+ }
280
+ }
281
+
282
+ function related(left: string, right: string) {
283
+ return left === right || left.startsWith(`${right}.`) || right.startsWith(`${left}.`);
284
+ }
285
+
286
+ /** JSON would coerce NaN/Infinity to null, incorrectly making malformed input look empty. */
287
+ function serializableInput(value: unknown, seen = new Set<object>(), depth = 0): boolean {
288
+ if (depth > 100) return false;
289
+ if (typeof value === "number") return Number.isFinite(value);
290
+ if (
291
+ value === null ||
292
+ value === undefined ||
293
+ typeof value === "string" ||
294
+ typeof value === "boolean"
295
+ )
296
+ return true;
297
+ if (typeof value !== "object" || seen.has(value)) return false;
298
+ seen.add(value);
299
+ const available = Object.values(value).every((child) =>
300
+ serializableInput(child, seen, depth + 1)
301
+ );
302
+ seen.delete(value);
303
+ return available;
304
+ }
305
+
306
+ /** Connect a document form to the same authenticated SDK used for its writes. */
307
+ export function connectDocumentLiveValidation(form: FormController, client: AdminClient) {
308
+ form.configureLiveValidation((input, signal) => {
309
+ const resource = form.resource;
310
+ if (!resource) throw new Error("No document is open for live validation.");
311
+ const options = { signal, locale: form.contentLocale };
312
+ const { id: _id, ...globalInput } = input;
313
+ return resource.global
314
+ ? client.globalLiveValidation(resource.collection, globalInput, options)
315
+ : client.collectionLiveValidation(resource.collection, input, options);
316
+ });
317
+ }