@ferrule-io/ok-fine 0.0.0-stage → 0.3.6

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.
@@ -0,0 +1,819 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdir, rm } from "node:fs/promises";
3
+ import { join, posix } from "node:path";
4
+ import { OkfError } from "../errors.js";
5
+ import { parseConcept } from "../okf/concept.js";
6
+ import { appendVerification, applyFrontmatter, isoAfterDays, nowIso, parseFrontmatter, serializeConcept, splitFrontmatter, } from "../okf/frontmatter.js";
7
+ import { renderIndex } from "../okf/index-file.js";
8
+ import { lintConceptFile } from "../okf/lint.js";
9
+ import { logCreationEntry, logDeletionEntry, logFileDeletionEntry, logFileUpdateEntry, logImportEntry, logInitializationEntry, logUpdateEntry, logVerificationEntry, prependLogEntry, } from "../okf/log-file.js";
10
+ import { blobRevision, normalizeConceptIdForWrite, normalizeFilePathForWrite, PROJECT_RE, resolveReadPath, } from "../okf/paths.js";
11
+ import { normalizeRepository } from "../okf/repository.js";
12
+ import { isStale } from "../okf/semantics.js";
13
+ import { extractBundleArchive } from "../store/archive.js";
14
+ import { bundleSource, } from "../store/backend.js";
15
+ import { buildDirListing, lintBundle, planIndexes } from "../store/bundle.js";
16
+ import { fsBundleSource } from "../store/fs-util.js";
17
+ import { checkActor } from "./principal.js";
18
+ export class KnowledgeService {
19
+ config;
20
+ storage;
21
+ catalog;
22
+ log;
23
+ constructor(opts) {
24
+ this.config = opts.config;
25
+ this.storage = opts.storage;
26
+ this.catalog = opts.catalog;
27
+ this.log = opts.log;
28
+ this.storage.setResyncHandler(async (project, tx) => {
29
+ const tree = await this.storage.tree(project);
30
+ if (tree.isEmpty) {
31
+ this.catalog.removeProject(project);
32
+ return;
33
+ }
34
+ await this.catalog.rebuildProject(project, bundleSource(this.storage, project, tree));
35
+ await this.regenerateIndexes(tx, project, "all");
36
+ });
37
+ }
38
+ async initialize() {
39
+ const tmpDir = join(this.config.dataDir, "tmp");
40
+ await rm(tmpDir, { recursive: true, force: true });
41
+ await mkdir(tmpDir, { recursive: true });
42
+ const projects = await this.storage.projects();
43
+ for (const project of projects) {
44
+ const tree = await this.storage.tree(project);
45
+ await this.catalog.rebuildProject(project, bundleSource(this.storage, project, tree));
46
+ }
47
+ // Reconciliation: regenerate all indexes and ensure log.md; the transaction commits only on change.
48
+ await this.storage.transaction({ projects }, async (tx) => {
49
+ for (const project of projects) {
50
+ await this.regenerateIndexes(tx, project, "all");
51
+ if (!(await this.storage.tree(project)).hasFile("log.md")) {
52
+ await tx.writeFile(project, "log.md", "# Update Log\n");
53
+ }
54
+ }
55
+ return {
56
+ value: undefined,
57
+ commit: {
58
+ subject: "okf: regenerate indexes",
59
+ author: "process:ok-fine",
60
+ principal: { subject: "system", clientId: "ok-fine" },
61
+ },
62
+ };
63
+ });
64
+ }
65
+ /** Writes every index.md that differs from the planned content and deletes obsolete ones. */
66
+ async regenerateIndexes(tx, project, dirs) {
67
+ const plan = planIndexes(await this.storage.tree(project), dirs, (id) => this.catalog.get(project, id));
68
+ for (const { path, content } of plan) {
69
+ if (content === null) {
70
+ await tx.deleteFile(project, path);
71
+ }
72
+ else if ((await this.storage.readFile(project, path))?.toString("utf8") !== content) {
73
+ await tx.writeFile(project, path, content);
74
+ }
75
+ }
76
+ }
77
+ async prependLog(tx, project, entry) {
78
+ const existing = (await this.storage.readFile(project, "log.md"))?.toString("utf8") ?? null;
79
+ await tx.writeFile(project, "log.md", prependLogEntry(existing, nowIso().slice(0, 10), entry));
80
+ }
81
+ async assertProjectExists(project, forWrite = false) {
82
+ // Every project-scoped path is built from this name; PROJECT_RE forbids `.`, `..`, and `/`.
83
+ if (!PROJECT_RE.test(project)) {
84
+ throw new OkfError("invalid_id", 400, `invalid project name "${project}"`);
85
+ }
86
+ const tree = await this.storage.tree(project);
87
+ if (tree.isEmpty) {
88
+ const msg = forWrite ? "call create_project first" : `project "${project}" not found`;
89
+ throw new OkfError("project_not_found", 404, msg);
90
+ }
91
+ return tree;
92
+ }
93
+ /** Normalized, deduplicated `repositories` from the project's overview frontmatter, in first-seen order. */
94
+ repositoriesOf(project) {
95
+ const raw = this.catalog.get(project, "overview")?.frontmatter?.repositories;
96
+ const values = typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw : [];
97
+ const out = [];
98
+ for (const value of values) {
99
+ if (typeof value !== "string")
100
+ continue;
101
+ const normalized = normalizeRepository(value);
102
+ if (normalized !== null && !out.includes(normalized))
103
+ out.push(normalized);
104
+ }
105
+ return out;
106
+ }
107
+ listProjects(filter = {}) {
108
+ let wanted = null;
109
+ if (filter.repository !== undefined) {
110
+ wanted = normalizeRepository(filter.repository);
111
+ if (wanted === null) {
112
+ throw new OkfError("bad_request", 400, "repository must be a git remote URL such as git@github.com:org/repo.git or https://github.com/org/repo");
113
+ }
114
+ }
115
+ const projects = this.catalog.projects();
116
+ const now = new Date();
117
+ const summaries = [];
118
+ for (const project of projects) {
119
+ const records = this.catalog.records(project);
120
+ const overview = this.catalog.get(project, "overview");
121
+ let latestUpdatedAt = null;
122
+ let staleCount = 0;
123
+ for (const r of records) {
124
+ if (isStale(r.frontmatter, now)) {
125
+ staleCount++;
126
+ }
127
+ if (r.generatedAt) {
128
+ if (!latestUpdatedAt || r.generatedAt > latestUpdatedAt) {
129
+ latestUpdatedAt = r.generatedAt;
130
+ }
131
+ }
132
+ }
133
+ const repositories = this.repositoriesOf(project);
134
+ if (wanted !== null && !repositories.includes(wanted))
135
+ continue;
136
+ summaries.push({
137
+ project,
138
+ title: overview?.title ?? project,
139
+ description: overview?.description ?? null,
140
+ conceptCount: records.length,
141
+ staleCount,
142
+ updatedAt: latestUpdatedAt,
143
+ repositories,
144
+ });
145
+ }
146
+ return { projects: summaries };
147
+ }
148
+ async getProject(project) {
149
+ await this.assertProjectExists(project);
150
+ const records = this.catalog.records(project);
151
+ const overview = this.catalog.get(project, "overview");
152
+ const now = new Date();
153
+ let latestUpdatedAt = null;
154
+ let staleCount = 0;
155
+ const typeCounts = {};
156
+ const trustTierCounts = {
157
+ unverified: 0,
158
+ "machine-confirmed": 0,
159
+ "human-reviewed": 0,
160
+ };
161
+ for (const r of records) {
162
+ if (isStale(r.frontmatter, now)) {
163
+ staleCount++;
164
+ }
165
+ if (r.generatedAt) {
166
+ if (!latestUpdatedAt || r.generatedAt > latestUpdatedAt) {
167
+ latestUpdatedAt = r.generatedAt;
168
+ }
169
+ }
170
+ const tKey = r.type && r.type.length > 0 ? r.type : "Other";
171
+ typeCounts[tKey] = (typeCounts[tKey] ?? 0) + 1;
172
+ trustTierCounts[r.trustTier] = (trustTierCounts[r.trustTier] ?? 0) + 1;
173
+ }
174
+ return {
175
+ project,
176
+ title: overview?.title ?? project,
177
+ description: overview?.description ?? null,
178
+ conceptCount: records.length,
179
+ staleCount,
180
+ updatedAt: latestUpdatedAt,
181
+ repositories: this.repositoriesOf(project),
182
+ typeCounts,
183
+ trustTierCounts,
184
+ };
185
+ }
186
+ async getIndex(project, dir = "") {
187
+ const tree = await this.assertProjectExists(project);
188
+ let cleanDir = "";
189
+ if (dir && dir !== "/" && dir !== ".") {
190
+ cleanDir = resolveReadPath(dir);
191
+ }
192
+ if (!tree.hasDir(cleanDir)) {
193
+ throw new OkfError("not_found", 404, `directory "${dir}" not found in project "${project}"`);
194
+ }
195
+ const listing = buildDirListing(tree, cleanDir, (id) => this.catalog.get(project, id));
196
+ const markdown = renderIndex(listing);
197
+ const entries = [];
198
+ for (const c of listing.concepts) {
199
+ const idWithoutMd = c.file.endsWith(".md") ? c.file.slice(0, -3) : c.file;
200
+ const fullId = cleanDir === "" ? idWithoutMd : posix.join(cleanDir, idWithoutMd);
201
+ entries.push({
202
+ kind: "concept",
203
+ id: fullId,
204
+ title: c.title,
205
+ description: c.description,
206
+ type: c.type,
207
+ });
208
+ }
209
+ for (const f of listing.files) {
210
+ entries.push({
211
+ kind: "file",
212
+ path: cleanDir === "" ? f : posix.join(cleanDir, f),
213
+ });
214
+ }
215
+ for (const d of listing.dirs) {
216
+ entries.push({
217
+ kind: "directory",
218
+ path: cleanDir === "" ? d.name : posix.join(cleanDir, d.name),
219
+ conceptCount: d.conceptCount,
220
+ fileCount: d.fileCount,
221
+ });
222
+ }
223
+ return {
224
+ project,
225
+ path: cleanDir,
226
+ markdown,
227
+ entries,
228
+ };
229
+ }
230
+ async readConcept(project, id) {
231
+ const tree = await this.assertProjectExists(project);
232
+ let s = id;
233
+ if (s.startsWith("/")) {
234
+ s = s.slice(1);
235
+ }
236
+ if (s.endsWith(".md")) {
237
+ s = s.slice(0, -3);
238
+ }
239
+ const cleanId = resolveReadPath(s);
240
+ const buf = await this.storage.readFile(project, `${cleanId}.md`);
241
+ if (buf === null) {
242
+ throw new OkfError("not_found", 404, `concept "${cleanId}" not found in project "${project}"`);
243
+ }
244
+ const text = buf.toString("utf8");
245
+ const revision = blobRevision(buf);
246
+ const ctx = {
247
+ now: new Date(),
248
+ conceptExists: (targetId) => this.catalog.get(project, targetId) !== undefined,
249
+ fileExists: (bundlePath) => tree.exists(bundlePath),
250
+ };
251
+ const issues = lintConceptFile(`${cleanId}.md`, text, ctx);
252
+ const split = splitFrontmatter(text);
253
+ if (!split) {
254
+ return {
255
+ project,
256
+ id: cleanId,
257
+ revision,
258
+ markdown: text,
259
+ frontmatter: null,
260
+ body: text,
261
+ derived: null,
262
+ links: {
263
+ outbound: [],
264
+ inbound: this.catalog.inbound(project, cleanId),
265
+ },
266
+ issues,
267
+ };
268
+ }
269
+ const parsed = parseFrontmatter(split.yaml);
270
+ if ("error" in parsed) {
271
+ return {
272
+ project,
273
+ id: cleanId,
274
+ revision,
275
+ markdown: text,
276
+ frontmatter: null,
277
+ body: split.body,
278
+ derived: null,
279
+ links: {
280
+ outbound: [],
281
+ inbound: this.catalog.inbound(project, cleanId),
282
+ },
283
+ issues,
284
+ };
285
+ }
286
+ const record = parseConcept(project, cleanId, `${cleanId}.md`, buf);
287
+ const derived = {
288
+ title: record.title,
289
+ type: record.type,
290
+ description: record.description,
291
+ tags: record.tags,
292
+ status: record.status,
293
+ trustTier: record.trustTier,
294
+ stale: isStale(record.frontmatter, new Date()),
295
+ staleAfter: record.staleAfter,
296
+ generatedAt: record.generatedAt,
297
+ generatedBy: record.generatedBy,
298
+ lastVerifiedAt: record.lastVerifiedAt,
299
+ };
300
+ const outbound = record.outbound.map((target) => ({
301
+ id: target,
302
+ exists: this.catalog.get(project, target) !== undefined,
303
+ }));
304
+ return {
305
+ project,
306
+ id: cleanId,
307
+ revision,
308
+ markdown: text,
309
+ frontmatter: record.frontmatter,
310
+ body: record.body,
311
+ derived,
312
+ links: {
313
+ outbound,
314
+ inbound: this.catalog.inbound(project, cleanId),
315
+ },
316
+ issues,
317
+ };
318
+ }
319
+ async search(params) {
320
+ if (params.project) {
321
+ await this.assertProjectExists(params.project);
322
+ }
323
+ return { results: this.catalog.search(params) };
324
+ }
325
+ async history(project, id, limit = 20) {
326
+ await this.assertProjectExists(project);
327
+ let path = null;
328
+ if (id) {
329
+ let s = id;
330
+ if (s.startsWith("/")) {
331
+ s = s.slice(1);
332
+ }
333
+ if (s.endsWith(".md")) {
334
+ s = s.slice(0, -3);
335
+ }
336
+ path = `${resolveReadPath(s)}.md`;
337
+ }
338
+ const commits = await this.storage.history(project, path, limit);
339
+ return { commits };
340
+ }
341
+ async readFile(project, path) {
342
+ await this.assertProjectExists(project);
343
+ const cleanPath = resolveReadPath(path);
344
+ const buf = await this.storage.readFile(project, cleanPath);
345
+ if (buf === null) {
346
+ throw new OkfError("not_found", 404, `file "${cleanPath}" not found in project "${project}"`);
347
+ }
348
+ if (buf.length > this.config.maxFileBytes) {
349
+ throw new OkfError("payload_too_large", 413, "file exceeds MAX_FILE_BYTES");
350
+ }
351
+ if (buf.includes(0)) {
352
+ throw new OkfError("unsupported_media", 415, "binary files containing NUL bytes are not supported");
353
+ }
354
+ return {
355
+ project,
356
+ path: cleanPath,
357
+ revision: blobRevision(buf),
358
+ content: buf.toString("utf8"),
359
+ };
360
+ }
361
+ async lint(project) {
362
+ const tree = await this.assertProjectExists(project);
363
+ const res = await lintBundle(bundleSource(this.storage, project, tree), new Date());
364
+ return {
365
+ project,
366
+ conformant: res.conformant,
367
+ issues: res.issues,
368
+ };
369
+ }
370
+ async createProject(p, args) {
371
+ checkActor(args.actor, p);
372
+ if (!PROJECT_RE.test(args.project)) {
373
+ throw new OkfError("invalid_id", 400, `invalid project name "${args.project}"`);
374
+ }
375
+ if (!args.title || args.title.trim().length === 0 || args.title.length > 200) {
376
+ throw new OkfError("bad_request", 400, "project title must be between 1 and 200 characters");
377
+ }
378
+ if (!(await this.storage.tree(args.project)).isEmpty) {
379
+ throw new OkfError("already_exists", 409, `project "${args.project}" already exists`);
380
+ }
381
+ const txRes = await this.storage.transaction({ projects: [args.project] }, async (tx) => {
382
+ const overviewBody = args.description && args.description.trim().length > 0
383
+ ? `# Overview\n\n${args.description.trim()}\n`
384
+ : "# Overview\n";
385
+ const fmInput = {
386
+ type: "Project",
387
+ title: args.title.trim(),
388
+ status: "stable",
389
+ };
390
+ if (args.description && args.description.trim().length > 0) {
391
+ fmInput.description = args.description.trim();
392
+ }
393
+ const fmRes = applyFrontmatter(null, fmInput, {
394
+ generated: { by: args.actor, at: nowIso() },
395
+ });
396
+ const overviewContent = serializeConcept(fmRes.doc, overviewBody);
397
+ await tx.writeFile(args.project, "overview.md", overviewContent);
398
+ await this.prependLog(tx, args.project, logInitializationEntry(args.actor));
399
+ const overviewBuf = Buffer.from(overviewContent, "utf8");
400
+ this.catalog.upsert(parseConcept(args.project, "overview", "overview.md", overviewBuf));
401
+ await this.regenerateIndexes(tx, args.project, [""]);
402
+ return {
403
+ value: { project: args.project },
404
+ commit: {
405
+ subject: `okf(${args.project}): create project`,
406
+ author: args.actor,
407
+ principal: { subject: p.subject, clientId: p.clientId },
408
+ },
409
+ };
410
+ });
411
+ return {
412
+ project: args.project,
413
+ commit: txRes.commit,
414
+ pushed: txRes.pushed,
415
+ warnings: txRes.warnings,
416
+ };
417
+ }
418
+ async writeConcept(p, args) {
419
+ checkActor(args.actor, p);
420
+ await this.assertProjectExists(args.project, true);
421
+ const cleanId = normalizeConceptIdForWrite(args.id);
422
+ if (!args.frontmatter || typeof args.frontmatter !== "object" || Array.isArray(args.frontmatter)) {
423
+ throw new OkfError("invalid_frontmatter", 400, "frontmatter must be a plain object");
424
+ }
425
+ if (typeof args.frontmatter.type !== "string" || args.frontmatter.type.trim().length === 0) {
426
+ throw new OkfError("invalid_frontmatter", 400, "frontmatter must contain a non-blank string type");
427
+ }
428
+ let resultPayload;
429
+ const txRes = await this.storage.transaction({ projects: [args.project] }, async (tx) => {
430
+ const filePath = `${cleanId}.md`;
431
+ let existingDoc = null;
432
+ let existingRev = null;
433
+ let existingRecord = null;
434
+ const existingBuf = await this.storage.readFile(args.project, filePath);
435
+ if (existingBuf !== null) {
436
+ existingRev = blobRevision(existingBuf);
437
+ if (args.expectedRevision === null) {
438
+ throw new OkfError("already_exists", 409, `concept "${cleanId}" already exists`);
439
+ }
440
+ if (typeof args.expectedRevision === "string" && args.expectedRevision !== existingRev) {
441
+ throw new OkfError("revision_conflict", 409, "revision conflict", {
442
+ currentRevision: existingRev,
443
+ });
444
+ }
445
+ const existingSplit = splitFrontmatter(existingBuf.toString("utf8"));
446
+ if (existingSplit) {
447
+ const parsed = parseFrontmatter(existingSplit.yaml);
448
+ if ("doc" in parsed) {
449
+ existingDoc = parsed.doc;
450
+ }
451
+ }
452
+ existingRecord = this.catalog.get(args.project, cleanId) ?? null;
453
+ }
454
+ else {
455
+ if (typeof args.expectedRevision === "string") {
456
+ throw new OkfError("revision_conflict", 409, "concept does not exist", {
457
+ currentRevision: null,
458
+ });
459
+ }
460
+ }
461
+ const isCreated = existingRev === null;
462
+ const prevStatus = existingRecord?.status;
463
+ let frontmatter = args.frontmatter;
464
+ if (this.config.defaultStaleAfterDays !== undefined && !("stale_after" in args.frontmatter)) {
465
+ frontmatter = {
466
+ ...args.frontmatter,
467
+ stale_after: isoAfterDays(new Date(), this.config.defaultStaleAfterDays),
468
+ };
469
+ }
470
+ const { doc, ignoredKeys } = applyFrontmatter(existingDoc, frontmatter, {
471
+ generated: { by: args.actor, at: nowIso() },
472
+ });
473
+ const newContent = serializeConcept(doc, args.body);
474
+ const newBuf = Buffer.from(newContent, "utf8");
475
+ if (newBuf.length > this.config.maxFileBytes) {
476
+ throw new OkfError("payload_too_large", 413, "concept exceeds MAX_FILE_BYTES");
477
+ }
478
+ await tx.writeFile(args.project, filePath, newBuf);
479
+ const newRev = blobRevision(newBuf);
480
+ const newRecord = parseConcept(args.project, cleanId, filePath, newBuf);
481
+ this.catalog.upsert(newRecord);
482
+ const touchedDir = posix.dirname(cleanId) === "." ? "" : posix.dirname(cleanId);
483
+ await this.regenerateIndexes(tx, args.project, [touchedDir]);
484
+ let logText;
485
+ const title = newRecord.title;
486
+ if (isCreated) {
487
+ logText = logCreationEntry(title, cleanId, args.actor, args.message);
488
+ }
489
+ else {
490
+ const isDeprecating = newRecord.status === "deprecated" && prevStatus !== "deprecated";
491
+ logText = logUpdateEntry(title, cleanId, args.actor, isDeprecating, args.message);
492
+ }
493
+ await this.prependLog(tx, args.project, logText);
494
+ const tree = await this.storage.tree(args.project);
495
+ const issues = lintConceptFile(`${cleanId}.md`, newContent, {
496
+ now: new Date(),
497
+ conceptExists: (id) => this.catalog.get(args.project, id) !== undefined,
498
+ fileExists: (bundlePath) => tree.exists(bundlePath),
499
+ });
500
+ resultPayload = {
501
+ revision: newRev,
502
+ created: isCreated,
503
+ ignoredKeys,
504
+ issues,
505
+ };
506
+ const subject = isCreated ? `okf(${args.project}): create ${cleanId}` : `okf(${args.project}): update ${cleanId}`;
507
+ return {
508
+ value: resultPayload,
509
+ commit: {
510
+ subject,
511
+ body: args.message,
512
+ author: args.actor,
513
+ principal: { subject: p.subject, clientId: p.clientId },
514
+ },
515
+ };
516
+ });
517
+ return {
518
+ project: args.project,
519
+ id: cleanId,
520
+ revision: resultPayload.revision,
521
+ created: resultPayload.created,
522
+ commit: txRes.commit,
523
+ pushed: txRes.pushed,
524
+ warnings: txRes.warnings,
525
+ ignoredKeys: resultPayload.ignoredKeys,
526
+ issues: resultPayload.issues,
527
+ };
528
+ }
529
+ async verifyConcept(p, args) {
530
+ checkActor(args.actor, p);
531
+ await this.assertProjectExists(args.project, true);
532
+ let s = args.id;
533
+ if (s.startsWith("/")) {
534
+ s = s.slice(1);
535
+ }
536
+ if (s.endsWith(".md")) {
537
+ s = s.slice(0, -3);
538
+ }
539
+ const cleanId = resolveReadPath(s);
540
+ const filePath = `${cleanId}.md`;
541
+ let newTrustTier;
542
+ let newRev;
543
+ const txRes = await this.storage.transaction({ projects: [args.project] }, async (tx) => {
544
+ const buf = await this.storage.readFile(args.project, filePath);
545
+ if (buf === null) {
546
+ throw new OkfError("not_found", 404, `concept "${cleanId}" not found in project "${args.project}"`);
547
+ }
548
+ const currentRev = blobRevision(buf);
549
+ if (args.expectedRevision && args.expectedRevision !== currentRev) {
550
+ throw new OkfError("revision_conflict", 409, "revision conflict", {
551
+ currentRevision: currentRev,
552
+ });
553
+ }
554
+ const text = buf.toString("utf8");
555
+ const split = splitFrontmatter(text);
556
+ if (!split) {
557
+ throw new OkfError("invalid_frontmatter", 400, "cannot verify an unparseable concept: missing frontmatter");
558
+ }
559
+ const parsed = parseFrontmatter(split.yaml);
560
+ if ("error" in parsed) {
561
+ throw new OkfError("invalid_frontmatter", 400, `cannot verify an unparseable concept: ${parsed.message}`);
562
+ }
563
+ appendVerification(parsed.doc, { by: args.actor, at: nowIso() });
564
+ const newContent = serializeConcept(parsed.doc, split.body);
565
+ const newBuf = Buffer.from(newContent, "utf8");
566
+ await tx.writeFile(args.project, filePath, newBuf);
567
+ newRev = blobRevision(newBuf);
568
+ const newRecord = parseConcept(args.project, cleanId, filePath, newBuf);
569
+ this.catalog.upsert(newRecord);
570
+ newTrustTier = newRecord.trustTier;
571
+ await this.prependLog(tx, args.project, logVerificationEntry(newRecord.title, cleanId, args.actor));
572
+ return {
573
+ value: { revision: newRev, trustTier: newTrustTier },
574
+ commit: {
575
+ subject: `okf(${args.project}): verify ${cleanId}`,
576
+ author: args.actor,
577
+ principal: { subject: p.subject, clientId: p.clientId },
578
+ },
579
+ };
580
+ });
581
+ return {
582
+ project: args.project,
583
+ id: cleanId,
584
+ revision: newRev,
585
+ trustTier: newTrustTier,
586
+ commit: txRes.commit,
587
+ pushed: txRes.pushed,
588
+ warnings: txRes.warnings,
589
+ };
590
+ }
591
+ async deleteConcept(p, args) {
592
+ checkActor(args.actor, p);
593
+ await this.assertProjectExists(args.project, true);
594
+ let s = args.id;
595
+ if (s.startsWith("/")) {
596
+ s = s.slice(1);
597
+ }
598
+ if (s.endsWith(".md")) {
599
+ s = s.slice(0, -3);
600
+ }
601
+ const cleanId = resolveReadPath(s);
602
+ const filePath = `${cleanId}.md`;
603
+ let brokenInbound = [];
604
+ const txRes = await this.storage.transaction({ projects: [args.project] }, async (tx) => {
605
+ const buf = await this.storage.readFile(args.project, filePath);
606
+ if (buf === null) {
607
+ throw new OkfError("not_found", 404, `concept "${cleanId}" not found in project "${args.project}"`);
608
+ }
609
+ const currentRev = blobRevision(buf);
610
+ if (args.expectedRevision && args.expectedRevision !== currentRev) {
611
+ throw new OkfError("revision_conflict", 409, "revision conflict", {
612
+ currentRevision: currentRev,
613
+ });
614
+ }
615
+ brokenInbound = this.catalog.inbound(args.project, cleanId);
616
+ await tx.deleteFile(args.project, filePath);
617
+ this.catalog.remove(args.project, cleanId);
618
+ const touchedDir = posix.dirname(cleanId) === "." ? "" : posix.dirname(cleanId);
619
+ await this.regenerateIndexes(tx, args.project, [touchedDir]);
620
+ await this.prependLog(tx, args.project, logDeletionEntry(cleanId, args.actor));
621
+ return {
622
+ value: { brokenInbound },
623
+ commit: {
624
+ subject: `okf(${args.project}): delete ${cleanId}`,
625
+ author: args.actor,
626
+ principal: { subject: p.subject, clientId: p.clientId },
627
+ },
628
+ };
629
+ });
630
+ return {
631
+ project: args.project,
632
+ id: cleanId,
633
+ commit: txRes.commit,
634
+ pushed: txRes.pushed,
635
+ warnings: txRes.warnings,
636
+ brokenInbound,
637
+ };
638
+ }
639
+ async writeFile(p, args) {
640
+ checkActor(args.actor, p);
641
+ await this.assertProjectExists(args.project, true);
642
+ const cleanPath = normalizeFilePathForWrite(args.path);
643
+ let newRev;
644
+ const txRes = await this.storage.transaction({ projects: [args.project] }, async (tx) => {
645
+ const dataBuf = Buffer.isBuffer(args.content) ? args.content : Buffer.from(args.content, "utf8");
646
+ if (dataBuf.length > this.config.maxFileBytes) {
647
+ throw new OkfError("payload_too_large", 413, "file exceeds MAX_FILE_BYTES");
648
+ }
649
+ const existingBuf = await this.storage.readFile(args.project, cleanPath);
650
+ if (existingBuf !== null) {
651
+ const currentRev = blobRevision(existingBuf);
652
+ if (args.expectedRevision === null) {
653
+ throw new OkfError("already_exists", 409, `file "${cleanPath}" already exists`);
654
+ }
655
+ if (typeof args.expectedRevision === "string" && args.expectedRevision !== currentRev) {
656
+ throw new OkfError("revision_conflict", 409, "revision conflict", {
657
+ currentRevision: currentRev,
658
+ });
659
+ }
660
+ }
661
+ else {
662
+ if (typeof args.expectedRevision === "string") {
663
+ throw new OkfError("revision_conflict", 409, "file does not exist", {
664
+ currentRevision: null,
665
+ });
666
+ }
667
+ }
668
+ await tx.writeFile(args.project, cleanPath, dataBuf);
669
+ newRev = blobRevision(dataBuf);
670
+ const touchedDir = posix.dirname(cleanPath) === "." ? "" : posix.dirname(cleanPath);
671
+ await this.regenerateIndexes(tx, args.project, [touchedDir]);
672
+ await this.prependLog(tx, args.project, logFileUpdateEntry(cleanPath, args.actor));
673
+ return {
674
+ value: { revision: newRev },
675
+ commit: {
676
+ subject: `okf(${args.project}): write file ${cleanPath}`,
677
+ author: args.actor,
678
+ principal: { subject: p.subject, clientId: p.clientId },
679
+ },
680
+ };
681
+ });
682
+ return {
683
+ project: args.project,
684
+ path: cleanPath,
685
+ revision: newRev,
686
+ commit: txRes.commit,
687
+ pushed: txRes.pushed,
688
+ warnings: txRes.warnings,
689
+ };
690
+ }
691
+ async deleteFile(p, args) {
692
+ checkActor(args.actor, p);
693
+ await this.assertProjectExists(args.project, true);
694
+ const cleanPath = normalizeFilePathForWrite(args.path);
695
+ const txRes = await this.storage.transaction({ projects: [args.project] }, async (tx) => {
696
+ const existingBuf = await this.storage.readFile(args.project, cleanPath);
697
+ if (existingBuf === null) {
698
+ throw new OkfError("not_found", 404, `file "${cleanPath}" not found in project "${args.project}"`);
699
+ }
700
+ const currentRev = blobRevision(existingBuf);
701
+ if (args.expectedRevision === null) {
702
+ throw new OkfError("revision_conflict", 409, "file exists but null expected", {
703
+ currentRevision: currentRev,
704
+ });
705
+ }
706
+ if (typeof args.expectedRevision === "string" && args.expectedRevision !== currentRev) {
707
+ throw new OkfError("revision_conflict", 409, "revision conflict", {
708
+ currentRevision: currentRev,
709
+ });
710
+ }
711
+ await tx.deleteFile(args.project, cleanPath);
712
+ const touchedDir = posix.dirname(cleanPath) === "." ? "" : posix.dirname(cleanPath);
713
+ await this.regenerateIndexes(tx, args.project, [touchedDir]);
714
+ await this.prependLog(tx, args.project, logFileDeletionEntry(cleanPath, args.actor));
715
+ return {
716
+ value: null,
717
+ commit: {
718
+ subject: `okf(${args.project}): delete file ${cleanPath}`,
719
+ author: args.actor,
720
+ principal: { subject: p.subject, clientId: p.clientId },
721
+ },
722
+ };
723
+ });
724
+ return {
725
+ project: args.project,
726
+ path: cleanPath,
727
+ commit: txRes.commit,
728
+ pushed: txRes.pushed,
729
+ warnings: txRes.warnings,
730
+ };
731
+ }
732
+ async deleteProject(p, args) {
733
+ checkActor(args.actor, p);
734
+ await this.assertProjectExists(args.project, true);
735
+ const txRes = await this.storage.transaction({ projects: [args.project] }, async (tx) => {
736
+ await tx.deleteProject(args.project);
737
+ this.catalog.removeProject(args.project);
738
+ return {
739
+ value: null,
740
+ commit: {
741
+ subject: `okf(${args.project}): delete project`,
742
+ author: args.actor,
743
+ principal: { subject: p.subject, clientId: p.clientId },
744
+ },
745
+ };
746
+ });
747
+ return {
748
+ project: args.project,
749
+ commit: txRes.commit,
750
+ pushed: txRes.pushed,
751
+ warnings: txRes.warnings,
752
+ };
753
+ }
754
+ exportArchive(project) {
755
+ if (!PROJECT_RE.test(project) || !this.catalog.projects().includes(project)) {
756
+ throw new OkfError("project_not_found", 404, `project "${project}" not found`);
757
+ }
758
+ return this.storage.archive(project);
759
+ }
760
+ async importArchive(p, args) {
761
+ checkActor(args.actor, p);
762
+ if (!PROJECT_RE.test(args.project)) {
763
+ throw new OkfError("invalid_id", 400, `invalid project name "${args.project}"`);
764
+ }
765
+ if (args.archive.length > this.config.maxArchiveBytes) {
766
+ throw new OkfError("payload_too_large", 413, `archive size (${args.archive.length}) exceeds MAX_ARCHIVE_BYTES (${this.config.maxArchiveBytes})`);
767
+ }
768
+ const randomSuffix = randomBytes(6).toString("hex");
769
+ const stagingDir = join(this.config.dataDir, "tmp", `import-${randomSuffix}`);
770
+ await mkdir(stagingDir, { recursive: true });
771
+ try {
772
+ await extractBundleArchive(args.archive, stagingDir, {
773
+ maxFileBytes: this.config.maxFileBytes,
774
+ maxArchiveBytes: this.config.maxArchiveBytes,
775
+ });
776
+ const staged = await fsBundleSource(stagingDir);
777
+ const lintRes = await lintBundle(staged, new Date());
778
+ const fatalErrors = lintRes.issues.filter((i) => i.severity === "error" && i.code !== "index_frontmatter" && i.code !== "index_no_sections");
779
+ if (fatalErrors.length > 0) {
780
+ throw new OkfError("bundle_not_conformant", 422, "bundle is not OKF-conformant", {
781
+ issues: lintRes.issues,
782
+ });
783
+ }
784
+ let conceptCount = 0;
785
+ const txRes = await this.storage.transaction({ projects: [args.project] }, async (tx) => {
786
+ await tx.replaceProject(args.project, staged);
787
+ await this.catalog.rebuildProject(args.project, staged);
788
+ await this.regenerateIndexes(tx, args.project, "all");
789
+ await this.prependLog(tx, args.project, logImportEntry(args.actor));
790
+ conceptCount = this.catalog.records(args.project).length;
791
+ return {
792
+ value: null,
793
+ commit: {
794
+ subject: `okf(${args.project}): import archive`,
795
+ author: args.actor,
796
+ principal: { subject: p.subject, clientId: p.clientId },
797
+ },
798
+ };
799
+ });
800
+ return {
801
+ project: args.project,
802
+ conceptCount,
803
+ commit: txRes.commit,
804
+ pushed: txRes.pushed,
805
+ warnings: txRes.warnings,
806
+ };
807
+ }
808
+ finally {
809
+ await rm(stagingDir, { recursive: true, force: true });
810
+ }
811
+ }
812
+ async syncNow() {
813
+ return this.storage.sync();
814
+ }
815
+ async syncStatus() {
816
+ return this.storage.syncStatus();
817
+ }
818
+ }
819
+ //# sourceMappingURL=knowledge-service.js.map