filegrc 0.3.3 → 0.4.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.
package/src/server.js CHANGED
@@ -4,14 +4,25 @@ import { extname, resolve } from "node:path";
4
4
  import { getResourceDefinition } from "../model/index.js";
5
5
  import { prepareAuditWorkspace } from "./audit-preparation.js";
6
6
  import { generateEvidencePacket, prepareEvidencePacket } from "./evidence-packet.js";
7
- import { ensureEvidenceTestDrafts } from "./evidence-tests.js";
8
7
  import { FAVICON_PNG, LOGO_MARK_PNG } from "./favicon.js";
9
8
  import { createResource, deleteResource, updateContent, updateResource } from "./files.js";
10
- import { commitAndPushWorkspace, getFileHistory, pullWorkspace, pushWorkspace } from "./git.js";
9
+ import {
10
+ BROWSER_VALIDATION,
11
+ commitAndPushWorkspace,
12
+ getBrowserRepositoryState,
13
+ getFileHistory,
14
+ getGitSummary,
15
+ pullWorkspace,
16
+ pushWorkspace,
17
+ retryBrowserSync,
18
+ runBrowserMutation
19
+ } from "./git.js";
20
+ import { normalizeResourceMutation, serializeWorkspaceMutation } from "./mutation.js";
11
21
  import { completeObligationOccurrence, createObligationEvent, planObligations } from "./obligations.js";
12
22
  import { isWithin, relativeToWorkspace, resolveWorkspacePath } from "./paths.js";
13
- import { createAppState } from "./state.js";
23
+ import { createAppState, createResourceDetail } from "./state.js";
14
24
  import { setupWorkspace } from "./setup.js";
25
+ import { collectTimings, measureTiming, timingEnabled } from "./timing.js";
15
26
  import { loadWorkspace } from "./workspace.js";
16
27
  import { APP_SCRIPT, APP_STYLES, renderIndex } from "./web.js";
17
28
 
@@ -26,7 +37,18 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
26
37
  return json(response, 403, { error: "Cross-origin writes are not allowed." });
27
38
  }
28
39
  if (request.method === "GET" && url.pathname === "/api/state") {
29
- return json(response, 200, await createAppState(input));
40
+ if (timingEnabled()) {
41
+ const { result, timings } = await collectTimings(() => measureTiming("state", () => createAppState(input, {
42
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
43
+ includeDetails: false
44
+ })));
45
+ console.error(`[filegrc timing] ${JSON.stringify({ operation: "state", ...timings })}`);
46
+ return json(response, 200, result);
47
+ }
48
+ return json(response, 200, await createAppState(input, {
49
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
50
+ includeDetails: false
51
+ }));
30
52
  }
31
53
  if (request.method === "GET" && url.pathname === "/api/history") {
32
54
  const path = url.searchParams.get("path");
@@ -40,21 +62,27 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
40
62
  from: url.searchParams.get("from") || undefined,
41
63
  through: url.searchParams.get("through") || undefined,
42
64
  now: url.searchParams.get("now") || undefined,
43
- includeComplete: url.searchParams.get("includeComplete") === "true"
65
+ includeComplete: url.searchParams.get("includeComplete") === "true",
66
+ model: loaded.model
44
67
  }));
45
68
  }
46
69
  if (request.method === "POST" && url.pathname === "/api/obligation-events") {
47
- return json(response, 201, await createObligationEvent(input, await readJson(request)));
70
+ const payload = await readJson(request);
71
+ return json(response, 201, await browserMutation(input, options, {
72
+ message: (result) => `Create policy event: ${result.event?.title || payload.title || payload.eventType}`
73
+ }, () => createObligationEvent(input, payload)));
48
74
  }
49
75
  if (request.method === "POST" && url.pathname === "/api/obligation-completions") {
50
76
  const payload = await readJson(request);
51
77
  if (!safeSegment(payload.obligationId)) return json(response, 400, { error: "A safe obligation ID is required." });
52
- const result = await completeObligationOccurrence(input, {
78
+ const result = await browserMutation(input, options, {
79
+ message: () => `Complete ${resourceTypeLabel(payload.record?.type)}: ${payload.record?.title || payload.obligationId}`
80
+ }, () => completeObligationOccurrence(input, {
53
81
  obligationId: payload.obligationId,
54
82
  record: payload.record,
55
83
  content: payload.content,
56
- expectedRevision: payload.revision
57
- });
84
+ expectedRevision: requireRevision(payload.revision, `obligation/${payload.obligationId}`)
85
+ }));
58
86
  return json(response, 201, result);
59
87
  }
60
88
  if (request.method === "GET" && url.pathname === "/api/evidence-packet") {
@@ -82,34 +110,71 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
82
110
  });
83
111
  }
84
112
  if (request.method === "POST" && url.pathname === "/api/audit-preparation") {
85
- return json(response, 201, await prepareAuditWorkspace(input, await readJson(request)));
86
- }
87
- if (request.method === "POST" && url.pathname === "/api/evidence-test-drafts") {
88
- return json(response, 201, await ensureEvidenceTestDrafts(input));
113
+ const payload = await readJson(request);
114
+ return json(response, 201, await browserMutation(input, options, {
115
+ message: () => `Prepare audit: ${payload.auditId || "engagement"}`
116
+ }, () => prepareAuditWorkspace(input, payload)));
89
117
  }
90
118
  if (request.method === "POST" && url.pathname === "/api/setup") {
91
- return json(response, 200, await setupWorkspace(input, await readJson(request)));
119
+ const payload = await readJson(request);
120
+ const completeSetup = async () => {
121
+ return browserMutation(input, options, {
122
+ message: (setupResult) => `${payload.draft === true ? "Save onboarding draft" : "Complete onboarding"} for ${setupResult.workspace.organizationName}`
123
+ }, () => setupWorkspace(input, payload));
124
+ };
125
+ return json(response, 200, await completeSetup());
92
126
  }
93
127
  if (request.method === "POST" && url.pathname === "/api/resources") {
94
- const payload = await readJson(request);
95
- const record = payload.record ?? payload;
96
- const result = await createResource(input, record, { content: payload.record ? payload.content : undefined });
97
- return json(response, 201, { record: result.record });
128
+ const payload = normalizeResourceMutation(await readJson(request));
129
+ const { record } = payload;
130
+ const result = await browserMutation(input, options, {
131
+ message: () => `Create ${resourceTypeLabel(record.type)}: ${record.title || record.id}`
132
+ }, () => createResource(input, record, { content: payload.content }));
133
+ return json(response, 201, { record: result.record, synchronization: result.synchronization, state: result.state });
98
134
  }
99
135
  if (request.method === "POST" && url.pathname === "/api/commit") {
136
+ await requireManualBrowserGit(input, options);
100
137
  const payload = await readJson(request);
101
- return json(response, 201, await commitAndPushWorkspace(input, payload.message));
138
+ return json(response, 201, await manualGitResultWithState(input, options, () => commitAndPushWorkspace(input, payload.message)));
102
139
  }
103
140
  if (request.method === "POST" && url.pathname === "/api/git/pull") {
104
- return json(response, 200, await pullWorkspace(input));
141
+ await requireManualBrowserGit(input, options);
142
+ return json(response, 200, await manualGitResultWithState(input, options, () => pullWorkspace(input)));
105
143
  }
106
144
  if (request.method === "POST" && url.pathname === "/api/git/push") {
107
- return json(response, 200, await pushWorkspace(input));
145
+ await requireManualBrowserGit(input, options);
146
+ return json(response, 200, await manualGitResultWithState(input, options, () => pushWorkspace(input)));
147
+ }
148
+ if (request.method === "POST" && url.pathname === "/api/git/retry-sync") {
149
+ const result = await retryBrowserSync(input, {
150
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
151
+ });
152
+ const state = await createAppState(input, {
153
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
154
+ includeDetails: false
155
+ });
156
+ return json(response, 200, { ...result, state });
157
+ }
158
+ if (request.method === "GET" && url.pathname === "/api/git/sync-status") {
159
+ const repository = await getBrowserRepositoryState(input, {
160
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
161
+ });
162
+ const git = getGitSummary(input);
163
+ delete git.root;
164
+ return json(response, 200, {
165
+ repository,
166
+ git,
167
+ readOnly: repository.mode === "trunk" && !repository.writesAllowed
168
+ });
108
169
  }
109
170
  if (request.method === "PUT" && url.pathname === "/api/content") {
110
171
  const payload = await readJson(request);
111
- const result = await updateContent(input, payload.path, payload.source, { expectedRevision: payload.revision });
112
- return json(response, 200, { path: result.dataRelativePath });
172
+ const result = await browserMutation(input, options, {
173
+ message: () => `Update content: ${payload.path}`
174
+ }, () => updateContent(input, payload.path, payload.source, {
175
+ expectedRevision: requireRevision(payload.revision, `content/${payload.path}`)
176
+ }));
177
+ return json(response, 200, { path: result.dataRelativePath, synchronization: result.synchronization, state: result.state });
113
178
  }
114
179
  const match = /^\/api\/resource\/([^/]+)\/([^/]+)$/.exec(url.pathname);
115
180
  if (match) {
@@ -117,23 +182,35 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
117
182
  const id = decodeURIComponent(match[2]);
118
183
  if (!safeSegment(type) || !safeSegment(id)) return json(response, 400, { error: "Unsafe resource identifier." });
119
184
  if (request.method === "GET") {
120
- const state = await createAppState(input);
121
- const entry = state.resources.find(({ record }) => record.type === type && record.id === id);
185
+ const entry = await createResourceDetail(input, type, id);
122
186
  return entry ? json(response, 200, entry) : json(response, 404, { error: "Resource not found." });
123
187
  }
124
188
  if (request.method === "PUT") {
125
- const payload = await readJson(request);
126
- const record = payload.record ?? payload;
127
- const result = await updateResource(input, type, id, record, {
128
- content: payload.record ? payload.content : undefined,
189
+ const payload = normalizeResourceMutation(await readJson(request), { requireRevision: true });
190
+ const { record } = payload;
191
+ const result = await browserMutation(input, options, {
192
+ message: () => `Update ${resourceTypeLabel(type)}: ${record.title || id}`
193
+ }, () => updateResource(input, type, id, record, {
194
+ content: payload.content,
129
195
  expectedRevision: payload.revision,
130
- expectedContentRevisions: payload.contentRevisions
131
- });
132
- return json(response, 200, { record: result.record });
196
+ expectedContentRevisions: payload.contentRevisions,
197
+ requireExpectedContentRevisions: true
198
+ }));
199
+ return json(response, 200, { record: result.record, synchronization: result.synchronization, state: result.state });
133
200
  }
134
201
  if (request.method === "DELETE") {
135
- const result = await deleteResource(input, type, id, { expectedRevision: url.searchParams.get("revision") });
136
- return json(response, 200, { deleted: true, type, id, deletedContent: result.deletedContent });
202
+ const revision = requireRevision(url.searchParams.get("revision"), `${type}/${id}`);
203
+ const result = await browserMutation(input, options, {
204
+ message: () => `Delete ${resourceTypeLabel(type)}: ${id}`
205
+ }, () => deleteResource(input, type, id, { expectedRevision: revision }));
206
+ return json(response, 200, {
207
+ deleted: true,
208
+ type,
209
+ id,
210
+ deletedContent: result.deletedContent,
211
+ synchronization: result.synchronization,
212
+ state: result.state
213
+ });
137
214
  }
138
215
  }
139
216
  if (request.method === "GET" && url.pathname === "/favicon.png") return text(response, 200, FAVICON_PNG, "image/png");
@@ -185,7 +262,11 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
185
262
  }
186
263
  const loaded = await loadWorkspace(input);
187
264
  getResourceDefinition(loaded.model, "workspace");
188
- const server = createFilegrcServer(loaded.root, { allowedHosts: [host] });
265
+ const server = createFilegrcServer(loaded.root, {
266
+ allowedHosts: [host],
267
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true,
268
+ backgroundPushDelayMs: options.backgroundPushDelayMs
269
+ });
189
270
  await new Promise((resolve, reject) => {
190
271
  server.once("error", reject);
191
272
  server.listen(port, host, resolve);
@@ -198,6 +279,57 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
198
279
  };
199
280
  }
200
281
 
282
+ function browserMutation(input, options, mutationOptions, task) {
283
+ const run = () => serializeWorkspaceMutation(input, async (root) => {
284
+ const result = await runBrowserMutation(root, {
285
+ ...mutationOptions,
286
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true,
287
+ backgroundPushDelayMs: options.backgroundPushDelayMs
288
+ }, task);
289
+ const state = await measureTiming("state", () => createAppState(root, {
290
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
291
+ includeDetails: false,
292
+ validationProof: result?.[BROWSER_VALIDATION]
293
+ }));
294
+ if (result?.synchronization?.status === "syncing" && state.repository.status !== "syncing") {
295
+ result.synchronization = {
296
+ ...result.synchronization,
297
+ status: state.repository.status === "synced" ? "synced" : "not-synced",
298
+ synchronizedAt: state.repository.lastSuccessfulSynchronization ?? null,
299
+ pushError: state.repository.backgroundSyncError ?? null
300
+ };
301
+ }
302
+ return { ...result, state };
303
+ });
304
+ if (!timingEnabled()) return run();
305
+ return collectTimings(run).then(({ result, timings }) => {
306
+ console.error(`[filegrc timing] ${JSON.stringify({ operation: "browser-mutation", ...timings })}`);
307
+ return result;
308
+ });
309
+ }
310
+
311
+ async function requireManualBrowserGit(input, options) {
312
+ const repository = await getBrowserRepositoryState(input, {
313
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
314
+ });
315
+ if (repository.mode === "trunk") {
316
+ throw new Error("Browser commit, pull, and push controls are disabled in trunk mode. Saved changes synchronize automatically.");
317
+ }
318
+ }
319
+
320
+ async function manualGitResultWithState(input, options, task) {
321
+ const result = await task();
322
+ const state = await createAppState(input, {
323
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
324
+ includeDetails: false
325
+ });
326
+ return { ...result, state };
327
+ }
328
+
329
+ function resourceTypeLabel(value) {
330
+ return String(value || "record").replaceAll("-", " ");
331
+ }
332
+
201
333
  async function readJson(request) {
202
334
  const chunks = [];
203
335
  let size = 0;
@@ -251,6 +383,13 @@ function safeSegment(value) {
251
383
  return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
252
384
  }
253
385
 
386
+ function requireRevision(value, target) {
387
+ if (typeof value !== "string" || value.length === 0) {
388
+ throw new Error(`A revision is required when changing ${target}. Reload the resource and try again.`);
389
+ }
390
+ return value;
391
+ }
392
+
254
393
  function sameOrigin(request) {
255
394
  const origin = request.headers.origin;
256
395
  if (!origin) return true;
@@ -296,7 +435,7 @@ function statusFor(error) {
296
435
  if (/exceeds 2 MB/i.test(error.message)) return 413;
297
436
  if (/changed after you opened|source changed|revision changed/i.test(error.message)) return 409;
298
437
  if (/already exists|target file already exists/i.test(error.message)) return 409;
299
- if (/Git could not (?:pull|push)|upstream branch|multiple remotes|no Git remote|check out a branch|before trying to (?:pull|push)/i.test(error.message)) return 409;
438
+ if (/Git could not|upstream branch|multiple remotes|no Git remote|configured repository remote|safe Git name|check out a branch|before trying to (?:pull|push)|authoritative branch|not synchronized|not synced|diverged|waiting to be pushed|Retry sync|background push|outside this FileGRC workspace|worktree has uncommitted changes|development write override|browser commit, pull, and push/i.test(error.message)) return 409;
300
439
  if (/not found|ENOENT/i.test(error.message)) return 404;
301
440
  if (/invalid|required|unsafe|match|workspace|singleton|commit message|no changes|git history|git user|unknown resource type|must use|must be|content path|data path|path leaves|valid .*date|not found|no active obligations|end date|through date|already exists|EEXIST/i.test(error.message)) return 400;
302
441
  return 500;
package/src/setup.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createResource, updateResource } from "./files.js";
1
+ import { applyResourceBatch } from "./files.js";
2
2
  import { createResourceId } from "./id.js";
3
3
  import { loadWorkspace } from "./workspace.js";
4
4
 
@@ -10,10 +10,19 @@ export async function setupWorkspace(input = process.cwd(), payload = {}) {
10
10
  const setup = normalizeSetupPayload(payload);
11
11
  validateSetup(loaded, setup);
12
12
  const plan = buildSetupRecords(loaded, setup);
13
-
14
- await upsertResource(loaded.root, plan.existingSystem, plan.system);
15
- await updateResource(loaded.root, "workspace", plan.workspace.id, plan.workspace);
16
- if (plan.renderer) await updateResource(loaded.root, plan.renderer.type, plan.renderer.id, plan.renderer);
13
+ const updates = [
14
+ ...(plan.existingSystem ? [plan.system] : []),
15
+ plan.workspace,
16
+ ...(plan.renderer ? [plan.renderer] : [])
17
+ ];
18
+ const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, entry.revision]));
19
+
20
+ await applyResourceBatch(loaded.root, {
21
+ create: plan.existingSystem ? [] : [plan.system],
22
+ update: updates,
23
+ expectedRevisions: Object.fromEntries(updates.map((record) => [record.id, revisionById.get(record.id)])),
24
+ validateWholeWorkspace: true
25
+ });
17
26
 
18
27
  return {
19
28
  draft: setup.draft,
@@ -21,7 +30,6 @@ export async function setupWorkspace(input = process.cwd(), payload = {}) {
21
30
  workspace: plan.workspace,
22
31
  renderer: plan.renderer,
23
32
  linkedControlIds: [],
24
- evidenceTestDraftIds: [],
25
33
  onboardingComplete: !setup.draft
26
34
  };
27
35
  }
@@ -39,8 +47,7 @@ export async function planWorkspaceSetup(input = process.cwd(), payload = {}) {
39
47
  system: plan.existingSystem ? "update" : "create",
40
48
  workspace: "update",
41
49
  renderer: plan.renderer ? "update" : "unchanged",
42
- controls: 0,
43
- evidenceDrafts: 0
50
+ controls: 0
44
51
  },
45
52
  system: setupSystemSummary(plan.system),
46
53
  target: setupTargetSummary(plan.workspace),
@@ -57,8 +64,7 @@ export function summarizeSetupResult(result) {
57
64
  changes: {
58
65
  system: "saved",
59
66
  workspace: "updated",
60
- controls: result.linkedControlIds?.length || 0,
61
- evidenceDrafts: result.evidenceTestDraftIds?.length || 0
67
+ controls: result.linkedControlIds?.length || 0
62
68
  },
63
69
  system: setupSystemSummary(result.system),
64
70
  target: setupTargetSummary(result.workspace),
@@ -77,7 +83,7 @@ export function normalizeSetupPayload(payload = {}) {
77
83
  boundary: cleanMultilineText(payload.boundary ?? payload.scope, "boundary"),
78
84
  ownerId: cleanText(payload.ownerId ?? payload.owner, "ownerId"),
79
85
  criticality: cleanText(payload.criticality, "criticality"),
80
- dataClassification: cleanText(payload.dataClassification ?? payload.classification, "dataClassification"),
86
+ classificationId: cleanText(payload.classificationId, "classificationId"),
81
87
  internetExposed: booleanValue(payload.internetExposed, "internetExposed"),
82
88
  programGoal: cleanText(payload.programGoal ?? "none", "programGoal"),
83
89
  draft,
@@ -91,7 +97,7 @@ function validateSetup(loaded, setup) {
91
97
  ["boundary", setup.boundary],
92
98
  ["ownerId", setup.ownerId],
93
99
  ["criticality", setup.criticality],
94
- ["dataClassification", setup.dataClassification]
100
+ ["classificationId", setup.classificationId]
95
101
  ]) {
96
102
  if (!value) throw new Error(`Setup field "${name}" is required.`);
97
103
  }
@@ -113,23 +119,24 @@ function validateSetup(loaded, setup) {
113
119
  }
114
120
  }
115
121
  const classifications = Object.keys(loaded.workspace.classificationDefinitions || {});
116
- if (classifications.length && !classifications.includes(setup.dataClassification)) {
117
- throw new Error(`dataClassification must be one of ${classifications.join(", ")}.`);
122
+ if (classifications.length && !classifications.includes(setup.classificationId)) {
123
+ throw new Error(`classificationId must be one of ${classifications.join(", ")}.`);
118
124
  }
119
125
  }
120
126
 
121
- function findSetupSystem(resources, setup) {
127
+ function findSetupSystem(resources, workspace, setup) {
128
+ const scopedSystemIds = new Set(workspace.systemIds || []);
122
129
  return (setup.systemId && resources.find(({ type, id }) => type === "system" && id === setup.systemId))
123
- || resources.find(({ type, title, inScope, status }) => (
130
+ || resources.find(({ type, id, title, status }) => (
124
131
  type === "system"
125
- && inScope === true
132
+ && scopedSystemIds.has(id)
126
133
  && status !== "retired"
127
134
  && title.trim().toLowerCase() === setup.serviceName.toLowerCase()
128
135
  ));
129
136
  }
130
137
 
131
138
  function buildSetupRecords(loaded, setup) {
132
- const existingSystem = findSetupSystem(loaded.resources, setup);
139
+ const existingSystem = findSetupSystem(loaded.resources, loaded.workspace, setup);
133
140
  const systemId = existingSystem?.id || createResourceId(
134
141
  "system",
135
142
  setup.serviceName,
@@ -137,7 +144,6 @@ function buildSetupRecords(loaded, setup) {
137
144
  );
138
145
  const system = {
139
146
  ...(existingSystem || {}),
140
- schemaVersion: 1,
141
147
  id: systemId,
142
148
  type: "system",
143
149
  title: setup.serviceName,
@@ -150,9 +156,8 @@ function buildSetupRecords(loaded, setup) {
150
156
  ownerIds: [setup.ownerId],
151
157
  description: setup.boundary,
152
158
  systemKind: existingSystem?.systemKind || "service",
153
- dataClassification: setup.dataClassification,
154
- internetExposed: setup.internetExposed,
155
- inScope: true
159
+ classificationId: setup.classificationId,
160
+ internetExposed: setup.internetExposed
156
161
  };
157
162
  const existingWorkspace = loaded.resources.find(({ type }) => type === "workspace");
158
163
  if (!existingWorkspace) throw new Error("The workspace settings record was not found.");
@@ -166,12 +171,6 @@ function buildSetupRecords(loaded, setup) {
166
171
  return { existingSystem, system, workspace, renderer };
167
172
  }
168
173
 
169
- async function upsertResource(root, existing, record) {
170
- return existing
171
- ? updateResource(root, record.type, record.id, record)
172
- : createResource(root, record);
173
- }
174
-
175
174
  function assuranceGoalFromSetup(goal) {
176
175
  if (goal === "type-1") return "soc-2-type-1";
177
176
  if (goal === "type-2") return "soc-2-type-2";
package/src/state.js CHANGED
@@ -1,49 +1,54 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { assessAuditPreparation } from "./audit-preparation.js";
4
- import { getGitSummary, getWorkspaceHistories } from "./git.js";
4
+ import { getBrowserRepositoryState, getGitSummary, getWorkspaceHistories } from "./git.js";
5
5
  import { renderMarkdown } from "./markdown.js";
6
6
  import { planObligations } from "./obligations.js";
7
7
  import { resolveDataPath } from "./paths.js";
8
8
  import { assessProgramReadiness } from "./program-readiness.js";
9
9
  import { markdownEntries } from "./resource-markdown.js";
10
10
  import { currentCalendarDate } from "./time.js";
11
- import { validateWorkspace } from "./validate.js";
11
+ import { serializeWorkspaceMutation } from "./mutation.js";
12
+ import { fingerprintWorkspace, validateWorkspace } from "./validate.js";
13
+
14
+ const renderedMarkdownCache = new Map();
15
+ const MAX_RENDERED_MARKDOWN_CACHE_ENTRIES = 1_000;
12
16
 
13
17
  export async function createAppState(input = process.cwd(), options = {}) {
14
- const validation = await validateWorkspace(input);
18
+ return serializeWorkspaceMutation(input, (root) => createAppStateUnlocked(root, options));
19
+ }
20
+
21
+ async function createAppStateUnlocked(input, options) {
22
+ let validation;
23
+ if (options.validationProof) {
24
+ const current = await fingerprintWorkspace(input);
25
+ validation = current.fingerprint === options.validationProof.fingerprint
26
+ ? { ...options.validationProof.validation, loaded: current.loaded }
27
+ : await validateWorkspace(current.loaded);
28
+ } else {
29
+ validation = await validateWorkspace(input);
30
+ }
15
31
  const { loaded } = validation;
16
32
  const entries = [];
17
- const relativePaths = loaded.entries.map((entry) => `data/${entry.relativePath}`);
18
- const histories = getWorkspaceHistories(loaded.root, relativePaths, 12);
33
+ const includeDetails = options.includeDetails !== false;
34
+ const histories = includeDetails
35
+ ? getWorkspaceHistories(loaded.root, loaded.entries.map((entry) => `data/${entry.relativePath}`), 12)
36
+ : new Map();
19
37
 
20
38
  for (const entry of loaded.entries) {
21
- const record = structuredClone(entry.record);
22
- const content = {};
23
- if (loaded.model.resources[record.type]) {
24
- for (const item of markdownEntries(loaded.model, record)) {
25
- try {
26
- const path = resolveDataPath(loaded.root, item.path);
27
- const source = await readFile(path, "utf8");
28
- content[item.name] = { source, html: renderMarkdown(source), path: item.path, revision: contentRevision(source) };
29
- } catch {
30
- // Validation reports missing required Markdown.
31
- }
32
- }
33
- }
34
- entries.push({
35
- record,
36
- relativePath: `data/${entry.relativePath}`,
37
- revision: contentRevision(entry.source),
38
- content,
39
+ entries.push(await createStateEntry(loaded, entry, {
40
+ includeDetails,
39
41
  history: histories.get(`data/${entry.relativePath}`) ?? []
40
- });
42
+ }));
41
43
  }
42
44
 
43
45
  const git = getGitSummary(loaded.root);
44
46
  delete git.root;
47
+ const repository = await getBrowserRepositoryState(loaded.root, {
48
+ readOnly: options.readOnly,
49
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
50
+ });
45
51
  const workspace = loaded.workspace ?? {
46
- schemaVersion: 1,
47
52
  dataModelVersion: loaded.model.modelVersion,
48
53
  id: "workspace",
49
54
  type: "workspace",
@@ -70,7 +75,9 @@ export async function createAppState(input = process.cwd(), options = {}) {
70
75
  ));
71
76
  return {
72
77
  generatedAt,
73
- readOnly: Boolean(options.readOnly),
78
+ asOf,
79
+ readOnly: Boolean(options.readOnly || (repository.mode === "trunk" && !repository.writesAllowed)),
80
+ repository,
74
81
  workspace,
75
82
  model: loaded.model,
76
83
  resources: entries,
@@ -79,13 +86,72 @@ export async function createAppState(input = process.cwd(), options = {}) {
79
86
  counts: validation.counts,
80
87
  diagnostics: validation.diagnostics
81
88
  },
82
- obligations: planObligations(entries, { asOf, now: options.now ?? generatedAt }),
89
+ obligations: planObligations(entries, { asOf, now: options.now ?? generatedAt, model: loaded.model }),
83
90
  programReadiness,
84
91
  auditPreparations,
85
92
  git
86
93
  };
87
94
  }
88
95
 
96
+ export async function createResourceDetail(input, type, id) {
97
+ return serializeWorkspaceMutation(input, async (root) => {
98
+ const validation = await validateWorkspace(root);
99
+ const entry = validation.loaded.entries.find(({ record }) => record.type === type && record.id === id);
100
+ if (!entry) return null;
101
+ const relativePath = `data/${entry.relativePath}`;
102
+ const histories = getWorkspaceHistories(validation.loaded.root, [relativePath], 12);
103
+ return createStateEntry(validation.loaded, entry, {
104
+ includeDetails: true,
105
+ history: histories.get(relativePath) ?? []
106
+ });
107
+ });
108
+ }
109
+
110
+ async function createStateEntry(loaded, entry, options) {
111
+ const record = structuredClone(entry.record);
112
+ const content = {};
113
+ if (loaded.model.resources[record.type]) {
114
+ for (const item of markdownEntries(loaded.model, record)) {
115
+ try {
116
+ const path = resolveDataPath(loaded.root, item.path);
117
+ const source = await readFile(path, "utf8");
118
+ content[item.name] = {
119
+ source,
120
+ ...(options.includeDetails ? { html: renderMarkdownCached(source) } : {}),
121
+ path: item.path,
122
+ revision: contentRevision(source)
123
+ };
124
+ } catch {
125
+ // Validation reports missing required Markdown.
126
+ }
127
+ }
128
+ }
129
+ return {
130
+ record,
131
+ relativePath: `data/${entry.relativePath}`,
132
+ revision: contentRevision(entry.source),
133
+ content,
134
+ history: options.includeDetails ? options.history : undefined,
135
+ detailsLoaded: options.includeDetails
136
+ };
137
+ }
138
+
139
+ function renderMarkdownCached(source) {
140
+ const revision = contentRevision(source);
141
+ const cached = renderedMarkdownCache.get(revision);
142
+ if (cached !== undefined) {
143
+ renderedMarkdownCache.delete(revision);
144
+ renderedMarkdownCache.set(revision, cached);
145
+ return cached;
146
+ }
147
+ const html = renderMarkdown(source);
148
+ renderedMarkdownCache.set(revision, html);
149
+ if (renderedMarkdownCache.size > MAX_RENDERED_MARKDOWN_CACHE_ENTRIES) {
150
+ renderedMarkdownCache.delete(renderedMarkdownCache.keys().next().value);
151
+ }
152
+ return html;
153
+ }
154
+
89
155
  function contentRevision(source) {
90
156
  return createHash("sha256").update(source).digest("hex");
91
157
  }
package/src/timing.js ADDED
@@ -0,0 +1,41 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { performance } from "node:perf_hooks";
3
+
4
+ const timingContext = new AsyncLocalStorage();
5
+
6
+ export async function collectTimings(task) {
7
+ const timings = new Map();
8
+ const result = await timingContext.run(timings, task);
9
+ return { result, timings: Object.fromEntries(timings) };
10
+ }
11
+
12
+ export async function measureTiming(name, task) {
13
+ const started = performance.now();
14
+ try {
15
+ return await task();
16
+ } finally {
17
+ recordTiming(name, performance.now() - started);
18
+ }
19
+ }
20
+
21
+ export function measureTimingSync(name, task) {
22
+ const started = performance.now();
23
+ try {
24
+ return task();
25
+ } finally {
26
+ recordTiming(name, performance.now() - started);
27
+ }
28
+ }
29
+
30
+ export function recordTiming(name, durationMs) {
31
+ const timings = timingContext.getStore();
32
+ if (!timings) return;
33
+ const current = timings.get(name) ?? { count: 0, durationMs: 0 };
34
+ current.count += 1;
35
+ current.durationMs += durationMs;
36
+ timings.set(name, current);
37
+ }
38
+
39
+ export function timingEnabled() {
40
+ return process.env.FILEGRC_TIMING === "1";
41
+ }