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/package.json +4 -2
- package/src/collection-review.js +28 -8
- package/src/files.js +13 -5
- package/src/git.js +2032 -759
- package/src/server.js +154 -68
- package/src/state.js +30 -5
- package/src/validate.js +51 -13
- package/src/web.js +154 -20
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
])
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
stateSessions.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
result = await retryBrowserSync(input, {
|
|
601
|
+
const result = await manualGitResultWithState(input, requestOptions, () => (
|
|
602
|
+
retryBrowserSync(input, {
|
|
572
603
|
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
|
|
573
|
-
})
|
|
574
|
-
|
|
575
|
-
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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",
|