filegrc 0.1.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/favicon.js ADDED
@@ -0,0 +1,109 @@
1
+ import { deflateSync } from "node:zlib";
2
+
3
+ const SIZE = 64;
4
+ const WHITE = [248, 249, 255, 255];
5
+
6
+ export const FAVICON_PNG = createFavicon();
7
+
8
+ function createFavicon() {
9
+ const pixels = Buffer.alloc(SIZE * SIZE * 4);
10
+
11
+ for (let y = 0; y < SIZE; y += 1) {
12
+ for (let x = 0; x < SIZE; x += 1) {
13
+ if (!insideRoundedSquare(x, y, 11)) continue;
14
+ setPixel(pixels, x, y, backgroundColor(x, y));
15
+ }
16
+ }
17
+
18
+ drawStroke(pixels, [
19
+ [18, 8],
20
+ [39, 8],
21
+ [51, 20],
22
+ [51, 50],
23
+ [50, 53],
24
+ [47, 55],
25
+ [17, 55],
26
+ [14, 54],
27
+ [12, 51],
28
+ [12, 13],
29
+ [14, 10],
30
+ [18, 8]
31
+ ], 2.3, WHITE);
32
+ drawStroke(pixels, [[39, 8], [39, 20], [51, 20]], 2.3, WHITE);
33
+
34
+ const rows = Buffer.alloc((SIZE * 4 + 1) * SIZE);
35
+ for (let y = 0; y < SIZE; y += 1) {
36
+ const rowOffset = y * (SIZE * 4 + 1);
37
+ rows[rowOffset] = 0;
38
+ pixels.copy(rows, rowOffset + 1, y * SIZE * 4, (y + 1) * SIZE * 4);
39
+ }
40
+
41
+ const header = Buffer.alloc(13);
42
+ header.writeUInt32BE(SIZE, 0);
43
+ header.writeUInt32BE(SIZE, 4);
44
+ header.set([8, 6, 0, 0, 0], 8);
45
+
46
+ return Buffer.concat([
47
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
48
+ pngChunk("IHDR", header),
49
+ pngChunk("IDAT", deflateSync(rows)),
50
+ pngChunk("IEND", Buffer.alloc(0))
51
+ ]);
52
+ }
53
+
54
+ function insideRoundedSquare(x, y, radius) {
55
+ const cornerX = x < radius ? radius - 1 : x >= SIZE - radius ? SIZE - radius : x;
56
+ const cornerY = y < radius ? radius - 1 : y >= SIZE - radius ? SIZE - radius : y;
57
+ return Math.hypot(x - cornerX, y - cornerY) <= radius;
58
+ }
59
+
60
+ function backgroundColor(x, y) {
61
+ const progress = Math.min(1, (x + y) / ((SIZE - 1) * 1.2));
62
+ return [0, 0, Math.round(112 + (53 - 112) * progress), 255];
63
+ }
64
+
65
+ function drawStroke(pixels, points, radius, color) {
66
+ for (let y = 0; y < SIZE; y += 1) {
67
+ for (let x = 0; x < SIZE; x += 1) {
68
+ const onStroke = points.slice(1).some((point, index) => (
69
+ distanceToSegment(x, y, points[index], point) <= radius
70
+ ));
71
+ if (onStroke) setPixel(pixels, x, y, color);
72
+ }
73
+ }
74
+ }
75
+
76
+ function distanceToSegment(x, y, start, end) {
77
+ const dx = end[0] - start[0];
78
+ const dy = end[1] - start[1];
79
+ const lengthSquared = dx * dx + dy * dy;
80
+ const progress = lengthSquared
81
+ ? Math.max(0, Math.min(1, ((x - start[0]) * dx + (y - start[1]) * dy) / lengthSquared))
82
+ : 0;
83
+ return Math.hypot(x - (start[0] + progress * dx), y - (start[1] + progress * dy));
84
+ }
85
+
86
+ function setPixel(pixels, x, y, color) {
87
+ pixels.set(color, (y * SIZE + x) * 4);
88
+ }
89
+
90
+ function pngChunk(type, data) {
91
+ const typeBuffer = Buffer.from(type, "ascii");
92
+ const chunk = Buffer.alloc(data.length + 12);
93
+ chunk.writeUInt32BE(data.length, 0);
94
+ typeBuffer.copy(chunk, 4);
95
+ data.copy(chunk, 8);
96
+ chunk.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), data.length + 8);
97
+ return chunk;
98
+ }
99
+
100
+ function crc32(buffer) {
101
+ let value = 0xffffffff;
102
+ for (const byte of buffer) {
103
+ value ^= byte;
104
+ for (let bit = 0; bit < 8; bit += 1) {
105
+ value = (value >>> 1) ^ (value & 1 ? 0xedb88320 : 0);
106
+ }
107
+ }
108
+ return (value ^ 0xffffffff) >>> 0;
109
+ }
package/src/files.js ADDED
@@ -0,0 +1,533 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants, link, lstat, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { getResourceDefinition } from "../model/index.js";
5
+ import { serializeWorkspaceMutation } from "./mutation.js";
6
+ import { isCanonicalDataPath, resolveDataPath, resolveWorkspaceRoot } from "./paths.js";
7
+ import { markdownEntries } from "./resource-markdown.js";
8
+ import { loadWorkspace } from "./workspace.js";
9
+ import { validateWorkspace } from "./validate.js";
10
+
11
+ export async function createResource(input, record, options = {}) {
12
+ return serializeWorkspaceMutation(input, (root) => createResourceUnlocked(root, record, options));
13
+ }
14
+
15
+ export async function addEvidenceAttachment(input, evidenceId, sourcePath, options = {}) {
16
+ return serializeWorkspaceMutation(input, (root) => addEvidenceAttachmentUnlocked(root, evidenceId, sourcePath, options));
17
+ }
18
+
19
+ export async function removeEvidenceAttachment(input, evidenceId, attachment, options = {}) {
20
+ return serializeWorkspaceMutation(input, (root) => removeEvidenceAttachmentUnlocked(root, evidenceId, attachment, options));
21
+ }
22
+
23
+ async function addEvidenceAttachmentUnlocked(input, evidenceId, sourcePath, options) {
24
+ const loaded = await loadWorkspace(input);
25
+ const entry = loaded.entries.find(({ record }) => record.type === "evidence" && record.id === evidenceId);
26
+ if (!entry) throw new Error(`Evidence "${evidenceId}" was not found.`);
27
+ const source = resolve(String(sourcePath || ""));
28
+ let sourceStat;
29
+ try {
30
+ sourceStat = await lstat(source);
31
+ } catch (error) {
32
+ if (error.code === "ENOENT") throw new Error(`Attachment source "${sourcePath}" was not found.`);
33
+ throw error;
34
+ }
35
+ if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
36
+ throw new Error("An attachment source must be a regular file, not a directory or symlink.");
37
+ }
38
+ const fileName = String(options.name || basename(source)).trim();
39
+ if (
40
+ !fileName
41
+ || fileName === "."
42
+ || fileName === ".."
43
+ || fileName.startsWith(".")
44
+ || fileName.includes("/")
45
+ || fileName.includes("\\")
46
+ || fileName.includes("\0")
47
+ || /[\u0000-\u001f\u007f]/.test(fileName)
48
+ || Buffer.byteLength(fileName, "utf8") > 200
49
+ ) {
50
+ throw new Error("An attachment name must be a non-hidden file name of 200 bytes or fewer without control characters or path separators.");
51
+ }
52
+ const dataRelativePath = join("evidence", evidenceId, fileName).replaceAll("\\", "/");
53
+ const destination = resolveDataPath(loaded.root, dataRelativePath);
54
+ if (source === destination) throw new Error("The attachment source is already at its evidence destination.");
55
+ await mkdir(dirname(destination), { recursive: true });
56
+ const temp = join(dirname(destination), `.${randomUUID()}.attachment`);
57
+ let destinationCreated = false;
58
+ try {
59
+ await copyRegularFileExclusive(source, temp, sourceStat);
60
+ await link(temp, destination);
61
+ destinationCreated = true;
62
+ await rm(temp, { force: true });
63
+ const filePaths = [...new Set([...(entry.record.filePaths || []), dataRelativePath])];
64
+ const updated = await updateResourceUnlocked(loaded.root, "evidence", evidenceId, {
65
+ ...entry.record,
66
+ filePaths
67
+ }, {
68
+ expectedRevision: options.expectedRevision
69
+ });
70
+ return { record: updated.record, path: destination, dataRelativePath };
71
+ } catch (error) {
72
+ await rm(temp, { force: true }).catch(() => {});
73
+ if (destinationCreated) await rm(destination, { force: true }).catch(() => {});
74
+ if (error.code === "EEXIST") throw new Error(`Attachment destination data/${dataRelativePath} already exists.`);
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ async function removeEvidenceAttachmentUnlocked(input, evidenceId, attachment, options) {
80
+ const loaded = await loadWorkspace(input);
81
+ const entry = loaded.entries.find(({ record }) => record.type === "evidence" && record.id === evidenceId);
82
+ if (!entry) throw new Error(`Evidence "${evidenceId}" was not found.`);
83
+ const requested = String(attachment || "").trim();
84
+ const matches = (entry.record.filePaths || []).filter((path) => (
85
+ path === requested || basename(path) === requested
86
+ ));
87
+ if (matches.length === 0) throw new Error(`Attachment "${requested}" is not linked from evidence "${evidenceId}".`);
88
+ if (matches.length > 1) throw new Error(`Attachment name "${requested}" is ambiguous; pass its full data-relative path.`);
89
+ const dataRelativePath = matches[0];
90
+ const expectedPrefix = `evidence/${evidenceId}/`;
91
+ if (!isCanonicalDataPath(dataRelativePath) || !dataRelativePath.startsWith(expectedPrefix)) {
92
+ throw new Error(`Attachment "${dataRelativePath}" is outside evidence/${evidenceId}/ and must be handled manually.`);
93
+ }
94
+ const shared = loaded.resources.some((record) => (
95
+ record.id !== evidenceId
96
+ && Array.isArray(record.filePaths)
97
+ && record.filePaths.includes(dataRelativePath)
98
+ ));
99
+ if (shared) throw new Error(`Attachment "${dataRelativePath}" is referenced by another record.`);
100
+ const path = resolveDataPath(loaded.root, dataRelativePath);
101
+ const fileStat = await lstat(path);
102
+ if (!fileStat.isFile() || fileStat.isSymbolicLink()) {
103
+ throw new Error("An evidence attachment must be a regular file, not a directory or symlink.");
104
+ }
105
+ const parked = join(dirname(path), `.${randomUUID()}.detached`);
106
+ let moved = false;
107
+ try {
108
+ await rename(path, parked);
109
+ moved = true;
110
+ const filePaths = entry.record.filePaths.filter((item) => item !== dataRelativePath);
111
+ const nextRecord = { ...entry.record };
112
+ if (filePaths.length) nextRecord.filePaths = filePaths;
113
+ else delete nextRecord.filePaths;
114
+ const updated = await updateResourceUnlocked(loaded.root, "evidence", evidenceId, nextRecord, {
115
+ expectedRevision: options.expectedRevision
116
+ });
117
+ try {
118
+ await rm(parked);
119
+ moved = false;
120
+ } catch (cleanupError) {
121
+ await rename(parked, path);
122
+ moved = false;
123
+ await updateResourceUnlocked(loaded.root, "evidence", evidenceId, entry.record, {});
124
+ throw cleanupError;
125
+ }
126
+ return { record: updated.record, dataRelativePath };
127
+ } catch (error) {
128
+ if (moved) await rename(parked, path).catch(() => {});
129
+ throw error;
130
+ }
131
+ }
132
+
133
+ async function copyRegularFileExclusive(source, destination, originalStat) {
134
+ let sourceHandle;
135
+ let destinationHandle;
136
+ try {
137
+ sourceHandle = await open(source, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
138
+ const [openedStat, currentStat] = await Promise.all([sourceHandle.stat(), lstat(source)]);
139
+ if (
140
+ !openedStat.isFile()
141
+ || currentStat.isSymbolicLink()
142
+ || !sameFile(originalStat, openedStat)
143
+ || !sameFile(currentStat, openedStat)
144
+ ) {
145
+ throw new Error("The attachment source changed while it was being opened.");
146
+ }
147
+ destinationHandle = await open(
148
+ destination,
149
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
150
+ openedStat.mode & 0o666
151
+ );
152
+ const buffer = Buffer.allocUnsafe(64 * 1024);
153
+ let position = 0;
154
+ while (true) {
155
+ const { bytesRead } = await sourceHandle.read(buffer, 0, buffer.length, position);
156
+ if (!bytesRead) break;
157
+ let written = 0;
158
+ while (written < bytesRead) {
159
+ const result = await destinationHandle.write(
160
+ buffer,
161
+ written,
162
+ bytesRead - written,
163
+ position + written
164
+ );
165
+ if (!result.bytesWritten) throw new Error("The attachment copy stopped before the source file was complete.");
166
+ written += result.bytesWritten;
167
+ }
168
+ position += bytesRead;
169
+ }
170
+ const finalStat = await sourceHandle.stat();
171
+ if (
172
+ position !== openedStat.size
173
+ || finalStat.size !== openedStat.size
174
+ || finalStat.mtimeMs !== openedStat.mtimeMs
175
+ || finalStat.ctimeMs !== openedStat.ctimeMs
176
+ ) {
177
+ throw new Error("The attachment source changed while it was being copied.");
178
+ }
179
+ await destinationHandle.sync();
180
+ } catch (error) {
181
+ if (error.code === "ELOOP") {
182
+ throw new Error("An attachment source must be a regular file, not a directory or symlink.");
183
+ }
184
+ throw error;
185
+ } finally {
186
+ await Promise.allSettled([
187
+ destinationHandle?.close(),
188
+ sourceHandle?.close()
189
+ ]);
190
+ }
191
+ }
192
+
193
+ function sameFile(left, right) {
194
+ return left.dev === right.dev && left.ino === right.ino;
195
+ }
196
+
197
+ export async function createResources(input, records) {
198
+ return serializeWorkspaceMutation(input, (root) => createResourcesUnlocked(root, records));
199
+ }
200
+
201
+ export async function createResourceAndLink(input, record, linkTarget, options = {}) {
202
+ return serializeWorkspaceMutation(input, (root) => createResourceAndLinkUnlocked(root, record, linkTarget, options));
203
+ }
204
+
205
+ async function createResourceAndLinkUnlocked(input, record, linkTarget, options) {
206
+ if (!record || typeof record !== "object" || Array.isArray(record)) {
207
+ throw new Error("A resource record is required.");
208
+ }
209
+ if (!linkTarget || typeof linkTarget !== "object" || Array.isArray(linkTarget)) {
210
+ throw new Error("A resource link target is required.");
211
+ }
212
+ const loaded = await loadWorkspace(input);
213
+ const targetEntry = loaded.entries.find(({ record: target }) => target.type === linkTarget.type && target.id === linkTarget.id);
214
+ if (!targetEntry) throw new Error(`Resource "${linkTarget.type}/${linkTarget.id}" was not found.`);
215
+ const targetDefinition = getResourceDefinition(loaded.model, linkTarget.type);
216
+ const targetFields = { ...loaded.model.commonFields, ...targetDefinition.fields };
217
+ const linkField = targetFields[linkTarget.field];
218
+ if (linkField?.type !== "array" || !linkField.relation) {
219
+ throw new Error(`Field "${linkTarget.field}" cannot link resources.`);
220
+ }
221
+ const allowedTypes = Array.isArray(linkField.relation) ? linkField.relation : [linkField.relation];
222
+ if (!allowedTypes.includes("*") && !allowedTypes.includes(record.type)) {
223
+ throw new Error(`Field "${linkTarget.field}" cannot link resource type "${record.type}".`);
224
+ }
225
+ const linkedIds = Array.isArray(targetEntry.record[linkTarget.field]) ? targetEntry.record[linkTarget.field] : [];
226
+ if (linkedIds.includes(record.id)) throw new Error(`Resource "${record.id}" is already linked.`);
227
+
228
+ const created = await createResourceUnlocked(input, record, { content: options.content });
229
+ try {
230
+ const patch = linkTarget.patch && !Array.isArray(linkTarget.patch) && typeof linkTarget.patch === "object"
231
+ ? linkTarget.patch
232
+ : {};
233
+ if (
234
+ (patch.id !== undefined && patch.id !== linkTarget.id)
235
+ || (patch.type !== undefined && patch.type !== linkTarget.type)
236
+ ) {
237
+ throw new Error("A linked resource patch cannot change the target type or ID.");
238
+ }
239
+ const linkedRecord = {
240
+ ...targetEntry.record,
241
+ ...patch,
242
+ [linkTarget.field]: [...linkedIds, record.id]
243
+ };
244
+ const linked = await updateResourceUnlocked(input, linkTarget.type, linkTarget.id, linkedRecord, {
245
+ expectedRevision: linkTarget.expectedRevision
246
+ });
247
+ return { created: created.record, linked: linked.record };
248
+ } catch (error) {
249
+ await deleteResourceUnlocked(input, record.type, record.id, {});
250
+ throw error;
251
+ }
252
+ }
253
+
254
+ async function createResourcesUnlocked(input, records) {
255
+ if (!Array.isArray(records) || records.length === 0) throw new Error("At least one resource is required.");
256
+ const loaded = await loadWorkspace(input);
257
+ const before = await validateWorkspace(loaded);
258
+ const ids = new Set();
259
+ const writes = [];
260
+ for (const record of records) {
261
+ if (!record || Array.isArray(record) || typeof record !== "object") throw new Error("Every resource must be a JSON object.");
262
+ if (ids.has(record.id)) throw new Error(`Resource "${record.id}" appears more than once in the batch.`);
263
+ ids.add(record.id);
264
+ const path = resourcePath(loaded.root, loaded.model, record);
265
+ try {
266
+ await stat(path);
267
+ throw new Error(`Resource "${record.id}" already exists.`);
268
+ } catch (error) {
269
+ if (error.code !== "ENOENT") throw error;
270
+ }
271
+ writes.push({ path, record });
272
+ }
273
+ const written = [];
274
+ try {
275
+ for (const item of writes) {
276
+ await writeAtomic(item.path, item.record, { exclusive: true });
277
+ written.push(item);
278
+ }
279
+ const result = await validateWorkspace(loaded.root);
280
+ const introduced = newErrors(result, before);
281
+ if (introduced.length) throw new Error(formatWriteFailure(introduced, "resource batch"));
282
+ } catch (error) {
283
+ for (const item of written.reverse()) await rm(item.path, { force: true });
284
+ throw error;
285
+ }
286
+ return records;
287
+ }
288
+
289
+ async function createResourceUnlocked(input, record, options) {
290
+ const loaded = await loadWorkspace(input);
291
+ const before = await validateWorkspace(loaded);
292
+ const path = resourcePath(loaded.root, loaded.model, record);
293
+ try {
294
+ await stat(path);
295
+ throw new Error(`Resource "${record.id}" already exists.`);
296
+ } catch (error) {
297
+ if (error.code !== "ENOENT") throw error;
298
+ }
299
+ const contentWrites = await prepareContentWrites(loaded, record, options.content, { exclusive: true });
300
+ const written = [];
301
+ let recordWritten = false;
302
+ try {
303
+ for (const item of contentWrites) {
304
+ await writeTextAtomic(item.path, item.source, { exclusive: true });
305
+ written.push(item);
306
+ }
307
+ await writeAtomic(path, record, { exclusive: true });
308
+ 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));
312
+ } catch (error) {
313
+ if (recordWritten) await rm(path, { force: true });
314
+ for (const item of written) await rm(item.path, { force: true });
315
+ throw error;
316
+ }
317
+ return { record, path };
318
+ }
319
+
320
+ export async function updateResource(input, type, id, record, options = {}) {
321
+ return serializeWorkspaceMutation(input, (root) => updateResourceUnlocked(root, type, id, record, options));
322
+ }
323
+
324
+ async function updateResourceUnlocked(input, type, id, record, options) {
325
+ if (record.type !== type || record.id !== id) {
326
+ throw new Error("The type and ID in the record must match the resource being updated.");
327
+ }
328
+ const loaded = await loadWorkspace(input);
329
+ const before = await validateWorkspace(loaded);
330
+ const path = resourcePath(loaded.root, loaded.model, record);
331
+ const previous = await readFile(path, "utf8");
332
+ assertRevision(previous, options.expectedRevision, "The record");
333
+ const contentWrites = await prepareContentWrites(loaded, record, options.content, {
334
+ expectedRevisions: options.expectedContentRevisions
335
+ });
336
+ try {
337
+ 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));
342
+ } catch (error) {
343
+ await writeTextAtomic(path, previous);
344
+ for (const item of contentWrites) {
345
+ if (item.previous === null) await rm(item.path, { force: true });
346
+ else await writeTextAtomic(item.path, item.previous);
347
+ }
348
+ throw error;
349
+ }
350
+ return { record, path };
351
+ }
352
+
353
+ export async function updateContent(input, dataRelativePath, source, options = {}) {
354
+ return serializeWorkspaceMutation(input, (root) => updateContentUnlocked(root, dataRelativePath, source, options));
355
+ }
356
+
357
+ async function updateContentUnlocked(input, dataRelativePath, source, options) {
358
+ if (typeof source !== "string") throw new Error("Markdown content must be a string.");
359
+ const loaded = await loadWorkspace(input);
360
+ const allowed = loaded.entries.some(({ record }) => (
361
+ markdownEntries(loaded.model, record).some(({ path }) => path === dataRelativePath)
362
+ ));
363
+ if (!allowed) {
364
+ const error = new Error(`Markdown path "${dataRelativePath}" was not found.`);
365
+ error.code = "ENOENT";
366
+ throw error;
367
+ }
368
+ const path = resolveDataPath(loaded.root, dataRelativePath);
369
+ const previous = await readFile(path, "utf8");
370
+ assertRevision(previous, options.expectedRevision, "The Markdown file");
371
+ await writeTextAtomic(path, source.endsWith("\n") ? source : `${source}\n`);
372
+ return { path, dataRelativePath };
373
+ }
374
+
375
+ export async function deleteResource(input, type, id, options = {}) {
376
+ return serializeWorkspaceMutation(input, (root) => deleteResourceUnlocked(root, type, id, options));
377
+ }
378
+
379
+ async function deleteResourceUnlocked(input, type, id, options) {
380
+ const loaded = await loadWorkspace(input);
381
+ const before = await validateWorkspace(loaded);
382
+ const definition = getResourceDefinition(loaded.model, type);
383
+ if (definition.singleton) throw new Error("Singleton records cannot be deleted.");
384
+ const path = resourcePath(loaded.root, loaded.model, { type, id });
385
+ const mode = (await stat(path)).mode & 0o777;
386
+ const source = await readFile(path, "utf8");
387
+ assertRevision(source, options.expectedRevision, "The record");
388
+ const record = JSON.parse(source);
389
+ if (record.type === "evidence" && (record.filePaths || []).length) {
390
+ throw new Error(`Evidence "${id}" still has local attachments. Detach them explicitly before deleting the record.`);
391
+ }
392
+ const contentFiles = await exclusiveContentFiles(loaded, record);
393
+ try {
394
+ await rm(path);
395
+ 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));
399
+ } catch (error) {
400
+ await writeTextAtomic(path, source, { mode });
401
+ for (const item of contentFiles) {
402
+ if (item.source !== null) await writeTextAtomic(item.path, item.source, { mode: item.mode });
403
+ }
404
+ throw error;
405
+ }
406
+ return { type, id, path, deletedContent: contentFiles.filter(({ source }) => source !== null).map(({ dataRelativePath }) => dataRelativePath) };
407
+ }
408
+
409
+ export function resourcePath(input, model, record) {
410
+ const root = resolveWorkspaceRoot(input);
411
+ const definition = getResourceDefinition(model, record.type);
412
+ if (definition.singleton) return resolveDataPath(root, definition.singleton);
413
+ if (typeof record.id !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(record.id)) {
414
+ throw new Error("Resource IDs must use lowercase kebab-case.");
415
+ }
416
+ const recordFile = (definition.recordPath ?? "{id}.json").replaceAll("{id}", record.id);
417
+ return resolveDataPath(root, join(definition.collection, recordFile).replaceAll("\\", "/"));
418
+ }
419
+
420
+ async function writeAtomic(path, value, options = {}) {
421
+ const source = `${JSON.stringify(value, null, 2)}\n`;
422
+ await writeTextAtomic(path, source, options);
423
+ }
424
+
425
+ async function writeTextAtomic(path, source, options = {}) {
426
+ await mkdir(dirname(path), { recursive: true });
427
+ const temp = join(dirname(path), `.${randomUUID()}.tmp`);
428
+ let mode = options.mode ?? 0o666;
429
+ if (options.mode === undefined) {
430
+ try {
431
+ mode = (await stat(path)).mode & 0o777;
432
+ } catch (error) {
433
+ if (error.code !== "ENOENT") throw error;
434
+ }
435
+ }
436
+ const handle = await open(temp, options.exclusive ? "wx" : "w", mode);
437
+ try {
438
+ try {
439
+ await handle.writeFile(source, "utf8");
440
+ await handle.sync();
441
+ } finally {
442
+ await handle.close();
443
+ }
444
+ if (options.exclusive) {
445
+ await link(temp, path);
446
+ await rm(temp, { force: true }).catch(() => {});
447
+ } else await rename(temp, path);
448
+ } catch (error) {
449
+ await rm(temp, { force: true });
450
+ if (error.code === "EEXIST") throw new Error("The target file already exists.");
451
+ throw error;
452
+ }
453
+ }
454
+
455
+ function newErrors(after, before) {
456
+ 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)));
458
+ }
459
+
460
+ function diagnosticKey(item) {
461
+ return `${item.code}\0${item.path}\0${item.message}`;
462
+ }
463
+
464
+ function formatWriteFailure(diagnostics, id) {
465
+ const related = diagnostics.filter(({ path, message }) => !id || path.includes(id) || message.includes(id));
466
+ const details = (related.length ? related : diagnostics)
467
+ .slice(0, 5)
468
+ .map(({ message }) => message)
469
+ .join(" ");
470
+ return `The write would leave the workspace invalid. ${details}`.trim();
471
+ }
472
+
473
+ async function prepareContentWrites(loaded, record, content, options = {}) {
474
+ if (content === undefined || content === null) return [];
475
+ if (Array.isArray(content) || typeof content !== "object") {
476
+ throw new Error("Content updates must be keyed by data-relative Markdown path.");
477
+ }
478
+ const allowed = new Map();
479
+ for (const item of markdownEntries(loaded.model, record)) {
480
+ allowed.set(item.name, item.path);
481
+ allowed.set(item.path, item.path);
482
+ }
483
+ const writes = [];
484
+ for (const [key, source] of Object.entries(content)) {
485
+ const dataRelativePath = allowed.get(key);
486
+ if (!dataRelativePath) throw new Error(`Markdown "${key}" does not belong to this record.`);
487
+ if (typeof source !== "string") throw new Error(`Content for "${dataRelativePath}" must be a string.`);
488
+ const path = resolveDataPath(loaded.root, dataRelativePath);
489
+ let previous = null;
490
+ try {
491
+ previous = await readFile(path, "utf8");
492
+ if (options.exclusive) throw new Error(`Content already exists at data/${dataRelativePath}.`);
493
+ assertRevision(previous, options.expectedRevisions?.[dataRelativePath], `Content at data/${dataRelativePath}`);
494
+ } catch (error) {
495
+ if (error.code !== "ENOENT") throw error;
496
+ }
497
+ writes.push({ path, dataRelativePath, source: source.endsWith("\n") ? source : `${source}\n`, previous });
498
+ }
499
+ return writes;
500
+ }
501
+
502
+ function assertRevision(source, expected, label) {
503
+ if (expected && contentRevision(source) !== expected) {
504
+ throw new Error(`${label} changed after you opened it. Reload the workspace and apply your change again.`);
505
+ }
506
+ }
507
+
508
+ function contentRevision(source) {
509
+ return createHash("sha256").update(source).digest("hex");
510
+ }
511
+
512
+ async function exclusiveContentFiles(loaded, record) {
513
+ const candidates = markdownEntries(loaded.model, record).map(({ path }) => path);
514
+ const files = [];
515
+ for (const dataRelativePath of new Set(candidates)) {
516
+ let contentPath;
517
+ try {
518
+ contentPath = resolveDataPath(loaded.root, dataRelativePath);
519
+ } catch {
520
+ continue;
521
+ }
522
+ let contentSource = null;
523
+ let mode = 0o666;
524
+ try {
525
+ contentSource = await readFile(contentPath, "utf8");
526
+ mode = (await stat(contentPath)).mode & 0o777;
527
+ } catch (error) {
528
+ if (error.code !== "ENOENT") throw error;
529
+ }
530
+ files.push({ path: contentPath, dataRelativePath, source: contentSource, mode });
531
+ }
532
+ return files;
533
+ }