filegrc 0.13.0 → 0.13.2

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/src/server.js CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  withGitCommandDeadline
38
38
  } from "./git.js";
39
39
  import { normalizeResourceMutation, serializeWorkspaceMutation } from "./mutation.js";
40
+ import { renderMarkdown } from "./markdown.js";
40
41
  import {
41
42
  approveReportingRouteSet,
42
43
  assessReportingRouteSets,
@@ -64,7 +65,7 @@ import { activatePolicies } from "./policy-activation.js";
64
65
  import { resolveProgram } from "./program.js";
65
66
  import { applyReconciliation, dismissReconciliation, planReconciliation } from "./reconciliation.js";
66
67
  import { resourceReviewRevisions } from "./retention.js";
67
- import { createAppBootstrap, createAppState, createAppStateSection, createResourceDetail } from "./state.js";
68
+ import { createAppBootstrap, createAppState, createAppStateSection, createResourceDetail, createResourceHistory } from "./state.js";
68
69
  import { setupWorkspace } from "./setup.js";
69
70
  import { collectTimings, measureTiming, timingEnabled } from "./timing.js";
70
71
  import { fingerprintWorkspace } from "./validate.js";
@@ -86,6 +87,10 @@ const STATE_SECTION_GIT_DEADLINE_MS = 10_000;
86
87
  export function createFilegrcServer(input = process.cwd(), options = {}) {
87
88
  const stateSessions = new Map();
88
89
  const fileDigestCache = new Map();
90
+ let bootstrapSnapshotPromise = null;
91
+ let stateInvalidationGeneration = 0;
92
+ let activeStateMutations = 0;
93
+ let stateMutationWaiters = [];
89
94
  return createHttpServer(async (request, response) => {
90
95
  const requestStarted = performance.now();
91
96
  if (timingEnabled()) {
@@ -107,7 +112,22 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
107
112
  const requestOptions = {
108
113
  ...options,
109
114
  programId: url.searchParams.get("programId") || undefined,
110
- invalidateStateSessions: () => invalidateStateSessions(stateSessions)
115
+ fastResponse: prefersFastMutation(request),
116
+ beginStateMutation: () => {
117
+ stateInvalidationGeneration += 1;
118
+ activeStateMutations += 1;
119
+ invalidateStateSessions(stateSessions);
120
+ },
121
+ endStateMutation: () => {
122
+ stateInvalidationGeneration += 1;
123
+ activeStateMutations -= 1;
124
+ invalidateStateSessions(stateSessions);
125
+ if (activeStateMutations === 0) {
126
+ const waiters = stateMutationWaiters;
127
+ stateMutationWaiters = [];
128
+ for (const resolve of waiters) resolve();
129
+ }
130
+ }
111
131
  };
112
132
  if (["POST", "PUT", "DELETE"].includes(request.method) && !sameOrigin(request)) {
113
133
  return json(response, 403, { error: "Cross-origin writes are not allowed." });
@@ -133,45 +153,51 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
133
153
  return json(response, 403, { error: "Cross-origin state requests are not allowed." });
134
154
  }
135
155
  const deadlineAt = performance.now() + STATE_SECTION_GIT_DEADLINE_MS;
136
- const [snapshot, repositorySignature] = await withGitCommandDeadline(deadlineAt, () => (
137
- serializeWorkspaceMutation(input, (root) => Promise.all([
138
- fingerprintWorkspace(root, {
156
+ for (let attempt = 0; attempt < 3; attempt += 1) {
157
+ if (activeStateMutations > 0) {
158
+ await awaitWithinDeadline(new Promise((resolve) => stateMutationWaiters.push(resolve)), deadlineAt);
159
+ }
160
+ const generation = stateInvalidationGeneration;
161
+ if (!bootstrapSnapshotPromise) {
162
+ bootstrapSnapshotPromise = withGitCommandDeadline(deadlineAt, () => stableStateSnapshot(input, {
139
163
  fileDigestCache,
140
164
  deadlineAt
141
- }),
142
- getRepositoryStateSignature(root, {
143
- timeoutMs: Math.max(1, Math.ceil(deadlineAt - performance.now()))
144
- })
145
- ]))
146
- ));
147
- const loaded = snapshot.loaded;
148
- const token = randomUUID();
149
- const session = {
150
- loaded,
151
- fingerprint: snapshot.fingerprint,
152
- repositorySignature,
153
- fileDigestCache,
154
- generatedAt: new Date().toISOString(),
155
- expiresAt: Date.now() + STATE_SESSION_MAX_AGE_MS,
156
- revoked: false,
157
- promises: new Map(),
158
- verificationPromise: null,
159
- gitCommandCache: new Map()
160
- };
161
- pruneStateSessions(stateSessions);
162
- stateSessions.set(token, session);
163
- while (stateSessions.size > MAX_STATE_SESSIONS) {
164
- const oldestToken = stateSessions.keys().next().value;
165
- const oldestSession = stateSessions.get(oldestToken);
166
- if (oldestSession) oldestSession.revoked = true;
167
- stateSessions.delete(oldestToken);
165
+ })).finally(() => {
166
+ bootstrapSnapshotPromise = null;
167
+ });
168
+ }
169
+ const [snapshot, repositorySignature] = await awaitWithinDeadline(bootstrapSnapshotPromise, deadlineAt);
170
+ const loaded = snapshot.loaded;
171
+ const token = randomUUID();
172
+ const session = {
173
+ loaded,
174
+ fingerprint: snapshot.fingerprint,
175
+ repositorySignature,
176
+ fileDigestCache,
177
+ generatedAt: new Date().toISOString(),
178
+ expiresAt: Date.now() + STATE_SESSION_MAX_AGE_MS,
179
+ revoked: false,
180
+ promises: new Map(),
181
+ verificationPromise: null,
182
+ gitCommandCache: new Map()
183
+ };
184
+ const state = await createAppBootstrap(loaded, {
185
+ generatedAt: session.generatedAt,
186
+ programId: url.searchParams.get("programId") || undefined
187
+ });
188
+ if (activeStateMutations > 0 || generation !== stateInvalidationGeneration) continue;
189
+ pruneStateSessions(stateSessions);
190
+ stateSessions.set(token, session);
191
+ while (stateSessions.size > MAX_STATE_SESSIONS) {
192
+ const oldestToken = stateSessions.keys().next().value;
193
+ const oldestSession = stateSessions.get(oldestToken);
194
+ if (oldestSession) oldestSession.revoked = true;
195
+ stateSessions.delete(oldestToken);
196
+ }
197
+ state.stateToken = token;
198
+ return json(response, 200, state);
168
199
  }
169
- const state = await createAppBootstrap(loaded, {
170
- generatedAt: session.generatedAt,
171
- programId: url.searchParams.get("programId") || undefined
172
- });
173
- state.stateToken = token;
174
- return json(response, 200, state);
200
+ throw stateSessionExpiredError();
175
201
  }
176
202
  if (request.method === "GET" && url.pathname.startsWith("/api/state/")) {
177
203
  const section = url.pathname.slice("/api/state/".length);
@@ -526,29 +552,35 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
526
552
  if (request.method === "POST" && url.pathname === "/api/policy-activations") {
527
553
  const payload = await readJson(request);
528
554
  const result = await browserMutation(input, requestOptions, {
529
- message: (activation) => `Activate ${activation.policyIds.length} ${activation.policyIds.length === 1 ? "Policy" : "Policies"}`
555
+ message: (activation) => `Activate ${activation.policyIds.length} ${activation.policyIds.length === 1 ? "Policy" : "Policies"}`,
556
+ prefetchToken: payload.prefetchToken
530
557
  }, () => activatePolicies(input, { ...payload, confirmed: true }));
531
558
  return json(response, 200, result);
532
559
  }
533
560
  if (request.method === "POST" && url.pathname === "/api/document-activations") {
534
561
  const payload = await readJson(request);
535
562
  const result = await browserMutation(input, requestOptions, {
536
- message: (activation) => `Activate ${activation.documentIds.length} governed ${activation.documentIds.length === 1 ? "Document" : "Documents"}`
563
+ message: (activation) => `Activate ${activation.documentIds.length} governed ${activation.documentIds.length === 1 ? "Document" : "Documents"}`,
564
+ prefetchToken: payload.prefetchToken
537
565
  }, () => activateDocuments(input, { ...payload, confirmed: true }));
538
566
  return json(response, 200, result);
539
567
  }
540
568
  if (request.method === "POST" && url.pathname === "/api/governed-content-activations") {
541
569
  const payload = await readJson(request);
542
570
  const result = await browserMutation(input, requestOptions, {
543
- message: (activation) => `Activate ${activation.resourceIds.length} governed-content ${activation.resourceIds.length === 1 ? "record" : "records"}`
571
+ message: (activation) => `Activate ${activation.resourceIds.length} governed-content ${activation.resourceIds.length === 1 ? "record" : "records"}`,
572
+ prefetchToken: payload.prefetchToken
544
573
  }, () => activateGovernedContent(input, { ...payload, confirmed: true }));
545
574
  return json(response, 200, result);
546
575
  }
547
576
  if (request.method === "POST" && url.pathname === "/api/resources") {
548
- const payload = normalizeResourceMutation(await readJson(request));
577
+ const requestPayload = await readJson(request);
578
+ const payload = normalizeResourceMutation(requestPayload);
549
579
  const { record } = payload;
550
580
  const result = await browserMutation(input, requestOptions, {
551
- message: () => `Create ${resourceTypeLabel(record.type)}: ${record.title || record.id}`
581
+ message: () => `Create ${resourceTypeLabel(record.type)}: ${record.title || record.id}`,
582
+ fastResponse: prefersFastMutation(request),
583
+ prefetchToken: requestPayload.prefetchToken
552
584
  }, () => createResource(input, record, { content: payload.content }));
553
585
  return json(response, 201, result);
554
586
  }
@@ -566,19 +598,12 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
566
598
  return json(response, 200, await manualGitResultWithState(input, requestOptions, () => pushWorkspace(input)));
567
599
  }
568
600
  if (request.method === "POST" && url.pathname === "/api/git/retry-sync") {
569
- let result;
570
- try {
571
- result = await retryBrowserSync(input, {
601
+ const result = await manualGitResultWithState(input, requestOptions, () => (
602
+ retryBrowserSync(input, {
572
603
  allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
573
- });
574
- } finally {
575
- requestOptions.invalidateStateSessions();
576
- }
577
- const state = await createAppState(input, {
578
- allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
579
- includeDetails: false
580
- });
581
- return json(response, 200, { ...result, state });
604
+ })
605
+ ));
606
+ return json(response, 200, result);
582
607
  }
583
608
  if (request.method === "GET" && url.pathname === "/api/git/sync-status") {
584
609
  const git = { ...await getRepositorySnapshot(input) };
@@ -607,11 +632,16 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
607
632
  if (request.method === "PUT" && url.pathname === "/api/content") {
608
633
  const payload = await readJson(request);
609
634
  const result = await browserMutation(input, requestOptions, {
610
- message: () => `Update content: ${payload.path}`
635
+ message: () => `Update content: ${payload.path}`,
636
+ fastResponse: prefersFastMutation(request),
637
+ prefetchToken: payload.prefetchToken
611
638
  }, () => updateContent(input, payload.path, payload.source, {
612
639
  expectedRevision: requireRevision(payload.revision, `content/${payload.path}`)
613
640
  }));
614
- return json(response, 200, result);
641
+ return json(response, 200, {
642
+ ...result,
643
+ ...(result.stateRefresh ? { html: renderMarkdown(result.source) } : {})
644
+ });
615
645
  }
616
646
  const match = /^\/api\/resource\/([^/]+)\/([^/]+)$/.exec(url.pathname);
617
647
  if (match) {
@@ -624,9 +654,17 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
624
654
  const session = token ? stateSessions.get(token) : null;
625
655
  if (token && !session) return json(response, 409, { error: "The workspace state expired. Reload it and try again." });
626
656
  const includeWorkflow = url.searchParams.get("workflow") === "true";
657
+ const historyOnly = url.searchParams.get("history") === "only";
658
+ if (historyOnly) {
659
+ const history = session
660
+ ? await loadStateSessionResourceHistory(session, token, type, id, options)
661
+ : await createResourceHistory(input, type, id);
662
+ if (!history) return json(response, 404, { error: "Resource not found." });
663
+ return json(response, 200, history);
664
+ }
627
665
  const entry = session
628
- ? await loadStateSessionResource(session, token, type, id, options, requestOptions.programId, includeWorkflow)
629
- : await createResourceDetail(input, type, id);
666
+ ? await loadStateSessionResource(session, token, type, id, options, requestOptions.programId, includeWorkflow, url.searchParams.get("history") !== "false")
667
+ : await createResourceDetail(input, type, id, { includeHistory: url.searchParams.get("history") !== "false" });
630
668
  if (!entry) return json(response, 404, { error: "Resource not found." });
631
669
  if (includeWorkflow && !session) {
632
670
  const workflow = await assessWorkflow(input, { programId: requestOptions.programId });
@@ -635,10 +673,13 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
635
673
  return json(response, 200, entry);
636
674
  }
637
675
  if (request.method === "PUT") {
638
- const payload = normalizeResourceMutation(await readJson(request), { requireRevision: true });
676
+ const requestPayload = await readJson(request);
677
+ const payload = normalizeResourceMutation(requestPayload, { requireRevision: true });
639
678
  const { record } = payload;
640
679
  const result = await browserMutation(input, requestOptions, {
641
- message: () => `Update ${resourceTypeLabel(type)}: ${record.title || id}`
680
+ message: () => `Update ${resourceTypeLabel(type)}: ${record.title || id}`,
681
+ fastResponse: prefersFastMutation(request),
682
+ prefetchToken: requestPayload.prefetchToken
642
683
  }, () => updateResource(input, type, id, record, {
643
684
  content: payload.content,
644
685
  expectedRevision: payload.revision,
@@ -650,7 +691,8 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
650
691
  if (request.method === "DELETE") {
651
692
  const revision = requireRevision(url.searchParams.get("revision"), `${type}/${id}`);
652
693
  const result = await browserMutation(input, requestOptions, {
653
- message: () => `Delete ${resourceTypeLabel(type)}: ${id}`
694
+ message: () => `Delete ${resourceTypeLabel(type)}: ${id}`,
695
+ prefetchToken: url.searchParams.get("prefetchToken") || undefined
654
696
  }, () => deleteResource(input, type, id, { expectedRevision: revision }));
655
697
  return json(response, 200, {
656
698
  deleted: true,
@@ -659,7 +701,8 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
659
701
  deletedContent: result.deletedContent,
660
702
  synchronization: result.synchronization,
661
703
  workflowDelta: result.workflowDelta,
662
- state: result.state
704
+ state: result.state,
705
+ stateRefresh: result.stateRefresh
663
706
  });
664
707
  }
665
708
  }
@@ -703,6 +746,30 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
703
746
  });
704
747
  }
705
748
 
749
+ async function stableStateSnapshot(input, options) {
750
+ for (let attempt = 0; attempt < 3; attempt += 1) {
751
+ const first = await fingerprintWorkspace(input, {
752
+ fileDigestCache: options.fileDigestCache,
753
+ deadlineAt: options.deadlineAt
754
+ });
755
+ const firstRepositorySignature = await getRepositoryStateSignature(input, {
756
+ timeoutMs: Math.max(1, Math.ceil(options.deadlineAt - performance.now()))
757
+ });
758
+ const second = await fingerprintWorkspace(input, {
759
+ fileDigestCache: options.fileDigestCache,
760
+ deadlineAt: options.deadlineAt
761
+ });
762
+ const secondRepositorySignature = await getRepositoryStateSignature(input, {
763
+ timeoutMs: Math.max(1, Math.ceil(options.deadlineAt - performance.now()))
764
+ });
765
+ if (
766
+ first.fingerprint === second.fingerprint
767
+ && firstRepositorySignature === secondRepositorySignature
768
+ ) return [second, secondRepositorySignature];
769
+ }
770
+ throw stateSessionExpiredError();
771
+ }
772
+
706
773
  export async function serveWorkspace(input = process.cwd(), options = {}) {
707
774
  const host = String(options.host ?? "127.0.0.1").trim();
708
775
  const port = Number(options.port ?? 8787);
@@ -761,11 +828,12 @@ function listen(server, port, host) {
761
828
 
762
829
  function browserMutation(input, requestOptions, mutationOptions, task) {
763
830
  const run = () => serializeWorkspaceMutation(input, async (root) => {
764
- const fastResponse = mutationOptions.fastResponse === true;
831
+ const fastResponse = mutationOptions.fastResponse ?? requestOptions.fastResponse;
765
832
  const workflowBefore = fastResponse
766
833
  ? null
767
834
  : await measureTiming("workflow-before", () => assessWorkflow(root, { programId: requestOptions.programId }));
768
835
  let result;
836
+ requestOptions.beginStateMutation?.();
769
837
  try {
770
838
  result = await measureTiming("mutation", () => runBrowserMutation(root, {
771
839
  ...mutationOptions,
@@ -774,7 +842,7 @@ function browserMutation(input, requestOptions, mutationOptions, task) {
774
842
  includeValidationProof: !fastResponse
775
843
  }, task));
776
844
  } finally {
777
- requestOptions.invalidateStateSessions?.();
845
+ requestOptions.endStateMutation?.();
778
846
  }
779
847
  if (fastResponse) {
780
848
  return {
@@ -899,7 +967,7 @@ async function loadStateSessionSection(session, section, serverOptions, programI
899
967
  return state;
900
968
  }
901
969
 
902
- async function loadStateSessionResource(session, token, type, id, serverOptions, programId, includeWorkflow) {
970
+ async function loadStateSessionResource(session, token, type, id, serverOptions, programId, includeWorkflow, includeHistory = true) {
903
971
  assertCurrentStateSession(session);
904
972
  const detailDeadlineMs = Number.isFinite(serverOptions.resourceDetailDeadlineMs)
905
973
  ? Math.max(0, serverOptions.resourceDetailDeadlineMs)
@@ -907,7 +975,8 @@ async function loadStateSessionResource(session, token, type, id, serverOptions,
907
975
  const deadlineAt = performance.now() + detailDeadlineMs;
908
976
  return withGitCommandDeadline(deadlineAt, async () => {
909
977
  const detail = await withGitCommandCache(session.gitCommandCache, () => createResourceDetail(session.loaded, type, id, {
910
- historyDeadlineAt: deadlineAt
978
+ historyDeadlineAt: deadlineAt,
979
+ includeHistory
911
980
  }));
912
981
  if (detail && includeWorkflow) {
913
982
  const repository = await loadStateSessionSection(
@@ -931,6 +1000,22 @@ async function loadStateSessionResource(session, token, type, id, serverOptions,
931
1000
  });
932
1001
  }
933
1002
 
1003
+ async function loadStateSessionResourceHistory(session, token, type, id, serverOptions) {
1004
+ assertCurrentStateSession(session);
1005
+ const detailDeadlineMs = Number.isFinite(serverOptions.resourceDetailDeadlineMs)
1006
+ ? Math.max(0, serverOptions.resourceDetailDeadlineMs)
1007
+ : RESOURCE_DETAIL_GIT_DEADLINE_MS;
1008
+ const deadlineAt = performance.now() + detailDeadlineMs;
1009
+ return withGitCommandDeadline(deadlineAt, async () => {
1010
+ const history = await withGitCommandCache(session.gitCommandCache, () => createResourceHistory(session.loaded, type, id, {
1011
+ historyDeadlineAt: deadlineAt
1012
+ }));
1013
+ await verifyStateSessionSnapshot(session, performance.now(), deadlineAt);
1014
+ assertCurrentStateSession(session);
1015
+ return history ? { ...history, stateToken: token } : null;
1016
+ });
1017
+ }
1018
+
934
1019
  function prefersFastMutation(request) {
935
1020
  return String(request.headers.prefer || "")
936
1021
  .split(",")
@@ -948,10 +1033,11 @@ async function requireManualBrowserGit(input, options) {
948
1033
 
949
1034
  async function manualGitResultWithState(input, requestOptions, task) {
950
1035
  let result;
1036
+ requestOptions.beginStateMutation?.();
951
1037
  try {
952
1038
  result = await task();
953
1039
  } finally {
954
- requestOptions.invalidateStateSessions?.();
1040
+ requestOptions.endStateMutation?.();
955
1041
  }
956
1042
  const state = await createAppState(input, {
957
1043
  allowNonAuthoritativeWrites: requestOptions.allowNonAuthoritativeWrites,
package/src/state.js CHANGED
@@ -368,15 +368,39 @@ async function createResourceDetailFromLoaded(loaded, type, id, options) {
368
368
  const entry = loaded.entries.find(({ record }) => record.type === type && record.id === id);
369
369
  if (!entry) return null;
370
370
  const relativePath = `data/${entry.relativePath}`;
371
- const histories = getWorkspaceHistories(loaded.root, [relativePath], 12, {
372
- deadlineAt: options.historyDeadlineAt
373
- });
371
+ const includeHistory = options.includeHistory !== false;
372
+ const histories = includeHistory
373
+ ? getWorkspaceHistories(loaded.root, [relativePath], 12, {
374
+ deadlineAt: options.historyDeadlineAt
375
+ })
376
+ : new Map();
374
377
  return createStateEntry(loaded, entry, {
375
378
  includeDetails: true,
376
- history: histories.get(relativePath) ?? []
379
+ includeHistory,
380
+ history: includeHistory ? histories.get(relativePath) ?? [] : undefined
381
+ });
382
+ }
383
+
384
+ export async function createResourceHistory(input, type, id, options = {}) {
385
+ if (input?.entries && input?.root) return createResourceHistoryFromLoaded(input, type, id, options);
386
+ return serializeWorkspaceMutation(input, async (root) => {
387
+ const validation = await validateWorkspace(root);
388
+ return createResourceHistoryFromLoaded(validation.loaded, type, id, options);
377
389
  });
378
390
  }
379
391
 
392
+ function createResourceHistoryFromLoaded(loaded, type, id, options) {
393
+ const entry = loaded.entries.find(({ record }) => record.type === type && record.id === id);
394
+ if (!entry) return null;
395
+ const relativePath = `data/${entry.relativePath}`;
396
+ return {
397
+ history: getWorkspaceHistories(loaded.root, [relativePath], 12, {
398
+ deadlineAt: options.historyDeadlineAt
399
+ }).get(relativePath) ?? [],
400
+ historyLoaded: true
401
+ };
402
+ }
403
+
380
404
  async function createStateEntry(loaded, entry, options) {
381
405
  const record = structuredClone(entry.record);
382
406
  const content = {};
@@ -401,7 +425,8 @@ async function createStateEntry(loaded, entry, options) {
401
425
  relativePath: `data/${entry.relativePath}`,
402
426
  revision: contentRevision(entry.source),
403
427
  content,
404
- history: options.includeDetails ? options.history : undefined,
428
+ history: options.includeDetails && options.includeHistory !== false ? options.history : undefined,
429
+ historyLoaded: options.includeDetails ? options.includeHistory !== false : false,
405
430
  detailsLoaded: options.includeDetails
406
431
  };
407
432
  }
package/src/validate.js CHANGED
@@ -68,6 +68,7 @@ const COMPLETION_DATE_FIELDS = [
68
68
  const COMPLETION_TIMESTAMP_FIELDS = [
69
69
  "completedAt", "endedAt", "closedAt", "provisionedOn", "deprovisionedOn"
70
70
  ];
71
+ const DEFERRED_VALIDATION_CONCURRENCY = 16;
71
72
 
72
73
  export async function validateWorkspace(input = process.cwd()) {
73
74
  const timingStarted = performance.now();
@@ -90,6 +91,8 @@ async function validateWorkspaceUnmeasured(input) {
90
91
  ]));
91
92
  const asOf = currentCalendarDate(loaded.workspace?.timezone || "UTC");
92
93
  const obligationsByControl = new Map();
94
+ const deferredDiagnosticTasks = [];
95
+ const serialContentDiagnosticTasks = [];
93
96
  const reviewRecords = loaded.resources.filter((record) => (
94
97
  record.status === "active" && ["retention-schedule-item", "requirement-mapping"].includes(record.type)
95
98
  ));
@@ -163,7 +166,11 @@ async function validateWorkspaceUnmeasured(input) {
163
166
  validateClassification(record, loaded, displayPath, diagnostics);
164
167
  validateCompletionDates(record, displayPath, diagnostics);
165
168
  validateReportingRouteBinding(record, loaded, displayPath, diagnostics);
166
- await validateAttestationBinding(record, loaded.model, loaded.root, byId, displayPath, diagnostics);
169
+ serialContentDiagnosticTasks.push(async () => {
170
+ const deferredDiagnostics = [];
171
+ await validateAttestationBinding(record, loaded.model, loaded.root, byId, displayPath, deferredDiagnostics);
172
+ return deferredDiagnostics;
173
+ });
167
174
 
168
175
  const fields = { ...loaded.model.commonFields, ...definition.fields };
169
176
  for (const [fieldName, field] of Object.entries(fields)) {
@@ -173,16 +180,19 @@ async function validateWorkspaceUnmeasured(input) {
173
180
  const values = Array.isArray(value) ? value : [value];
174
181
  for (const item of values) {
175
182
  if (typeof item !== "string") continue;
176
- try {
177
- const path = resolveDataPath(loaded.root, item);
178
- if (!(await stat(path)).isFile()) throw new Error("The data path is not a file.");
179
- } catch {
180
- diagnostics.push(error(
181
- "missing-content",
182
- displayPath,
183
- `${fieldName} points to unavailable data path "${item}".`
184
- ));
185
- }
183
+ deferredDiagnosticTasks.push(async () => {
184
+ try {
185
+ const path = resolveDataPath(loaded.root, item);
186
+ if (!(await stat(path)).isFile()) throw new Error("The data path is not a file.");
187
+ return [];
188
+ } catch {
189
+ return [error(
190
+ "missing-content",
191
+ displayPath,
192
+ `${fieldName} points to unavailable data path "${item}".`
193
+ )];
194
+ }
195
+ });
186
196
  }
187
197
  }
188
198
  if (field.relation) {
@@ -209,8 +219,19 @@ async function validateWorkspaceUnmeasured(input) {
209
219
  validateCompletedObligationEvent(record, byId, loaded.model, displayPath, diagnostics);
210
220
  validateActionObligationRule(record, byId, displayPath, diagnostics);
211
221
  validateImplementedControlSchedules(record, obligationsByControl, displayPath, diagnostics);
212
- await validateMarkdown(record, definition, loaded.model, loaded.root, displayPath, diagnostics);
213
- await validateApprovalBinding(record, loaded.model, loaded.root, displayPath, diagnostics);
222
+ serialContentDiagnosticTasks.push(async () => {
223
+ const markdownDiagnostics = [];
224
+ const approvalDiagnostics = [];
225
+ await validateMarkdown(record, definition, loaded.model, loaded.root, displayPath, markdownDiagnostics);
226
+ await validateApprovalBinding(record, loaded.model, loaded.root, displayPath, approvalDiagnostics);
227
+ return [...markdownDiagnostics, ...approvalDiagnostics];
228
+ });
229
+ }
230
+ for (const deferredDiagnostics of await runDeferredValidationTasks(deferredDiagnosticTasks)) {
231
+ diagnostics.push(...deferredDiagnostics);
232
+ }
233
+ for (const task of serialContentDiagnosticTasks) {
234
+ diagnostics.push(...await task());
214
235
  }
215
236
  validateRelationshipConstraints(
216
237
  loaded.resources,
@@ -285,6 +306,23 @@ async function validateWorkspaceUnmeasured(input) {
285
306
  return result;
286
307
  }
287
308
 
309
+ async function runDeferredValidationTasks(tasks) {
310
+ const results = new Array(tasks.length);
311
+ let nextIndex = 0;
312
+ const workers = Array.from(
313
+ { length: Math.min(DEFERRED_VALIDATION_CONCURRENCY, tasks.length) },
314
+ async () => {
315
+ while (nextIndex < tasks.length) {
316
+ const index = nextIndex;
317
+ nextIndex += 1;
318
+ results[index] = await tasks[index]();
319
+ }
320
+ }
321
+ );
322
+ await Promise.all(workers);
323
+ return results;
324
+ }
325
+
288
326
  function validateDocumentWorkflowScopes(resources, model, byId, pathById, diagnostics) {
289
327
  const managementFields = [
290
328
  "engagementTermsDocumentId",