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/files.js CHANGED
@@ -2,9 +2,10 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { constants, link, lstat, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
4
  import { getResourceDefinition } from "../model/index.js";
5
- import { serializeWorkspaceMutation } from "./mutation.js";
5
+ import { serializeWorkspaceMutation, workspaceValidationDeferred } from "./mutation.js";
6
6
  import { isCanonicalDataPath, resolveDataPath, resolveWorkspaceRoot } from "./paths.js";
7
7
  import { markdownEntries } from "./resource-markdown.js";
8
+ import { measureTiming } from "./timing.js";
8
9
  import { loadWorkspace } from "./workspace.js";
9
10
  import { validateWorkspace } from "./validate.js";
10
11
 
@@ -198,6 +199,95 @@ export async function createResources(input, records) {
198
199
  return serializeWorkspaceMutation(input, (root) => createResourcesUnlocked(root, records));
199
200
  }
200
201
 
202
+ export async function applyResourceBatch(input, changes) {
203
+ return serializeWorkspaceMutation(input, (root) => applyResourceBatchUnlocked(root, changes));
204
+ }
205
+
206
+ async function applyResourceBatchUnlocked(input, changes = {}) {
207
+ const creates = changes.create || [];
208
+ const updates = changes.update || [];
209
+ const expectedRevisions = changes.expectedRevisions || {};
210
+ if (!Array.isArray(creates) || !Array.isArray(updates) || (!creates.length && !updates.length)) {
211
+ throw new Error("A resource batch needs at least one create or update.");
212
+ }
213
+ if (Array.isArray(expectedRevisions) || typeof expectedRevisions !== "object") {
214
+ throw new Error("Batch expected revisions must be keyed by resource ID.");
215
+ }
216
+ const loaded = await loadWorkspace(input);
217
+ const deferValidation = workspaceValidationDeferred();
218
+ const before = deferValidation || changes.validateWholeWorkspace
219
+ ? null
220
+ : await validateWorkspace(loaded);
221
+ const existingById = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
222
+ const ids = new Set();
223
+ const writes = [];
224
+ for (const record of creates) {
225
+ validateBatchRecord(record, ids);
226
+ if (existingById.has(record.id)) throw new Error(`Resource "${record.id}" already exists.`);
227
+ const path = resourcePath(loaded.root, loaded.model, record);
228
+ writes.push({ operation: "create", path, record, previous: null, fileMode: 0o666 });
229
+ }
230
+ for (const record of updates) {
231
+ validateBatchRecord(record, ids);
232
+ const existing = existingById.get(record.id);
233
+ if (!existing) throw new Error(`Resource "${record.id}" was not found.`);
234
+ if (existing.record.type !== record.type) {
235
+ throw new Error(`Resource "${record.id}" cannot change type.`);
236
+ }
237
+ const path = resourcePath(loaded.root, loaded.model, record);
238
+ const previous = await readFile(path, "utf8");
239
+ const mode = (await stat(path)).mode & 0o777;
240
+ assertRevision(
241
+ previous,
242
+ expectedRevisions[record.id] || existing.revision,
243
+ `Resource "${record.id}"`
244
+ );
245
+ writes.push({ operation: "update", path, record, previous, fileMode: mode });
246
+ }
247
+ const written = [];
248
+ try {
249
+ for (const item of writes) {
250
+ await writeAtomic(item.path, item.record, { exclusive: item.operation === "create" });
251
+ written.push(item);
252
+ }
253
+ let validation = null;
254
+ if (!deferValidation) {
255
+ validation = await validateWorkspace(loaded.root);
256
+ const errors = changes.validateWholeWorkspace
257
+ ? validation.diagnostics.filter(({ severity }) => severity === "error")
258
+ : newErrors(validation, before);
259
+ if (errors.length) throw new Error(formatWriteFailure(errors, "resource batch"));
260
+ }
261
+ return {
262
+ created: creates,
263
+ updated: updates,
264
+ validation
265
+ };
266
+ } catch (error) {
267
+ const rollbackErrors = [];
268
+ for (const item of written.reverse()) {
269
+ try {
270
+ if (item.operation === "create") await rm(item.path, { force: true });
271
+ else await writeTextAtomic(item.path, item.previous, { mode: item.fileMode });
272
+ } catch (rollbackError) {
273
+ rollbackErrors.push(rollbackError.message);
274
+ }
275
+ }
276
+ if (rollbackErrors.length) {
277
+ throw new Error(`${error.message} FileGRC could not restore every file in the resource batch: ${rollbackErrors.join(" ")}`);
278
+ }
279
+ throw error;
280
+ }
281
+ }
282
+
283
+ function validateBatchRecord(record, ids) {
284
+ if (!record || Array.isArray(record) || typeof record !== "object") {
285
+ throw new Error("Every resource in a batch must be a JSON object.");
286
+ }
287
+ if (ids.has(record.id)) throw new Error(`Resource "${record.id}" appears more than once in the batch.`);
288
+ ids.add(record.id);
289
+ }
290
+
201
291
  export async function createResourceAndLink(input, record, linkTarget, options = {}) {
202
292
  return serializeWorkspaceMutation(input, (root) => createResourceAndLinkUnlocked(root, record, linkTarget, options));
203
293
  }
@@ -254,7 +344,8 @@ async function createResourceAndLinkUnlocked(input, record, linkTarget, options)
254
344
  async function createResourcesUnlocked(input, records) {
255
345
  if (!Array.isArray(records) || records.length === 0) throw new Error("At least one resource is required.");
256
346
  const loaded = await loadWorkspace(input);
257
- const before = await validateWorkspace(loaded);
347
+ const deferValidation = workspaceValidationDeferred();
348
+ const before = deferValidation ? null : await validateWorkspace(loaded);
258
349
  const ids = new Set();
259
350
  const writes = [];
260
351
  for (const record of records) {
@@ -276,9 +367,11 @@ async function createResourcesUnlocked(input, records) {
276
367
  await writeAtomic(item.path, item.record, { exclusive: true });
277
368
  written.push(item);
278
369
  }
279
- const result = await validateWorkspace(loaded.root);
280
- const introduced = newErrors(result, before);
281
- if (introduced.length) throw new Error(formatWriteFailure(introduced, "resource batch"));
370
+ if (!deferValidation) {
371
+ const result = await validateWorkspace(loaded.root);
372
+ const introduced = newErrors(result, before);
373
+ if (introduced.length) throw new Error(formatWriteFailure(introduced, "resource batch"));
374
+ }
282
375
  } catch (error) {
283
376
  for (const item of written.reverse()) await rm(item.path, { force: true });
284
377
  throw error;
@@ -288,7 +381,8 @@ async function createResourcesUnlocked(input, records) {
288
381
 
289
382
  async function createResourceUnlocked(input, record, options) {
290
383
  const loaded = await loadWorkspace(input);
291
- const before = await validateWorkspace(loaded);
384
+ const deferValidation = workspaceValidationDeferred();
385
+ const before = deferValidation ? null : await validateWorkspace(loaded);
292
386
  const path = resourcePath(loaded.root, loaded.model, record);
293
387
  try {
294
388
  await stat(path);
@@ -297,6 +391,7 @@ async function createResourceUnlocked(input, record, options) {
297
391
  if (error.code !== "ENOENT") throw error;
298
392
  }
299
393
  const contentWrites = await prepareContentWrites(loaded, record, options.content, { exclusive: true });
394
+ const nextRecord = await prepareApprovalBinding(loaded, record, contentWrites);
300
395
  const written = [];
301
396
  let recordWritten = false;
302
397
  try {
@@ -304,17 +399,19 @@ async function createResourceUnlocked(input, record, options) {
304
399
  await writeTextAtomic(item.path, item.source, { exclusive: true });
305
400
  written.push(item);
306
401
  }
307
- await writeAtomic(path, record, { exclusive: true });
402
+ await writeAtomic(path, nextRecord, { exclusive: true });
308
403
  recordWritten = true;
309
- const result = await validateWorkspace(loaded.root);
310
- const introduced = newErrors(result, before);
311
- if (introduced.length) throw new Error(formatWriteFailure(introduced, record.id));
404
+ if (!deferValidation) {
405
+ const result = await validateWorkspace(loaded.root);
406
+ const introduced = newErrors(result, before);
407
+ if (introduced.length) throw new Error(formatWriteFailure(introduced, record.id));
408
+ }
312
409
  } catch (error) {
313
410
  if (recordWritten) await rm(path, { force: true });
314
411
  for (const item of written) await rm(item.path, { force: true });
315
412
  throw error;
316
413
  }
317
- return { record, path };
414
+ return { record: nextRecord, path };
318
415
  }
319
416
 
320
417
  export async function updateResource(input, type, id, record, options = {}) {
@@ -326,19 +423,25 @@ async function updateResourceUnlocked(input, type, id, record, options) {
326
423
  throw new Error("The type and ID in the record must match the resource being updated.");
327
424
  }
328
425
  const loaded = await loadWorkspace(input);
329
- const before = await validateWorkspace(loaded);
426
+ const deferValidation = workspaceValidationDeferred();
427
+ const before = deferValidation ? null : await validateWorkspace(loaded);
330
428
  const path = resourcePath(loaded.root, loaded.model, record);
331
429
  const previous = await readFile(path, "utf8");
332
430
  assertRevision(previous, options.expectedRevision, "The record");
333
431
  const contentWrites = await prepareContentWrites(loaded, record, options.content, {
334
- expectedRevisions: options.expectedContentRevisions
432
+ expectedRevisions: options.expectedContentRevisions,
433
+ requireExpectedRevisions: options.requireExpectedContentRevisions
335
434
  });
435
+ const existing = loaded.entries.find(({ record: candidate }) => candidate.id === id)?.record;
436
+ const nextRecord = await prepareApprovalBinding(loaded, record, contentWrites, existing);
336
437
  try {
337
438
  for (const item of contentWrites) await writeTextAtomic(item.path, item.source);
338
- await writeAtomic(path, record);
339
- const result = await validateWorkspace(loaded.root);
340
- const introduced = newErrors(result, before);
341
- if (introduced.length) throw new Error(formatWriteFailure(introduced, id));
439
+ await writeAtomic(path, nextRecord);
440
+ if (!deferValidation) {
441
+ const result = await validateWorkspace(loaded.root);
442
+ const introduced = newErrors(result, before);
443
+ if (introduced.length) throw new Error(formatWriteFailure(introduced, id));
444
+ }
342
445
  } catch (error) {
343
446
  await writeTextAtomic(path, previous);
344
447
  for (const item of contentWrites) {
@@ -347,7 +450,7 @@ async function updateResourceUnlocked(input, type, id, record, options) {
347
450
  }
348
451
  throw error;
349
452
  }
350
- return { record, path };
453
+ return { record: nextRecord, path };
351
454
  }
352
455
 
353
456
  export async function updateContent(input, dataRelativePath, source, options = {}) {
@@ -368,8 +471,18 @@ async function updateContentUnlocked(input, dataRelativePath, source, options) {
368
471
  const path = resolveDataPath(loaded.root, dataRelativePath);
369
472
  const previous = await readFile(path, "utf8");
370
473
  assertRevision(previous, options.expectedRevision, "The Markdown file");
371
- await writeTextAtomic(path, source.endsWith("\n") ? source : `${source}\n`);
372
- return { path, dataRelativePath };
474
+ const before = await validateWorkspace(loaded);
475
+ const nextSource = source.endsWith("\n") ? source : `${source}\n`;
476
+ await writeTextAtomic(path, nextSource);
477
+ try {
478
+ const result = await validateWorkspace(loaded.root);
479
+ const introduced = newErrors(result, before);
480
+ if (introduced.length) throw new Error(formatWriteFailure(introduced, dataRelativePath));
481
+ return { path, dataRelativePath };
482
+ } catch (error) {
483
+ await writeTextAtomic(path, previous);
484
+ throw error;
485
+ }
373
486
  }
374
487
 
375
488
  export async function deleteResource(input, type, id, options = {}) {
@@ -378,7 +491,8 @@ export async function deleteResource(input, type, id, options = {}) {
378
491
 
379
492
  async function deleteResourceUnlocked(input, type, id, options) {
380
493
  const loaded = await loadWorkspace(input);
381
- const before = await validateWorkspace(loaded);
494
+ const deferValidation = workspaceValidationDeferred();
495
+ const before = deferValidation ? null : await validateWorkspace(loaded);
382
496
  const definition = getResourceDefinition(loaded.model, type);
383
497
  if (definition.singleton) throw new Error("Singleton records cannot be deleted.");
384
498
  const path = resourcePath(loaded.root, loaded.model, { type, id });
@@ -393,9 +507,11 @@ async function deleteResourceUnlocked(input, type, id, options) {
393
507
  try {
394
508
  await rm(path);
395
509
  for (const item of contentFiles) await rm(item.path, { force: true });
396
- const result = await validateWorkspace(loaded.root);
397
- const introduced = newErrors(result, before);
398
- if (introduced.length) throw new Error(formatWriteFailure(introduced, id));
510
+ if (!deferValidation) {
511
+ const result = await validateWorkspace(loaded.root);
512
+ const introduced = newErrors(result, before);
513
+ if (introduced.length) throw new Error(formatWriteFailure(introduced, id));
514
+ }
399
515
  } catch (error) {
400
516
  await writeTextAtomic(path, source, { mode });
401
517
  for (const item of contentFiles) {
@@ -418,11 +534,17 @@ export function resourcePath(input, model, record) {
418
534
  }
419
535
 
420
536
  async function writeAtomic(path, value, options = {}) {
421
- const source = `${JSON.stringify(value, null, 2)}\n`;
422
- await writeTextAtomic(path, source, options);
537
+ return measureTiming("writes", async () => {
538
+ const source = `${JSON.stringify(value, null, 2)}\n`;
539
+ await writeTextAtomicUnmeasured(path, source, options);
540
+ });
423
541
  }
424
542
 
425
543
  async function writeTextAtomic(path, source, options = {}) {
544
+ return measureTiming("writes", () => writeTextAtomicUnmeasured(path, source, options));
545
+ }
546
+
547
+ async function writeTextAtomicUnmeasured(path, source, options = {}) {
426
548
  await mkdir(dirname(path), { recursive: true });
427
549
  const temp = join(dirname(path), `.${randomUUID()}.tmp`);
428
550
  let mode = options.mode ?? 0o666;
@@ -454,7 +576,9 @@ async function writeTextAtomic(path, source, options = {}) {
454
576
 
455
577
  function newErrors(after, before) {
456
578
  const existing = new Set(before.diagnostics.filter(({ severity }) => severity === "error").map(diagnosticKey));
457
- return after.diagnostics.filter(({ severity }) => severity === "error").filter((item) => !existing.has(diagnosticKey(item)));
579
+ return after.diagnostics
580
+ .filter(({ severity }) => severity === "error")
581
+ .filter((item) => item.code === "unsupported-model" || !existing.has(diagnosticKey(item)));
458
582
  }
459
583
 
460
584
  function diagnosticKey(item) {
@@ -490,6 +614,12 @@ async function prepareContentWrites(loaded, record, content, options = {}) {
490
614
  try {
491
615
  previous = await readFile(path, "utf8");
492
616
  if (options.exclusive) throw new Error(`Content already exists at data/${dataRelativePath}.`);
617
+ if (
618
+ options.requireExpectedRevisions
619
+ && !Object.hasOwn(options.expectedRevisions ?? {}, dataRelativePath)
620
+ ) {
621
+ throw new Error(`A content revision is required for existing content at data/${dataRelativePath}.`);
622
+ }
493
623
  assertRevision(previous, options.expectedRevisions?.[dataRelativePath], `Content at data/${dataRelativePath}`);
494
624
  } catch (error) {
495
625
  if (error.code !== "ENOENT") throw error;
@@ -505,10 +635,82 @@ function assertRevision(source, expected, label) {
505
635
  }
506
636
  }
507
637
 
508
- function contentRevision(source) {
638
+ export function contentRevision(source) {
509
639
  return createHash("sha256").update(source).digest("hex");
510
640
  }
511
641
 
642
+ async function prepareApprovalBinding(loaded, record, contentWrites, previousRecord = null) {
643
+ if (record.type === "attestation") {
644
+ return prepareAttestationBinding(loaded, record, previousRecord);
645
+ }
646
+ if (!["policy", "document"].includes(record.type)) return record;
647
+ const nextRecord = structuredClone(record);
648
+ if (!approvalBound(record)) {
649
+ delete nextRecord.approvedContentRevisions;
650
+ return nextRecord;
651
+ }
652
+ if (approvalBound(previousRecord) && previousRecord.approvedContentRevisions) {
653
+ nextRecord.approvedContentRevisions = structuredClone(previousRecord.approvedContentRevisions);
654
+ return nextRecord;
655
+ }
656
+ const proposed = new Map(contentWrites.map((item) => [item.dataRelativePath, item.source]));
657
+ const revisions = {};
658
+ for (const item of markdownEntries(loaded.model, nextRecord)) {
659
+ let source = proposed.get(item.path);
660
+ if (source === undefined) {
661
+ try {
662
+ source = await readFile(resolveDataPath(loaded.root, item.path), "utf8");
663
+ } catch (error) {
664
+ if (error.code === "ENOENT") continue;
665
+ throw error;
666
+ }
667
+ }
668
+ revisions[item.path] = contentRevision(source);
669
+ }
670
+ nextRecord.approvedContentRevisions = revisions;
671
+ return nextRecord;
672
+ }
673
+
674
+ async function prepareAttestationBinding(loaded, record, previousRecord = null) {
675
+ const nextRecord = structuredClone(record);
676
+ const bound = record.status === "completed" && record.attestationMethod === "git-approval";
677
+ if (!bound) {
678
+ delete nextRecord.contentRevisions;
679
+ return nextRecord;
680
+ }
681
+ if (
682
+ previousRecord?.status === "completed"
683
+ && previousRecord.attestationMethod === "git-approval"
684
+ && previousRecord.contentRevisions
685
+ ) {
686
+ nextRecord.contentRevisions = structuredClone(previousRecord.contentRevisions);
687
+ return nextRecord;
688
+ }
689
+ const revisions = {};
690
+ for (const id of record.subjectResourceIds || []) {
691
+ const subject = loaded.resources.find((candidate) => candidate.id === id);
692
+ if (!subject) continue;
693
+ for (const item of markdownEntries(loaded.model, subject)) {
694
+ try {
695
+ const source = await readFile(resolveDataPath(loaded.root, item.path), "utf8");
696
+ revisions[item.path] = contentRevision(source);
697
+ } catch (error) {
698
+ if (error.code !== "ENOENT") throw error;
699
+ }
700
+ }
701
+ }
702
+ nextRecord.contentRevisions = revisions;
703
+ return nextRecord;
704
+ }
705
+
706
+ function approvalBound(record) {
707
+ if (!record || !["policy", "document"].includes(record.type)) return false;
708
+ const statuses = record.type === "policy"
709
+ ? ["approved", "active", "superseded", "retired"]
710
+ : ["active", "superseded", "retired"];
711
+ return statuses.includes(record.status);
712
+ }
713
+
512
714
  async function exclusiveContentFiles(loaded, record) {
513
715
  const candidates = markdownEntries(loaded.model, record).map(({ path }) => path);
514
716
  const files = [];
@@ -0,0 +1,16 @@
1
+ export function isSafeGitName(value) {
2
+ const segments = value.split("/");
3
+ return Boolean(value)
4
+ && value !== "@"
5
+ && value !== "HEAD"
6
+ && !value.startsWith("-")
7
+ && !value.includes("..")
8
+ && !value.includes("@{")
9
+ && !/[\s~^:?*[\]\\\u0000-\u001f\u007f]/.test(value)
10
+ && segments.every((segment) => (
11
+ segment
12
+ && !segment.startsWith(".")
13
+ && !segment.endsWith(".")
14
+ && !segment.endsWith(".lock")
15
+ ));
16
+ }