filegrc 0.3.4 → 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,22 +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
9
  import {
10
+ BROWSER_VALIDATION,
11
11
  commitAndPushWorkspace,
12
12
  getBrowserRepositoryState,
13
13
  getFileHistory,
14
+ getGitSummary,
14
15
  pullWorkspace,
15
16
  pushWorkspace,
16
17
  retryBrowserSync,
17
18
  runBrowserMutation
18
19
  } from "./git.js";
20
+ import { normalizeResourceMutation, serializeWorkspaceMutation } from "./mutation.js";
19
21
  import { completeObligationOccurrence, createObligationEvent, planObligations } from "./obligations.js";
20
22
  import { isWithin, relativeToWorkspace, resolveWorkspacePath } from "./paths.js";
21
- import { createAppState } from "./state.js";
23
+ import { createAppState, createResourceDetail } from "./state.js";
22
24
  import { setupWorkspace } from "./setup.js";
25
+ import { collectTimings, measureTiming, timingEnabled } from "./timing.js";
23
26
  import { loadWorkspace } from "./workspace.js";
24
27
  import { APP_SCRIPT, APP_STYLES, renderIndex } from "./web.js";
25
28
 
@@ -34,8 +37,17 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
34
37
  return json(response, 403, { error: "Cross-origin writes are not allowed." });
35
38
  }
36
39
  if (request.method === "GET" && url.pathname === "/api/state") {
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
+ }
37
48
  return json(response, 200, await createAppState(input, {
38
- allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
49
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
50
+ includeDetails: false
39
51
  }));
40
52
  }
41
53
  if (request.method === "GET" && url.pathname === "/api/history") {
@@ -50,7 +62,8 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
50
62
  from: url.searchParams.get("from") || undefined,
51
63
  through: url.searchParams.get("through") || undefined,
52
64
  now: url.searchParams.get("now") || undefined,
53
- includeComplete: url.searchParams.get("includeComplete") === "true"
65
+ includeComplete: url.searchParams.get("includeComplete") === "true",
66
+ model: loaded.model
54
67
  }));
55
68
  }
56
69
  if (request.method === "POST" && url.pathname === "/api/obligation-events") {
@@ -68,7 +81,7 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
68
81
  obligationId: payload.obligationId,
69
82
  record: payload.record,
70
83
  content: payload.content,
71
- expectedRevision: payload.revision
84
+ expectedRevision: requireRevision(payload.revision, `obligation/${payload.obligationId}`)
72
85
  }));
73
86
  return json(response, 201, result);
74
87
  }
@@ -102,49 +115,66 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
102
115
  message: () => `Prepare audit: ${payload.auditId || "engagement"}`
103
116
  }, () => prepareAuditWorkspace(input, payload)));
104
117
  }
105
- if (request.method === "POST" && url.pathname === "/api/evidence-test-drafts") {
106
- return json(response, 201, await browserMutation(input, options, {
107
- message: "Create evidence collection test drafts"
108
- }, () => ensureEvidenceTestDrafts(input)));
109
- }
110
118
  if (request.method === "POST" && url.pathname === "/api/setup") {
111
119
  const payload = await readJson(request);
112
- return json(response, 200, await browserMutation(input, options, {
113
- message: (result) => `${payload.draft === true ? "Save onboarding draft" : "Complete onboarding"} for ${result.workspace.organizationName}`
114
- }, () => setupWorkspace(input, payload)));
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());
115
126
  }
116
127
  if (request.method === "POST" && url.pathname === "/api/resources") {
117
- const payload = await readJson(request);
118
- const record = payload.record ?? payload;
128
+ const payload = normalizeResourceMutation(await readJson(request));
129
+ const { record } = payload;
119
130
  const result = await browserMutation(input, options, {
120
131
  message: () => `Create ${resourceTypeLabel(record.type)}: ${record.title || record.id}`
121
- }, () => createResource(input, record, { content: payload.record ? payload.content : undefined }));
122
- return json(response, 201, { record: result.record, synchronization: result.synchronization });
132
+ }, () => createResource(input, record, { content: payload.content }));
133
+ return json(response, 201, { record: result.record, synchronization: result.synchronization, state: result.state });
123
134
  }
124
135
  if (request.method === "POST" && url.pathname === "/api/commit") {
125
136
  await requireManualBrowserGit(input, options);
126
137
  const payload = await readJson(request);
127
- return json(response, 201, await commitAndPushWorkspace(input, payload.message));
138
+ return json(response, 201, await manualGitResultWithState(input, options, () => commitAndPushWorkspace(input, payload.message)));
128
139
  }
129
140
  if (request.method === "POST" && url.pathname === "/api/git/pull") {
130
141
  await requireManualBrowserGit(input, options);
131
- return json(response, 200, await pullWorkspace(input));
142
+ return json(response, 200, await manualGitResultWithState(input, options, () => pullWorkspace(input)));
132
143
  }
133
144
  if (request.method === "POST" && url.pathname === "/api/git/push") {
134
145
  await requireManualBrowserGit(input, options);
135
- return json(response, 200, await pushWorkspace(input));
146
+ return json(response, 200, await manualGitResultWithState(input, options, () => pushWorkspace(input)));
136
147
  }
137
148
  if (request.method === "POST" && url.pathname === "/api/git/retry-sync") {
138
- return json(response, 200, await retryBrowserSync(input, {
149
+ const result = await retryBrowserSync(input, {
139
150
  allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
140
- }));
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
+ });
141
169
  }
142
170
  if (request.method === "PUT" && url.pathname === "/api/content") {
143
171
  const payload = await readJson(request);
144
172
  const result = await browserMutation(input, options, {
145
173
  message: () => `Update content: ${payload.path}`
146
- }, () => updateContent(input, payload.path, payload.source, { expectedRevision: payload.revision }));
147
- return json(response, 200, { path: result.dataRelativePath, synchronization: result.synchronization });
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 });
148
178
  }
149
179
  const match = /^\/api\/resource\/([^/]+)\/([^/]+)$/.exec(url.pathname);
150
180
  if (match) {
@@ -152,32 +182,34 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
152
182
  const id = decodeURIComponent(match[2]);
153
183
  if (!safeSegment(type) || !safeSegment(id)) return json(response, 400, { error: "Unsafe resource identifier." });
154
184
  if (request.method === "GET") {
155
- const state = await createAppState(input);
156
- const entry = state.resources.find(({ record }) => record.type === type && record.id === id);
185
+ const entry = await createResourceDetail(input, type, id);
157
186
  return entry ? json(response, 200, entry) : json(response, 404, { error: "Resource not found." });
158
187
  }
159
188
  if (request.method === "PUT") {
160
- const payload = await readJson(request);
161
- const record = payload.record ?? payload;
189
+ const payload = normalizeResourceMutation(await readJson(request), { requireRevision: true });
190
+ const { record } = payload;
162
191
  const result = await browserMutation(input, options, {
163
192
  message: () => `Update ${resourceTypeLabel(type)}: ${record.title || id}`
164
193
  }, () => updateResource(input, type, id, record, {
165
- content: payload.record ? payload.content : undefined,
194
+ content: payload.content,
166
195
  expectedRevision: payload.revision,
167
- expectedContentRevisions: payload.contentRevisions
196
+ expectedContentRevisions: payload.contentRevisions,
197
+ requireExpectedContentRevisions: true
168
198
  }));
169
- return json(response, 200, { record: result.record, synchronization: result.synchronization });
199
+ return json(response, 200, { record: result.record, synchronization: result.synchronization, state: result.state });
170
200
  }
171
201
  if (request.method === "DELETE") {
202
+ const revision = requireRevision(url.searchParams.get("revision"), `${type}/${id}`);
172
203
  const result = await browserMutation(input, options, {
173
204
  message: () => `Delete ${resourceTypeLabel(type)}: ${id}`
174
- }, () => deleteResource(input, type, id, { expectedRevision: url.searchParams.get("revision") }));
205
+ }, () => deleteResource(input, type, id, { expectedRevision: revision }));
175
206
  return json(response, 200, {
176
207
  deleted: true,
177
208
  type,
178
209
  id,
179
210
  deletedContent: result.deletedContent,
180
- synchronization: result.synchronization
211
+ synchronization: result.synchronization,
212
+ state: result.state
181
213
  });
182
214
  }
183
215
  }
@@ -232,7 +264,8 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
232
264
  getResourceDefinition(loaded.model, "workspace");
233
265
  const server = createFilegrcServer(loaded.root, {
234
266
  allowedHosts: [host],
235
- allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true
267
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true,
268
+ backgroundPushDelayMs: options.backgroundPushDelayMs
236
269
  });
237
270
  await new Promise((resolve, reject) => {
238
271
  server.once("error", reject);
@@ -247,10 +280,32 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
247
280
  }
248
281
 
249
282
  function browserMutation(input, options, mutationOptions, task) {
250
- return runBrowserMutation(input, {
251
- ...mutationOptions,
252
- allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true
253
- }, 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
+ });
254
309
  }
255
310
 
256
311
  async function requireManualBrowserGit(input, options) {
@@ -262,6 +317,15 @@ async function requireManualBrowserGit(input, options) {
262
317
  }
263
318
  }
264
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
+
265
329
  function resourceTypeLabel(value) {
266
330
  return String(value || "record").replaceAll("-", " ");
267
331
  }
@@ -319,6 +383,13 @@ function safeSegment(value) {
319
383
  return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
320
384
  }
321
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
+
322
393
  function sameOrigin(request) {
323
394
  const origin = request.headers.origin;
324
395
  if (!origin) return true;
@@ -364,7 +435,7 @@ function statusFor(error) {
364
435
  if (/exceeds 2 MB/i.test(error.message)) return 413;
365
436
  if (/changed after you opened|source changed|revision changed/i.test(error.message)) return 409;
366
437
  if (/already exists|target file already exists/i.test(error.message)) return 409;
367
- 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|outside this FileGRC workspace|worktree has uncommitted changes|development write override|browser commit, pull, and 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;
368
439
  if (/not found|ENOENT/i.test(error.message)) return 404;
369
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;
370
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
@@ -8,36 +8,38 @@ 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);
@@ -47,7 +49,6 @@ export async function createAppState(input = process.cwd(), options = {}) {
47
49
  allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
48
50
  });
49
51
  const workspace = loaded.workspace ?? {
50
- schemaVersion: 1,
51
52
  dataModelVersion: loaded.model.modelVersion,
52
53
  id: "workspace",
53
54
  type: "workspace",
@@ -74,6 +75,7 @@ export async function createAppState(input = process.cwd(), options = {}) {
74
75
  ));
75
76
  return {
76
77
  generatedAt,
78
+ asOf,
77
79
  readOnly: Boolean(options.readOnly || (repository.mode === "trunk" && !repository.writesAllowed)),
78
80
  repository,
79
81
  workspace,
@@ -84,13 +86,72 @@ export async function createAppState(input = process.cwd(), options = {}) {
84
86
  counts: validation.counts,
85
87
  diagnostics: validation.diagnostics
86
88
  },
87
- obligations: planObligations(entries, { asOf, now: options.now ?? generatedAt }),
89
+ obligations: planObligations(entries, { asOf, now: options.now ?? generatedAt, model: loaded.model }),
88
90
  programReadiness,
89
91
  auditPreparations,
90
92
  git
91
93
  };
92
94
  }
93
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
+
94
155
  function contentRevision(source) {
95
156
  return createHash("sha256").update(source).digest("hex");
96
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
+ }