mdmaid-desk 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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +215 -0
  3. package/dist/api-client.d.ts +20 -0
  4. package/dist/api-client.js +264 -0
  5. package/dist/api-client.js.map +1 -0
  6. package/dist/api-types.d.ts +51 -0
  7. package/dist/api-types.js +2 -0
  8. package/dist/api-types.js.map +1 -0
  9. package/dist/auth-state.d.ts +2 -0
  10. package/dist/auth-state.js +113 -0
  11. package/dist/auth-state.js.map +1 -0
  12. package/dist/catalog.d.ts +46 -0
  13. package/dist/catalog.js +659 -0
  14. package/dist/catalog.js.map +1 -0
  15. package/dist/cli.d.ts +16 -0
  16. package/dist/cli.js +370 -0
  17. package/dist/cli.js.map +1 -0
  18. package/dist/daemon-state.d.ts +17 -0
  19. package/dist/daemon-state.js +204 -0
  20. package/dist/daemon-state.js.map +1 -0
  21. package/dist/domain.d.ts +49 -0
  22. package/dist/domain.js +44 -0
  23. package/dist/domain.js.map +1 -0
  24. package/dist/fs-durability.d.ts +2 -0
  25. package/dist/fs-durability.js +35 -0
  26. package/dist/fs-durability.js.map +1 -0
  27. package/dist/package-info.d.ts +1 -0
  28. package/dist/package-info.js +17 -0
  29. package/dist/package-info.js.map +1 -0
  30. package/dist/server.d.ts +18 -0
  31. package/dist/server.js +702 -0
  32. package/dist/server.js.map +1 -0
  33. package/dist/sqlite-storage.d.ts +17 -0
  34. package/dist/sqlite-storage.js +342 -0
  35. package/dist/sqlite-storage.js.map +1 -0
  36. package/dist/storage.d.ts +12 -0
  37. package/dist/storage.js +2 -0
  38. package/dist/storage.js.map +1 -0
  39. package/dist/terminal-text.d.ts +9 -0
  40. package/dist/terminal-text.js +156 -0
  41. package/dist/terminal-text.js.map +1 -0
  42. package/dist/tui.d.ts +63 -0
  43. package/dist/tui.js +912 -0
  44. package/dist/tui.js.map +1 -0
  45. package/dist/web-client.d.ts +21 -0
  46. package/dist/web-client.js +337 -0
  47. package/dist/web-client.js.map +1 -0
  48. package/dist/web-styles.d.ts +1 -0
  49. package/dist/web-styles.js +249 -0
  50. package/dist/web-styles.js.map +1 -0
  51. package/package.json +60 -0
@@ -0,0 +1,659 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { access, chmod, lstat, open, readFile, realpath, rename, stat, } from "node:fs/promises";
4
+ import { extname, isAbsolute, relative, resolve, sep, } from "node:path";
5
+ import { isAttention, isDocumentKind, presentDocument, } from "./domain.js";
6
+ import { SqliteCatalogStorage } from "./sqlite-storage.js";
7
+ export const CATALOG_SCHEMA_VERSION = 1;
8
+ const DEFAULT_MAX_DOCUMENT_BYTES = 2 * 1024 * 1024;
9
+ const MAX_LEGACY_CATALOG_BYTES = 4 * 1024 * 1024;
10
+ const MAX_TITLE_LENGTH = 512;
11
+ const MAX_CONTEXT_LENGTH = 256;
12
+ const TAG_PATTERN = /^[a-z0-9][a-z0-9._/-]{0,63}$/;
13
+ const WORKSPACE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
14
+ export class Catalog {
15
+ #storage;
16
+ #maxDocumentBytes;
17
+ constructor(storage, options) {
18
+ this.#storage = storage;
19
+ this.#maxDocumentBytes =
20
+ options.maxDocumentBytes ?? DEFAULT_MAX_DOCUMENT_BYTES;
21
+ }
22
+ static async open(databasePath, options = {}) {
23
+ validateCatalogOptions(options);
24
+ if (typeof databasePath !== "string" || databasePath.trim() === "") {
25
+ throw new Error("database path is required");
26
+ }
27
+ const storage = SqliteCatalogStorage.open(resolve(databasePath));
28
+ const catalog = new Catalog(storage, options);
29
+ try {
30
+ const legacyStatePath = options.legacyStatePath === false
31
+ ? undefined
32
+ : resolve(options.legacyStatePath ??
33
+ defaultLegacyStatePath(resolve(databasePath)));
34
+ if (legacyStatePath && storage.isEmpty()) {
35
+ await catalog.#migrateLegacyState(legacyStatePath);
36
+ }
37
+ return catalog;
38
+ }
39
+ catch (error) {
40
+ storage.close();
41
+ throw error;
42
+ }
43
+ }
44
+ close() {
45
+ this.#storage.close();
46
+ }
47
+ listWorkspaces() {
48
+ return structuredClone(this.#storage.listWorkspaces());
49
+ }
50
+ listDocuments(filters = {}) {
51
+ const validated = validateFilters(filters);
52
+ return this.#storage
53
+ .listDocuments(validated)
54
+ .map((document) => presentDocument(document));
55
+ }
56
+ getDocument(id) {
57
+ validateDocumentId(id);
58
+ const document = this.#storage.getDocument(id);
59
+ return document ? presentDocument(document) : undefined;
60
+ }
61
+ async readDocument(id) {
62
+ validateDocumentId(id);
63
+ const stored = this.#storage.getDocument(id);
64
+ if (!stored) {
65
+ throw new Error(`unknown document ${id}`);
66
+ }
67
+ const workspace = this.#storage.getWorkspace(stored.workspaceId);
68
+ if (!workspace) {
69
+ throw new Error(`unknown workspace ${stored.workspaceId}`);
70
+ }
71
+ const inspected = await inspectMarkdownDocument(stored.path, workspace, this.#maxDocumentBytes);
72
+ return {
73
+ content: inspected.content.toString("utf8"),
74
+ document: presentDocument(stored),
75
+ };
76
+ }
77
+ async addWorkspace(input) {
78
+ validateAddWorkspaceInput(input);
79
+ const root = await canonicalDirectory(input.root, "workspace root");
80
+ const artifactRoots = [];
81
+ for (const candidate of input.artifactRoots) {
82
+ const artifactRoot = await canonicalDirectory(candidate, "artifact root");
83
+ if (!isWithin(root, artifactRoot)) {
84
+ throw new Error("artifact root is outside workspace root");
85
+ }
86
+ if (!artifactRoots.includes(artifactRoot)) {
87
+ artifactRoots.push(artifactRoot);
88
+ }
89
+ }
90
+ const workspace = {
91
+ id: input.id,
92
+ name: input.name.trim(),
93
+ root,
94
+ artifactRoots,
95
+ };
96
+ const existing = this.#storage.getWorkspace(workspace.id);
97
+ if (existing && existing.root !== workspace.root) {
98
+ throw new Error(`workspace ${workspace.id} already uses another root`);
99
+ }
100
+ const documents = [
101
+ ...this.#storage.listDocuments(),
102
+ ...this.#storage.listDocuments({ archived: true }),
103
+ ];
104
+ const excludedDocument = documents.find((document) => document.workspaceId === workspace.id &&
105
+ !workspace.artifactRoots.some((artifactRoot) => isWithin(artifactRoot, document.path)));
106
+ if (excludedDocument) {
107
+ throw new Error(`workspace update would exclude registered document ${excludedDocument.id}`);
108
+ }
109
+ this.#storage.saveWorkspace(workspace);
110
+ return structuredClone(workspace);
111
+ }
112
+ async registerDocument(input) {
113
+ const validated = validateRegisterDocumentInput(input);
114
+ const workspace = this.#storage.getWorkspace(validated.workspaceId);
115
+ if (!workspace) {
116
+ throw new Error(`unknown workspace ${validated.workspaceId}`);
117
+ }
118
+ const inspected = await inspectMarkdownDocument(validated.path, workspace, this.#maxDocumentBytes);
119
+ const id = documentId(validated.workspaceId, inspected.path);
120
+ const existing = this.#storage.getDocument(id);
121
+ const now = new Date().toISOString();
122
+ const contentChanged = existing !== undefined && existing.contentHash !== inspected.contentHash;
123
+ const tags = validated.tags === undefined
124
+ ? (existing?.tags ?? [])
125
+ : normalizeTags(validated.tags);
126
+ const document = {
127
+ id,
128
+ workspaceId: validated.workspaceId,
129
+ ...(validated.taskId === undefined
130
+ ? existing?.taskId === undefined
131
+ ? {}
132
+ : { taskId: existing.taskId }
133
+ : { taskId: validated.taskId }),
134
+ ...(validated.producer === undefined
135
+ ? existing?.producer === undefined
136
+ ? {}
137
+ : { producer: existing.producer }
138
+ : { producer: validated.producer }),
139
+ kind: validated.kind,
140
+ title: validated.title,
141
+ path: inspected.path,
142
+ attention: validated.attention,
143
+ tags,
144
+ contentHash: inspected.contentHash,
145
+ revision: existing ? existing.revision + (contentChanged ? 1 : 0) : 1,
146
+ openedRevision: existing?.openedRevision ?? null,
147
+ completedRevision: existing?.completedRevision ?? null,
148
+ archivedAt: existing?.archivedAt ?? null,
149
+ missingAt: null,
150
+ createdAt: existing?.createdAt ?? now,
151
+ updatedAt: now,
152
+ };
153
+ this.#storage.saveDocument(document);
154
+ return presentDocument(document);
155
+ }
156
+ async markDocumentOpened(id) {
157
+ return this.#updateDocument(id, (document) => ({
158
+ ...document,
159
+ openedRevision: document.revision,
160
+ }));
161
+ }
162
+ async markDocumentRead(id) {
163
+ return this.#updateDocument(id, (document) => ({
164
+ ...document,
165
+ completedRevision: document.revision,
166
+ }));
167
+ }
168
+ async markDocumentUnread(id) {
169
+ return this.#updateDocument(id, (document) => ({
170
+ ...document,
171
+ openedRevision: null,
172
+ completedRevision: null,
173
+ }));
174
+ }
175
+ async setDocumentTags(id, tags) {
176
+ const normalized = normalizeTags(tags);
177
+ return this.#updateDocument(id, (document) => ({
178
+ ...document,
179
+ tags: normalized,
180
+ }));
181
+ }
182
+ async archiveDocument(id) {
183
+ return this.#updateDocument(id, (document) => ({
184
+ ...document,
185
+ archivedAt: document.archivedAt ?? new Date().toISOString(),
186
+ }));
187
+ }
188
+ async restoreDocument(id) {
189
+ return this.#updateDocument(id, (document) => ({
190
+ ...document,
191
+ archivedAt: null,
192
+ }));
193
+ }
194
+ async markDocumentMissing(id) {
195
+ return this.#updateDocument(id, (document) => ({
196
+ ...document,
197
+ missingAt: document.missingAt ?? new Date().toISOString(),
198
+ }));
199
+ }
200
+ async markDocumentPresent(id) {
201
+ return this.#updateDocument(id, (document) => ({
202
+ ...document,
203
+ missingAt: null,
204
+ }));
205
+ }
206
+ #updateDocument(id, update) {
207
+ validateDocumentId(id);
208
+ const existing = this.#storage.getDocument(id);
209
+ if (!existing) {
210
+ throw new Error(`unknown document ${id}`);
211
+ }
212
+ const updated = update(existing);
213
+ this.#storage.saveDocument(updated);
214
+ return presentDocument(updated);
215
+ }
216
+ async #migrateLegacyState(legacyStatePath) {
217
+ const state = await loadLegacyState(legacyStatePath);
218
+ if (!state) {
219
+ return;
220
+ }
221
+ const backupPath = `${legacyStatePath}.migrated`;
222
+ try {
223
+ await access(backupPath);
224
+ throw new Error(`legacy catalog backup already exists: ${backupPath}`);
225
+ }
226
+ catch (error) {
227
+ if (!isNodeError(error) || error.code !== "ENOENT") {
228
+ throw error;
229
+ }
230
+ }
231
+ const workspaces = [];
232
+ for (const legacyWorkspace of state.workspaces) {
233
+ const root = await canonicalDirectory(legacyWorkspace.root, "legacy workspace root");
234
+ const artifactRoots = [];
235
+ for (const candidate of legacyWorkspace.artifactRoots) {
236
+ const artifactRoot = await canonicalDirectory(candidate, "legacy artifact root");
237
+ if (!isWithin(root, artifactRoot)) {
238
+ throw new Error("artifact root is outside workspace root");
239
+ }
240
+ if (!artifactRoots.includes(artifactRoot)) {
241
+ artifactRoots.push(artifactRoot);
242
+ }
243
+ }
244
+ workspaces.push({
245
+ id: legacyWorkspace.id,
246
+ name: legacyWorkspace.name,
247
+ root,
248
+ artifactRoots,
249
+ });
250
+ }
251
+ const documents = [];
252
+ for (const legacyDocument of state.documents) {
253
+ const workspace = workspaces.find(({ id }) => id === legacyDocument.workspaceId);
254
+ if (!workspace) {
255
+ throw new Error(`document ${legacyDocument.id} references an unknown workspace`);
256
+ }
257
+ const inspected = await inspectMarkdownDocument(legacyDocument.path, workspace, this.#maxDocumentBytes);
258
+ documents.push({
259
+ id: legacyDocument.id,
260
+ workspaceId: legacyDocument.workspaceId,
261
+ ...(legacyDocument.taskId === undefined
262
+ ? {}
263
+ : { taskId: legacyDocument.taskId }),
264
+ kind: legacyDocument.kind,
265
+ title: legacyDocument.title,
266
+ path: inspected.path,
267
+ attention: legacyDocument.attention,
268
+ tags: [],
269
+ contentHash: inspected.contentHash,
270
+ revision: 1,
271
+ openedRevision: null,
272
+ completedRevision: null,
273
+ archivedAt: null,
274
+ missingAt: null,
275
+ createdAt: legacyDocument.createdAt,
276
+ updatedAt: legacyDocument.updatedAt,
277
+ });
278
+ }
279
+ this.#storage.transaction(() => {
280
+ for (const workspace of workspaces) {
281
+ this.#storage.saveWorkspace(workspace);
282
+ }
283
+ for (const document of documents) {
284
+ this.#storage.saveDocument(document);
285
+ }
286
+ });
287
+ await rename(legacyStatePath, backupPath);
288
+ await chmod(backupPath, 0o600);
289
+ }
290
+ }
291
+ function validateCatalogOptions(options) {
292
+ if (!isRecord(options)) {
293
+ throw new Error("catalog options must be an object");
294
+ }
295
+ if (!hasOnlyKeys(options, ["maxDocumentBytes", "legacyStatePath"])) {
296
+ throw new Error("catalog options contain unknown fields");
297
+ }
298
+ if (options.maxDocumentBytes !== undefined &&
299
+ (typeof options.maxDocumentBytes !== "number" ||
300
+ !Number.isSafeInteger(options.maxDocumentBytes) ||
301
+ options.maxDocumentBytes <= 0)) {
302
+ throw new Error("maxDocumentBytes must be a positive integer");
303
+ }
304
+ if (options.legacyStatePath !== undefined &&
305
+ options.legacyStatePath !== false &&
306
+ (typeof options.legacyStatePath !== "string" ||
307
+ options.legacyStatePath.trim() === "")) {
308
+ throw new Error("legacyStatePath must be a path or false");
309
+ }
310
+ }
311
+ function validateAddWorkspaceInput(input) {
312
+ if (!isRecord(input) ||
313
+ !hasOnlyKeys(input, ["id", "name", "root", "artifactRoots"]) ||
314
+ typeof input.id !== "string" ||
315
+ !WORKSPACE_ID_PATTERN.test(input.id) ||
316
+ typeof input.name !== "string" ||
317
+ input.name.trim() === "" ||
318
+ input.name.length > MAX_TITLE_LENGTH ||
319
+ typeof input.root !== "string" ||
320
+ input.root.trim() === "" ||
321
+ !isStringArray(input.artifactRoots) ||
322
+ input.artifactRoots.length === 0 ||
323
+ input.artifactRoots.some((path) => path.trim() === "")) {
324
+ throw new Error("invalid workspace input");
325
+ }
326
+ }
327
+ function validateRegisterDocumentInput(input) {
328
+ if (!isRecord(input) ||
329
+ !hasOnlyKeys(input, [
330
+ "workspaceId",
331
+ "taskId",
332
+ "producer",
333
+ "kind",
334
+ "title",
335
+ "path",
336
+ "attention",
337
+ "tags",
338
+ ]) ||
339
+ typeof input.workspaceId !== "string" ||
340
+ !WORKSPACE_ID_PATTERN.test(input.workspaceId) ||
341
+ (input.taskId !== undefined &&
342
+ (typeof input.taskId !== "string" ||
343
+ input.taskId.trim() === "" ||
344
+ input.taskId.length > MAX_CONTEXT_LENGTH)) ||
345
+ (input.producer !== undefined &&
346
+ (typeof input.producer !== "string" ||
347
+ input.producer.trim() === "" ||
348
+ input.producer.length > MAX_CONTEXT_LENGTH)) ||
349
+ typeof input.kind !== "string" ||
350
+ !isDocumentKind(input.kind) ||
351
+ typeof input.title !== "string" ||
352
+ input.title.trim() === "" ||
353
+ input.title.length > MAX_TITLE_LENGTH ||
354
+ typeof input.path !== "string" ||
355
+ input.path.trim() === "" ||
356
+ typeof input.attention !== "string" ||
357
+ !isAttention(input.attention) ||
358
+ (input.tags !== undefined && !isStringArray(input.tags))) {
359
+ throw new Error("invalid document registration input");
360
+ }
361
+ if (input.tags !== undefined) {
362
+ normalizeTags(input.tags);
363
+ }
364
+ return {
365
+ workspaceId: input.workspaceId,
366
+ ...(input.taskId === undefined ? {} : { taskId: input.taskId.trim() }),
367
+ ...(input.producer === undefined
368
+ ? {}
369
+ : { producer: input.producer.trim() }),
370
+ kind: input.kind,
371
+ title: input.title.trim(),
372
+ path: input.path,
373
+ attention: input.attention,
374
+ ...(input.tags === undefined ? {} : { tags: [...input.tags] }),
375
+ };
376
+ }
377
+ function validateFilters(filters) {
378
+ if (!isRecord(filters) ||
379
+ !hasOnlyKeys(filters, [
380
+ "workspaceId",
381
+ "taskId",
382
+ "tag",
383
+ "status",
384
+ "kind",
385
+ "attention",
386
+ "archived",
387
+ "missing",
388
+ ])) {
389
+ throw new Error("invalid document filters");
390
+ }
391
+ if (filters.workspaceId !== undefined &&
392
+ (typeof filters.workspaceId !== "string" ||
393
+ !WORKSPACE_ID_PATTERN.test(filters.workspaceId))) {
394
+ throw new Error("invalid workspace filter");
395
+ }
396
+ if (filters.taskId !== undefined &&
397
+ (typeof filters.taskId !== "string" || filters.taskId.trim() === "")) {
398
+ throw new Error("invalid task filter");
399
+ }
400
+ if (filters.tag !== undefined &&
401
+ (typeof filters.tag !== "string" ||
402
+ !TAG_PATTERN.test(filters.tag.trim().toLowerCase()))) {
403
+ throw new Error("invalid tag filter");
404
+ }
405
+ if (filters.status !== undefined &&
406
+ (typeof filters.status !== "string" ||
407
+ !["unread", "reading", "done"].includes(filters.status))) {
408
+ throw new Error("invalid status filter");
409
+ }
410
+ if (filters.kind !== undefined &&
411
+ (typeof filters.kind !== "string" || !isDocumentKind(filters.kind))) {
412
+ throw new Error("invalid kind filter");
413
+ }
414
+ if (filters.attention !== undefined &&
415
+ (typeof filters.attention !== "string" ||
416
+ !isAttention(filters.attention))) {
417
+ throw new Error("invalid attention filter");
418
+ }
419
+ if ((filters.archived !== undefined &&
420
+ typeof filters.archived !== "boolean") ||
421
+ (filters.missing !== undefined && typeof filters.missing !== "boolean")) {
422
+ throw new Error("invalid boolean document filter");
423
+ }
424
+ return {
425
+ ...filters,
426
+ ...(filters.tag === undefined
427
+ ? {}
428
+ : { tag: filters.tag.trim().toLowerCase() }),
429
+ };
430
+ }
431
+ function normalizeTags(tags) {
432
+ if (!Array.isArray(tags) || tags.length > 64) {
433
+ throw new Error("document tags must be an array of at most 64 entries");
434
+ }
435
+ const normalized = tags.map((tag) => {
436
+ if (typeof tag !== "string") {
437
+ throw new Error("document tags must be strings");
438
+ }
439
+ const value = tag.trim().toLowerCase();
440
+ if (!TAG_PATTERN.test(value)) {
441
+ throw new Error("tags must use lowercase letters, digits, dots, slashes, underscores, or hyphens");
442
+ }
443
+ return value;
444
+ });
445
+ return [...new Set(normalized)].sort();
446
+ }
447
+ async function inspectMarkdownDocument(inputPath, workspace, maxDocumentBytes) {
448
+ const requestedPath = resolve(inputPath);
449
+ if (extname(requestedPath).toLowerCase() !== ".md") {
450
+ throw new Error("only Markdown files can be registered");
451
+ }
452
+ const requestedInfo = await lstat(requestedPath);
453
+ if (requestedInfo.isSymbolicLink()) {
454
+ throw new Error("document path must not be a symlink");
455
+ }
456
+ const documentPath = await realpath(requestedPath);
457
+ if (!workspace.artifactRoots.some((artifactRoot) => isWithin(artifactRoot, documentPath))) {
458
+ throw new Error("document is outside registered artifact roots");
459
+ }
460
+ const content = await readBoundedRegularFile(documentPath, maxDocumentBytes);
461
+ return {
462
+ path: documentPath,
463
+ contentHash: createHash("sha256").update(content).digest("hex"),
464
+ content,
465
+ };
466
+ }
467
+ async function readBoundedRegularFile(path, maxBytes) {
468
+ const handle = await open(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
469
+ try {
470
+ const info = await handle.stat();
471
+ if (!info.isFile()) {
472
+ throw new Error("document must be a regular file");
473
+ }
474
+ if (info.size > maxBytes) {
475
+ throw new Error(`document exceeds ${maxBytes} bytes`);
476
+ }
477
+ const buffer = Buffer.alloc(maxBytes + 1);
478
+ let offset = 0;
479
+ while (offset < buffer.length) {
480
+ const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
481
+ if (bytesRead === 0) {
482
+ break;
483
+ }
484
+ offset += bytesRead;
485
+ }
486
+ if (offset > maxBytes) {
487
+ throw new Error(`document exceeds ${maxBytes} bytes`);
488
+ }
489
+ return buffer.subarray(0, offset);
490
+ }
491
+ finally {
492
+ await handle.close();
493
+ }
494
+ }
495
+ async function canonicalDirectory(path, label) {
496
+ const canonical = await realpath(resolve(path));
497
+ const info = await stat(canonical);
498
+ if (!info.isDirectory()) {
499
+ throw new Error(`${label} must be a directory`);
500
+ }
501
+ return canonical;
502
+ }
503
+ function isWithin(root, candidate) {
504
+ const rel = relative(root, candidate);
505
+ return (rel === "" ||
506
+ (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)));
507
+ }
508
+ function documentId(workspaceId, path) {
509
+ const hash = createHash("sha256")
510
+ .update(workspaceId)
511
+ .update("\0")
512
+ .update(path)
513
+ .digest("hex")
514
+ .slice(0, 20);
515
+ return `doc-${hash}`;
516
+ }
517
+ function validateDocumentId(id) {
518
+ if (typeof id !== "string" || !/^doc-[a-f0-9]{20}$/.test(id)) {
519
+ throw new Error("invalid document id");
520
+ }
521
+ }
522
+ function defaultLegacyStatePath(databasePath) {
523
+ return databasePath.endsWith(".sqlite3")
524
+ ? `${databasePath.slice(0, -".sqlite3".length)}.json`
525
+ : `${databasePath}.json`;
526
+ }
527
+ async function loadLegacyState(path) {
528
+ try {
529
+ const info = await lstat(path);
530
+ if (info.isSymbolicLink() || !info.isFile()) {
531
+ throw new Error("legacy catalog state must be a regular file");
532
+ }
533
+ if (info.size > MAX_LEGACY_CATALOG_BYTES) {
534
+ throw new Error(`legacy catalog state exceeds ${MAX_LEGACY_CATALOG_BYTES} bytes`);
535
+ }
536
+ const parsed = JSON.parse(await readFile(path, "utf8"));
537
+ return validateLegacyState(parsed);
538
+ }
539
+ catch (error) {
540
+ if (isNodeError(error) && error.code === "ENOENT") {
541
+ return undefined;
542
+ }
543
+ throw error;
544
+ }
545
+ }
546
+ function validateLegacyState(value) {
547
+ if (!isRecord(value) ||
548
+ !hasOnlyKeys(value, ["schemaVersion", "workspaces", "documents"]) ||
549
+ value.schemaVersion !== CATALOG_SCHEMA_VERSION ||
550
+ !Array.isArray(value.workspaces) ||
551
+ !Array.isArray(value.documents)) {
552
+ throw new Error("invalid legacy catalog state");
553
+ }
554
+ const workspaces = value.workspaces.map(validateLegacyWorkspace);
555
+ const workspaceIds = new Set();
556
+ for (const workspace of workspaces) {
557
+ if (workspaceIds.has(workspace.id)) {
558
+ throw new Error(`duplicate workspace ${workspace.id}`);
559
+ }
560
+ workspaceIds.add(workspace.id);
561
+ }
562
+ const documents = value.documents.map(validateLegacyDocument);
563
+ const documentIds = new Set();
564
+ for (const document of documents) {
565
+ if (documentIds.has(document.id)) {
566
+ throw new Error(`duplicate document ${document.id}`);
567
+ }
568
+ documentIds.add(document.id);
569
+ const workspace = workspaces.find(({ id }) => id === document.workspaceId);
570
+ if (!workspace) {
571
+ throw new Error(`document ${document.id} references an unknown workspace`);
572
+ }
573
+ if (!workspace.artifactRoots.some((root) => isWithin(root, document.path))) {
574
+ throw new Error(`document ${document.id} is outside registered artifact roots`);
575
+ }
576
+ }
577
+ return { schemaVersion: 1, workspaces, documents };
578
+ }
579
+ function validateLegacyWorkspace(value) {
580
+ if (!isRecord(value) ||
581
+ !hasOnlyKeys(value, ["id", "name", "root", "artifactRoots"]) ||
582
+ typeof value.id !== "string" ||
583
+ !WORKSPACE_ID_PATTERN.test(value.id) ||
584
+ typeof value.name !== "string" ||
585
+ value.name.trim() === "" ||
586
+ typeof value.root !== "string" ||
587
+ !isAbsolute(value.root) ||
588
+ !isStringArray(value.artifactRoots) ||
589
+ value.artifactRoots.length === 0 ||
590
+ value.artifactRoots.some((artifactRoot) => !isAbsolute(artifactRoot) ||
591
+ !isWithin(value.root, artifactRoot))) {
592
+ throw new Error("invalid workspace entry in legacy catalog state");
593
+ }
594
+ return {
595
+ id: value.id,
596
+ name: value.name,
597
+ root: value.root,
598
+ artifactRoots: [...value.artifactRoots],
599
+ };
600
+ }
601
+ function validateLegacyDocument(value) {
602
+ if (!isRecord(value) ||
603
+ !hasOnlyKeys(value, [
604
+ "id",
605
+ "workspaceId",
606
+ "taskId",
607
+ "kind",
608
+ "title",
609
+ "path",
610
+ "attention",
611
+ "createdAt",
612
+ "updatedAt",
613
+ ]) ||
614
+ typeof value.id !== "string" ||
615
+ !/^doc-[a-f0-9]{20}$/.test(value.id) ||
616
+ typeof value.workspaceId !== "string" ||
617
+ (value.taskId !== undefined && typeof value.taskId !== "string") ||
618
+ typeof value.kind !== "string" ||
619
+ !isDocumentKind(value.kind) ||
620
+ typeof value.title !== "string" ||
621
+ value.title.trim() === "" ||
622
+ typeof value.path !== "string" ||
623
+ !isAbsolute(value.path) ||
624
+ extname(value.path).toLowerCase() !== ".md" ||
625
+ typeof value.attention !== "string" ||
626
+ !isAttention(value.attention) ||
627
+ !isIsoDate(value.createdAt) ||
628
+ !isIsoDate(value.updatedAt)) {
629
+ throw new Error("invalid document entry in legacy catalog state");
630
+ }
631
+ return {
632
+ id: value.id,
633
+ workspaceId: value.workspaceId,
634
+ ...(value.taskId === undefined ? {} : { taskId: value.taskId }),
635
+ kind: value.kind,
636
+ title: value.title,
637
+ path: value.path,
638
+ attention: value.attention,
639
+ createdAt: value.createdAt,
640
+ updatedAt: value.updatedAt,
641
+ };
642
+ }
643
+ function isIsoDate(value) {
644
+ return typeof value === "string" && !Number.isNaN(Date.parse(value));
645
+ }
646
+ function isRecord(value) {
647
+ return typeof value === "object" && value !== null && !Array.isArray(value);
648
+ }
649
+ function isStringArray(value) {
650
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
651
+ }
652
+ function hasOnlyKeys(value, allowed) {
653
+ const allowedSet = new Set(allowed);
654
+ return Object.keys(value).every((key) => allowedSet.has(key));
655
+ }
656
+ function isNodeError(error) {
657
+ return error instanceof Error && "code" in error;
658
+ }
659
+ //# sourceMappingURL=catalog.js.map