backpack-ontology 0.2.24 → 0.3.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/README.md +153 -3
- package/dist/bin/backpack-benchmark.d.ts +3 -0
- package/dist/bin/backpack-benchmark.d.ts.map +1 -0
- package/dist/bin/backpack-benchmark.js +213 -0
- package/dist/bin/backpack-benchmark.js.map +1 -0
- package/dist/bin/backpack.js +3 -3
- package/dist/bin/backpack.js.map +1 -1
- package/dist/bin/init.js +8 -11
- package/dist/bin/init.js.map +1 -1
- package/dist/core/backpack.d.ts +78 -2
- package/dist/core/backpack.d.ts.map +1 -1
- package/dist/core/backpack.js +226 -3
- package/dist/core/backpack.js.map +1 -1
- package/dist/core/draft.d.ts +42 -0
- package/dist/core/draft.d.ts.map +1 -0
- package/dist/core/draft.js +232 -0
- package/dist/core/draft.js.map +1 -0
- package/dist/core/events.d.ts +114 -0
- package/dist/core/events.d.ts.map +1 -0
- package/dist/core/events.js +375 -0
- package/dist/core/events.js.map +1 -0
- package/dist/core/graph.d.ts +3 -1
- package/dist/core/graph.d.ts.map +1 -1
- package/dist/core/graph.js +50 -0
- package/dist/core/graph.js.map +1 -1
- package/dist/core/hooks.d.ts +11 -3
- package/dist/core/hooks.d.ts.map +1 -1
- package/dist/core/hooks.js +53 -47
- package/dist/core/hooks.js.map +1 -1
- package/dist/core/normalize.d.ts +28 -0
- package/dist/core/normalize.d.ts.map +1 -0
- package/dist/core/normalize.js +133 -0
- package/dist/core/normalize.js.map +1 -0
- package/dist/core/remote-fetch.d.ts +50 -0
- package/dist/core/remote-fetch.d.ts.map +1 -0
- package/dist/core/remote-fetch.js +338 -0
- package/dist/core/remote-fetch.js.map +1 -0
- package/dist/core/remote-registry.d.ts +95 -0
- package/dist/core/remote-registry.d.ts.map +1 -0
- package/dist/core/remote-registry.js +296 -0
- package/dist/core/remote-registry.js.map +1 -0
- package/dist/core/remote-schema.d.ts +31 -0
- package/dist/core/remote-schema.d.ts.map +1 -0
- package/dist/core/remote-schema.js +252 -0
- package/dist/core/remote-schema.js.map +1 -0
- package/dist/core/role-audit.d.ts +20 -0
- package/dist/core/role-audit.d.ts.map +1 -0
- package/dist/core/role-audit.js +197 -0
- package/dist/core/role-audit.js.map +1 -0
- package/dist/core/telemetry.d.ts +2 -0
- package/dist/core/telemetry.d.ts.map +1 -1
- package/dist/core/telemetry.js +27 -0
- package/dist/core/telemetry.js.map +1 -1
- package/dist/core/token-estimate.d.ts +16 -0
- package/dist/core/token-estimate.d.ts.map +1 -0
- package/dist/core/token-estimate.js +29 -0
- package/dist/core/token-estimate.js.map +1 -0
- package/dist/core/types.d.ts +30 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +11 -0
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools/bulk-tools.d.ts.map +1 -1
- package/dist/mcp/tools/bulk-tools.js +80 -12
- package/dist/mcp/tools/bulk-tools.js.map +1 -1
- package/dist/mcp/tools/edge-tools.d.ts.map +1 -1
- package/dist/mcp/tools/edge-tools.js +14 -18
- package/dist/mcp/tools/edge-tools.js.map +1 -1
- package/dist/mcp/tools/error-helpers.d.ts +16 -0
- package/dist/mcp/tools/error-helpers.d.ts.map +1 -0
- package/dist/mcp/tools/error-helpers.js +34 -0
- package/dist/mcp/tools/error-helpers.js.map +1 -0
- package/dist/mcp/tools/intelligence-tools.d.ts +4 -0
- package/dist/mcp/tools/intelligence-tools.d.ts.map +1 -0
- package/dist/mcp/tools/intelligence-tools.js +184 -0
- package/dist/mcp/tools/intelligence-tools.js.map +1 -0
- package/dist/mcp/tools/node-tools.d.ts.map +1 -1
- package/dist/mcp/tools/node-tools.js +43 -40
- package/dist/mcp/tools/node-tools.js.map +1 -1
- package/dist/mcp/tools/ontology-tools.d.ts.map +1 -1
- package/dist/mcp/tools/ontology-tools.js +237 -12
- package/dist/mcp/tools/ontology-tools.js.map +1 -1
- package/dist/mcp/tools/remote-tools.d.ts +5 -0
- package/dist/mcp/tools/remote-tools.d.ts.map +1 -0
- package/dist/mcp/tools/remote-tools.js +295 -0
- package/dist/mcp/tools/remote-tools.js.map +1 -0
- package/dist/mcp/tools/version-tools.d.ts.map +1 -1
- package/dist/mcp/tools/version-tools.js +86 -0
- package/dist/mcp/tools/version-tools.js.map +1 -1
- package/dist/storage/backpack-app-backend.d.ts +1 -1
- package/dist/storage/backpack-app-backend.d.ts.map +1 -1
- package/dist/storage/backpack-app-backend.js +3 -1
- package/dist/storage/backpack-app-backend.js.map +1 -1
- package/dist/storage/event-sourced-backend.d.ts +174 -0
- package/dist/storage/event-sourced-backend.d.ts.map +1 -0
- package/dist/storage/event-sourced-backend.js +840 -0
- package/dist/storage/event-sourced-backend.js.map +1 -0
- package/dist/storage/json-file-backend.d.ts +1 -54
- package/dist/storage/json-file-backend.d.ts.map +1 -1
- package/dist/storage/json-file-backend.js +11 -470
- package/dist/storage/json-file-backend.js.map +1 -1
- package/package.json +3 -3
- package/hooks/auto-capture-prompt.md +0 -40
- package/hooks/hooks.json +0 -16
- package/hooks/suggest-viewer.sh +0 -4
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Event-sourced storage backend.
|
|
3
|
+
//
|
|
4
|
+
// Each graph branch is an append-only events.jsonl file plus a
|
|
5
|
+
// snapshot.json materialized cache. Events are the source of truth;
|
|
6
|
+
// the snapshot is a derived view that exists for fast reads.
|
|
7
|
+
//
|
|
8
|
+
// On-disk layout:
|
|
9
|
+
// graphs/<name>/
|
|
10
|
+
// metadata.json graph-level metadata + defaultBranch
|
|
11
|
+
// branches/<branch>/
|
|
12
|
+
// events.jsonl append-only event log (truth)
|
|
13
|
+
// snapshot.json materialized cache (rebuildable)
|
|
14
|
+
// snippets/<id>.json saved queries (orthogonal to events)
|
|
15
|
+
// terms.json term registry (carry-forward)
|
|
16
|
+
//
|
|
17
|
+
// Snapshots (the user-visible "save state and roll back here later"
|
|
18
|
+
// feature) are events with op="snapshot.label". Listing snapshots is
|
|
19
|
+
// scanning events for label entries. Rolling back is truncating the
|
|
20
|
+
// event log at the snapshot's position.
|
|
21
|
+
//
|
|
22
|
+
// Branches are forks: creating a branch copies the parent branch's
|
|
23
|
+
// events.jsonl and snapshot.json into a new branch directory.
|
|
24
|
+
//
|
|
25
|
+
// The current event count of a branch is its version number, used
|
|
26
|
+
// for optimistic concurrency.
|
|
27
|
+
// ============================================================
|
|
28
|
+
import * as fs from "node:fs/promises";
|
|
29
|
+
import * as path from "node:path";
|
|
30
|
+
import { dataDir } from "../core/paths.js";
|
|
31
|
+
import { applyEvents, diffToEvents, makeSnapshotLabelEvent, parseEvent, replay, serializeEvent, } from "../core/events.js";
|
|
32
|
+
const SCHEMA_VERSION = 1;
|
|
33
|
+
const DEFAULT_BRANCH = "main";
|
|
34
|
+
/** How long a heartbeat is considered "fresh" before it's treated as stale. */
|
|
35
|
+
export const LOCK_FRESH_MS = 5 * 60 * 1000;
|
|
36
|
+
/**
|
|
37
|
+
* Error thrown when an optimistic-concurrency write detects that the
|
|
38
|
+
* underlying graph has been modified since the caller loaded it.
|
|
39
|
+
*/
|
|
40
|
+
export class ConcurrencyError extends Error {
|
|
41
|
+
graph;
|
|
42
|
+
expectedVersion;
|
|
43
|
+
actualVersion;
|
|
44
|
+
constructor(graph, expectedVersion, actualVersion) {
|
|
45
|
+
super(`version conflict on "${graph}": expected ${expectedVersion}, found ${actualVersion}`);
|
|
46
|
+
this.graph = graph;
|
|
47
|
+
this.expectedVersion = expectedVersion;
|
|
48
|
+
this.actualVersion = actualVersion;
|
|
49
|
+
this.name = "ConcurrencyError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function emptyGraphData(name, description) {
|
|
53
|
+
const now = new Date().toISOString();
|
|
54
|
+
return {
|
|
55
|
+
metadata: {
|
|
56
|
+
name,
|
|
57
|
+
description,
|
|
58
|
+
createdAt: now,
|
|
59
|
+
updatedAt: now,
|
|
60
|
+
},
|
|
61
|
+
nodes: [],
|
|
62
|
+
edges: [],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export class EventSourcedBackend {
|
|
66
|
+
baseDir;
|
|
67
|
+
author;
|
|
68
|
+
constructor(baseDir, options) {
|
|
69
|
+
this.baseDir = baseDir ?? dataDir();
|
|
70
|
+
this.author =
|
|
71
|
+
options?.author ?? process.env.BACKPACK_AUTHOR ?? undefined;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Set the author identifier used for newly-emitted events. Pass
|
|
75
|
+
* undefined to clear it. Useful in tests and in long-running
|
|
76
|
+
* processes where the active user changes.
|
|
77
|
+
*/
|
|
78
|
+
setAuthor(author) {
|
|
79
|
+
this.author = author;
|
|
80
|
+
}
|
|
81
|
+
getAuthor() {
|
|
82
|
+
return this.author;
|
|
83
|
+
}
|
|
84
|
+
// --- Path helpers ---
|
|
85
|
+
graphsDir() {
|
|
86
|
+
return path.join(this.baseDir, "graphs");
|
|
87
|
+
}
|
|
88
|
+
graphDir(name) {
|
|
89
|
+
return path.join(this.graphsDir(), name);
|
|
90
|
+
}
|
|
91
|
+
metadataFile(name) {
|
|
92
|
+
return path.join(this.graphDir(name), "metadata.json");
|
|
93
|
+
}
|
|
94
|
+
branchesDir(name) {
|
|
95
|
+
return path.join(this.graphDir(name), "branches");
|
|
96
|
+
}
|
|
97
|
+
branchDir(name, branch) {
|
|
98
|
+
return path.join(this.branchesDir(name), branch);
|
|
99
|
+
}
|
|
100
|
+
eventsFile(name, branch) {
|
|
101
|
+
return path.join(this.branchDir(name, branch), "events.jsonl");
|
|
102
|
+
}
|
|
103
|
+
snapshotFile(name, branch) {
|
|
104
|
+
return path.join(this.branchDir(name, branch), "snapshot.json");
|
|
105
|
+
}
|
|
106
|
+
snippetsDir(name) {
|
|
107
|
+
return path.join(this.graphDir(name), "snippets");
|
|
108
|
+
}
|
|
109
|
+
snippetFile(name, snippetId) {
|
|
110
|
+
return path.join(this.snippetsDir(name), `${snippetId}.json`);
|
|
111
|
+
}
|
|
112
|
+
termsFile(name) {
|
|
113
|
+
return path.join(this.graphDir(name), "terms.json");
|
|
114
|
+
}
|
|
115
|
+
lockFile(name) {
|
|
116
|
+
return path.join(this.graphDir(name), ".lock");
|
|
117
|
+
}
|
|
118
|
+
// --- IO helpers ---
|
|
119
|
+
async writeAtomic(filePath, content) {
|
|
120
|
+
const tmp = `${filePath}.tmp`;
|
|
121
|
+
await fs.writeFile(tmp, content, "utf8");
|
|
122
|
+
await fs.rename(tmp, filePath);
|
|
123
|
+
}
|
|
124
|
+
// --- Metadata ---
|
|
125
|
+
async loadMetadata(name) {
|
|
126
|
+
let raw;
|
|
127
|
+
try {
|
|
128
|
+
raw = await fs.readFile(this.metadataFile(name), "utf8");
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
if (err.code === "ENOENT") {
|
|
132
|
+
throw new Error(`Learning graph "${name}" not found`);
|
|
133
|
+
}
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
const parsed = JSON.parse(raw);
|
|
137
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
138
|
+
throw new Error(`metadata.json for "${name}" is malformed`);
|
|
139
|
+
}
|
|
140
|
+
return parsed;
|
|
141
|
+
}
|
|
142
|
+
async writeMetadata(name, meta) {
|
|
143
|
+
await this.writeAtomic(this.metadataFile(name), JSON.stringify(meta, null, 2));
|
|
144
|
+
}
|
|
145
|
+
// --- Snapshot helpers ---
|
|
146
|
+
/**
|
|
147
|
+
* Load the materialized state for a branch. Reads snapshot.json
|
|
148
|
+
* directly. If the cache is missing, rebuilds it from the event log.
|
|
149
|
+
*/
|
|
150
|
+
async loadBranchSnapshot(name, branch) {
|
|
151
|
+
try {
|
|
152
|
+
const raw = await fs.readFile(this.snapshotFile(name, branch), "utf8");
|
|
153
|
+
return JSON.parse(raw);
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
if (err.code !== "ENOENT")
|
|
157
|
+
throw err;
|
|
158
|
+
return this.rebuildSnapshot(name, branch);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async writeSnapshotFile(name, branch, data) {
|
|
162
|
+
await this.writeAtomic(this.snapshotFile(name, branch), JSON.stringify(data, null, 2));
|
|
163
|
+
}
|
|
164
|
+
async rebuildSnapshot(name, branch) {
|
|
165
|
+
const events = await this.loadEvents(name, branch);
|
|
166
|
+
const meta = await this.loadMetadata(name);
|
|
167
|
+
const initial = {
|
|
168
|
+
name: meta.name,
|
|
169
|
+
description: meta.description,
|
|
170
|
+
createdAt: meta.createdAt,
|
|
171
|
+
updatedAt: meta.updatedAt,
|
|
172
|
+
};
|
|
173
|
+
const state = replay(events, initial);
|
|
174
|
+
await this.writeSnapshotFile(name, branch, state);
|
|
175
|
+
return state;
|
|
176
|
+
}
|
|
177
|
+
// --- Event log helpers ---
|
|
178
|
+
async loadEvents(name, branch) {
|
|
179
|
+
let raw;
|
|
180
|
+
try {
|
|
181
|
+
raw = await fs.readFile(this.eventsFile(name, branch), "utf8");
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
if (err.code === "ENOENT")
|
|
185
|
+
return [];
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
const events = [];
|
|
189
|
+
const lines = raw.split("\n");
|
|
190
|
+
for (let i = 0; i < lines.length; i++) {
|
|
191
|
+
const line = lines[i];
|
|
192
|
+
if (line.trim().length === 0)
|
|
193
|
+
continue;
|
|
194
|
+
try {
|
|
195
|
+
events.push(parseEvent(line));
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
throw new Error(`parse error in ${this.eventsFile(name, branch)}:${i + 1}: ${err.message}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return events;
|
|
202
|
+
}
|
|
203
|
+
async eventCount(name, branch) {
|
|
204
|
+
const events = await this.loadEvents(name, branch);
|
|
205
|
+
return events.length;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Append events to a branch's event log and update its snapshot
|
|
209
|
+
* cache. If `expectedVersion` is provided, the append fails when the
|
|
210
|
+
* current event count differs (optimistic concurrency).
|
|
211
|
+
*
|
|
212
|
+
* Returns the new event count after append.
|
|
213
|
+
*/
|
|
214
|
+
async appendEvents(name, branch, events, expectedVersion) {
|
|
215
|
+
if (events.length === 0) {
|
|
216
|
+
return this.eventCount(name, branch);
|
|
217
|
+
}
|
|
218
|
+
if (expectedVersion !== undefined) {
|
|
219
|
+
const current = await this.eventCount(name, branch);
|
|
220
|
+
if (current !== expectedVersion) {
|
|
221
|
+
throw new ConcurrencyError(name, expectedVersion, current);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const currentState = await this.loadBranchSnapshot(name, branch);
|
|
225
|
+
const newState = applyEvents(currentState, events);
|
|
226
|
+
const lines = events.map(serializeEvent).join("\n") + "\n";
|
|
227
|
+
await fs.appendFile(this.eventsFile(name, branch), lines, "utf8");
|
|
228
|
+
await this.writeSnapshotFile(name, branch, newState);
|
|
229
|
+
return await this.eventCount(name, branch);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Replace the entire event log for a branch (used for rollback).
|
|
233
|
+
* Atomically writes a new events.jsonl file and rebuilds the snapshot.
|
|
234
|
+
*/
|
|
235
|
+
async replaceEvents(name, branch, events) {
|
|
236
|
+
const lines = events.map(serializeEvent).join("\n") + (events.length > 0 ? "\n" : "");
|
|
237
|
+
await this.writeAtomic(this.eventsFile(name, branch), lines);
|
|
238
|
+
const meta = await this.loadMetadata(name);
|
|
239
|
+
const initial = {
|
|
240
|
+
name: meta.name,
|
|
241
|
+
description: meta.description,
|
|
242
|
+
createdAt: meta.createdAt,
|
|
243
|
+
updatedAt: meta.updatedAt,
|
|
244
|
+
};
|
|
245
|
+
const state = replay(events, initial);
|
|
246
|
+
await this.writeSnapshotFile(name, branch, state);
|
|
247
|
+
}
|
|
248
|
+
// --- StorageBackend interface ---
|
|
249
|
+
async initialize() {
|
|
250
|
+
await fs.mkdir(this.graphsDir(), { recursive: true });
|
|
251
|
+
// Auto-migrate any graphs from the legacy 0.2.x format on first start.
|
|
252
|
+
// Idempotent — re-running it skips already-converted branches. Failures
|
|
253
|
+
// are logged to stderr but do not block startup.
|
|
254
|
+
try {
|
|
255
|
+
await this.autoMigrateLegacyGraphs();
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
console.error(`[backpack] auto-migration encountered an error: ${err.message}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Acquire an exclusive cross-process migration lock for a single graph
|
|
263
|
+
* by creating `.migration-lock` with O_EXCL. Two MCP instances starting
|
|
264
|
+
* simultaneously can't both convert the same legacy directory.
|
|
265
|
+
* Returns true on success, false if another process holds the lock.
|
|
266
|
+
*/
|
|
267
|
+
async tryAcquireMigrationLock(name) {
|
|
268
|
+
const lockPath = path.join(this.graphDir(name), ".migration-lock");
|
|
269
|
+
try {
|
|
270
|
+
const fd = await fs.open(lockPath, "wx");
|
|
271
|
+
await fd.close();
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
if (err.code === "EEXIST")
|
|
276
|
+
return false;
|
|
277
|
+
throw err;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
async releaseMigrationLock(name) {
|
|
281
|
+
const lockPath = path.join(this.graphDir(name), ".migration-lock");
|
|
282
|
+
await fs.rm(lockPath).catch(() => { });
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Detect and convert any 0.2.x format graphs to the new event-sourced
|
|
286
|
+
* layout. Runs once on initialize(); idempotent across restarts.
|
|
287
|
+
*
|
|
288
|
+
* Old layout:
|
|
289
|
+
* graphs/<name>/meta.json + branches/<branch>.json
|
|
290
|
+
* New layout:
|
|
291
|
+
* graphs/<name>/metadata.json + branches/<branch>/{events.jsonl, snapshot.json}
|
|
292
|
+
*/
|
|
293
|
+
async autoMigrateLegacyGraphs() {
|
|
294
|
+
let entries;
|
|
295
|
+
try {
|
|
296
|
+
entries = await fs.readdir(this.graphsDir());
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
for (const name of entries) {
|
|
302
|
+
// Skip anything that isn't a plausible graph directory name.
|
|
303
|
+
// Defends against hidden files (.DS_Store), traversal (.., .),
|
|
304
|
+
// and accidental symlinks.
|
|
305
|
+
if (name.startsWith("."))
|
|
306
|
+
continue;
|
|
307
|
+
if (name.includes("/") || name.includes("\\"))
|
|
308
|
+
continue;
|
|
309
|
+
try {
|
|
310
|
+
const graphDir = path.join(this.graphsDir(), name);
|
|
311
|
+
const stat = await fs.lstat(graphDir);
|
|
312
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
// Acquire a per-graph lock to prevent two MCP instances from
|
|
319
|
+
// racing on the same legacy directory.
|
|
320
|
+
let locked = false;
|
|
321
|
+
try {
|
|
322
|
+
locked = await this.tryAcquireMigrationLock(name);
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
console.error(`[backpack] failed to acquire migration lock for "${name}": ${err.message}`);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (!locked)
|
|
329
|
+
continue;
|
|
330
|
+
try {
|
|
331
|
+
await this.migrateOneGraph(name);
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
console.error(`[backpack] migration failed for "${name}": ${err.message}`);
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
await this.releaseMigrationLock(name);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async migrateOneGraph(name) {
|
|
342
|
+
const graphDir = this.graphDir(name);
|
|
343
|
+
const newMetaPath = this.metadataFile(name);
|
|
344
|
+
const oldMetaPath = path.join(graphDir, "meta.json");
|
|
345
|
+
const branchesDir = this.branchesDir(name);
|
|
346
|
+
let branchEntries;
|
|
347
|
+
try {
|
|
348
|
+
branchEntries = await fs.readdir(branchesDir);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const legacyBranchFiles = branchEntries.filter((e) => e.endsWith(".json"));
|
|
354
|
+
// Nothing to do if there are no legacy files left to convert
|
|
355
|
+
if (legacyBranchFiles.length === 0)
|
|
356
|
+
return;
|
|
357
|
+
const hasNewMeta = await this.fileExists(newMetaPath);
|
|
358
|
+
// Determine activeBranch from old meta.json (default "main")
|
|
359
|
+
let activeBranch = DEFAULT_BRANCH;
|
|
360
|
+
if (await this.fileExists(oldMetaPath)) {
|
|
361
|
+
try {
|
|
362
|
+
const oldMeta = JSON.parse(await fs.readFile(oldMetaPath, "utf8"));
|
|
363
|
+
if (oldMeta && typeof oldMeta.activeBranch === "string") {
|
|
364
|
+
activeBranch = oldMeta.activeBranch;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch { }
|
|
368
|
+
}
|
|
369
|
+
// Synthesize metadata.json from a seed branch if missing
|
|
370
|
+
if (!hasNewMeta) {
|
|
371
|
+
const seedFile = legacyBranchFiles.find((f) => f === `${activeBranch}.json`) ||
|
|
372
|
+
legacyBranchFiles[0];
|
|
373
|
+
if (!seedFile)
|
|
374
|
+
return;
|
|
375
|
+
let seedData;
|
|
376
|
+
try {
|
|
377
|
+
seedData = JSON.parse(await fs.readFile(path.join(branchesDir, seedFile), "utf8"));
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const now = new Date().toISOString();
|
|
383
|
+
const meta = {
|
|
384
|
+
name: seedData?.metadata?.name || name,
|
|
385
|
+
description: seedData?.metadata?.description || "",
|
|
386
|
+
defaultBranch: activeBranch,
|
|
387
|
+
schemaVersion: SCHEMA_VERSION,
|
|
388
|
+
createdAt: seedData?.metadata?.createdAt || now,
|
|
389
|
+
updatedAt: now,
|
|
390
|
+
};
|
|
391
|
+
await this.writeMetadata(name, meta);
|
|
392
|
+
}
|
|
393
|
+
// Convert each legacy branch file
|
|
394
|
+
for (const branchFile of legacyBranchFiles) {
|
|
395
|
+
try {
|
|
396
|
+
await this.migrateOneBranchFile(name, branchFile);
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
// Leave the legacy file in place if conversion failed
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// Remove old meta.json after successful conversion
|
|
403
|
+
if (await this.fileExists(oldMetaPath)) {
|
|
404
|
+
await fs.rm(oldMetaPath).catch(() => { });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
async migrateOneBranchFile(name, branchFile) {
|
|
408
|
+
const branchName = branchFile.replace(/\.json$/, "");
|
|
409
|
+
const oldPath = path.join(this.branchesDir(name), branchFile);
|
|
410
|
+
const newDir = this.branchDir(name, branchName);
|
|
411
|
+
const eventsPath = this.eventsFile(name, branchName);
|
|
412
|
+
const snapshotPath = this.snapshotFile(name, branchName);
|
|
413
|
+
// If new layout already exists, drop the legacy file and stop
|
|
414
|
+
if (await this.fileExists(eventsPath)) {
|
|
415
|
+
await fs.rm(oldPath).catch(() => { });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
let data;
|
|
419
|
+
try {
|
|
420
|
+
data = JSON.parse(await fs.readFile(oldPath, "utf8"));
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (!data || !Array.isArray(data.nodes) || !Array.isArray(data.edges)) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
await fs.mkdir(newDir, { recursive: true });
|
|
429
|
+
const author = this.author ?? `migration@${process.env.HOSTNAME ?? "local"}`;
|
|
430
|
+
const ts = new Date().toISOString();
|
|
431
|
+
const events = [];
|
|
432
|
+
for (const node of data.nodes) {
|
|
433
|
+
events.push({
|
|
434
|
+
v: 1,
|
|
435
|
+
ts,
|
|
436
|
+
author,
|
|
437
|
+
op: "node.add",
|
|
438
|
+
node,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
for (const edge of data.edges) {
|
|
442
|
+
events.push({
|
|
443
|
+
v: 1,
|
|
444
|
+
ts,
|
|
445
|
+
author,
|
|
446
|
+
op: "edge.add",
|
|
447
|
+
edge,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
const lines = events.map((e) => JSON.stringify(e)).join("\n") + (events.length ? "\n" : "");
|
|
451
|
+
await this.writeAtomic(eventsPath, lines);
|
|
452
|
+
await this.writeAtomic(snapshotPath, JSON.stringify(data, null, 2));
|
|
453
|
+
// Remove the legacy monolithic file
|
|
454
|
+
await fs.rm(oldPath).catch(() => { });
|
|
455
|
+
}
|
|
456
|
+
async fileExists(p) {
|
|
457
|
+
try {
|
|
458
|
+
await fs.access(p);
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
async listOntologies() {
|
|
466
|
+
let entries;
|
|
467
|
+
try {
|
|
468
|
+
entries = await fs.readdir(this.graphsDir());
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
return [];
|
|
472
|
+
}
|
|
473
|
+
const summaries = [];
|
|
474
|
+
for (const entry of entries) {
|
|
475
|
+
try {
|
|
476
|
+
const meta = await this.loadMetadata(entry);
|
|
477
|
+
const state = await this.loadBranchSnapshot(entry, meta.defaultBranch);
|
|
478
|
+
const typeCounts = new Map();
|
|
479
|
+
for (const node of state.nodes) {
|
|
480
|
+
typeCounts.set(node.type, (typeCounts.get(node.type) ?? 0) + 1);
|
|
481
|
+
}
|
|
482
|
+
// Use the directory name as the canonical identifier — that's
|
|
483
|
+
// what loadOntology takes. metadata.name can drift from the dir
|
|
484
|
+
// name through historical renames.
|
|
485
|
+
summaries.push({
|
|
486
|
+
name: entry,
|
|
487
|
+
description: meta.description,
|
|
488
|
+
nodeCount: state.nodes.length,
|
|
489
|
+
edgeCount: state.edges.length,
|
|
490
|
+
nodeTypes: Array.from(typeCounts.entries())
|
|
491
|
+
.map(([type, count]) => ({ type, count }))
|
|
492
|
+
.sort((a, b) => b.count - a.count),
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
// Skip malformed graph directories
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return summaries;
|
|
500
|
+
}
|
|
501
|
+
async loadOntology(name) {
|
|
502
|
+
const meta = await this.loadMetadata(name);
|
|
503
|
+
return this.loadBranchSnapshot(name, meta.defaultBranch);
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Load the current event count for the default branch — used by
|
|
507
|
+
* Backpack as the optimistic-concurrency version token.
|
|
508
|
+
*/
|
|
509
|
+
async getCurrentVersion(name) {
|
|
510
|
+
const meta = await this.loadMetadata(name);
|
|
511
|
+
return this.eventCount(name, meta.defaultBranch);
|
|
512
|
+
}
|
|
513
|
+
async saveOntology(name, data, expectedVersion) {
|
|
514
|
+
const meta = await this.loadMetadata(name);
|
|
515
|
+
const before = await this.loadBranchSnapshot(name, meta.defaultBranch);
|
|
516
|
+
const events = diffToEvents(before, data, this.author);
|
|
517
|
+
if (events.length === 0)
|
|
518
|
+
return;
|
|
519
|
+
await this.appendEvents(name, meta.defaultBranch, events, expectedVersion);
|
|
520
|
+
// Heartbeat: every successful write touches the lock file
|
|
521
|
+
await this.touchLock(name).catch(() => { });
|
|
522
|
+
if (data.metadata.name !== meta.name ||
|
|
523
|
+
data.metadata.description !== meta.description) {
|
|
524
|
+
meta.name = data.metadata.name;
|
|
525
|
+
meta.description = data.metadata.description;
|
|
526
|
+
meta.updatedAt = new Date().toISOString();
|
|
527
|
+
await this.writeMetadata(name, meta);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
// --- Lockfile heartbeat ---
|
|
531
|
+
/**
|
|
532
|
+
* Touch the lock file with the current author + timestamp. Called
|
|
533
|
+
* automatically on every successful write. Safe to call concurrently;
|
|
534
|
+
* the file is overwritten atomically.
|
|
535
|
+
*/
|
|
536
|
+
async touchLock(name) {
|
|
537
|
+
const lock = {
|
|
538
|
+
author: this.author ?? "unknown",
|
|
539
|
+
lastActivity: new Date().toISOString(),
|
|
540
|
+
hostname: typeof process !== "undefined" ? process.env.HOSTNAME : undefined,
|
|
541
|
+
pid: typeof process !== "undefined" ? process.pid : undefined,
|
|
542
|
+
};
|
|
543
|
+
await this.writeAtomic(this.lockFile(name), JSON.stringify(lock));
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Read the lock file. Returns null if no lock exists, or if the lock
|
|
547
|
+
* is stale (older than LOCK_FRESH_MS).
|
|
548
|
+
*/
|
|
549
|
+
async readLock(name) {
|
|
550
|
+
let raw;
|
|
551
|
+
try {
|
|
552
|
+
raw = await fs.readFile(this.lockFile(name), "utf8");
|
|
553
|
+
}
|
|
554
|
+
catch (err) {
|
|
555
|
+
if (err.code === "ENOENT")
|
|
556
|
+
return null;
|
|
557
|
+
throw err;
|
|
558
|
+
}
|
|
559
|
+
let lock;
|
|
560
|
+
try {
|
|
561
|
+
lock = JSON.parse(raw);
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
const age = Date.now() - new Date(lock.lastActivity).getTime();
|
|
567
|
+
if (Number.isNaN(age) || age > LOCK_FRESH_MS)
|
|
568
|
+
return null;
|
|
569
|
+
return lock;
|
|
570
|
+
}
|
|
571
|
+
async createOntology(name, description) {
|
|
572
|
+
if (await this.ontologyExists(name)) {
|
|
573
|
+
throw new Error(`Learning graph "${name}" already exists`);
|
|
574
|
+
}
|
|
575
|
+
await fs.mkdir(this.branchDir(name, DEFAULT_BRANCH), { recursive: true });
|
|
576
|
+
const now = new Date().toISOString();
|
|
577
|
+
const meta = {
|
|
578
|
+
name,
|
|
579
|
+
description,
|
|
580
|
+
defaultBranch: DEFAULT_BRANCH,
|
|
581
|
+
schemaVersion: SCHEMA_VERSION,
|
|
582
|
+
createdAt: now,
|
|
583
|
+
updatedAt: now,
|
|
584
|
+
};
|
|
585
|
+
await this.writeMetadata(name, meta);
|
|
586
|
+
const initial = emptyGraphData(name, description);
|
|
587
|
+
await this.writeAtomic(this.eventsFile(name, DEFAULT_BRANCH), "");
|
|
588
|
+
await this.writeSnapshotFile(name, DEFAULT_BRANCH, initial);
|
|
589
|
+
return initial;
|
|
590
|
+
}
|
|
591
|
+
async deleteOntology(name) {
|
|
592
|
+
if (!(await this.ontologyExists(name))) {
|
|
593
|
+
throw new Error(`Learning graph "${name}" not found`);
|
|
594
|
+
}
|
|
595
|
+
await fs.rm(this.graphDir(name), { recursive: true, force: true });
|
|
596
|
+
}
|
|
597
|
+
async renameOntology(oldName, newName) {
|
|
598
|
+
if (await this.ontologyExists(newName)) {
|
|
599
|
+
throw new Error(`Learning graph "${newName}" already exists`);
|
|
600
|
+
}
|
|
601
|
+
await fs.rename(this.graphDir(oldName), this.graphDir(newName));
|
|
602
|
+
const meta = await this.loadMetadata(newName);
|
|
603
|
+
meta.name = newName;
|
|
604
|
+
meta.updatedAt = new Date().toISOString();
|
|
605
|
+
await this.writeMetadata(newName, meta);
|
|
606
|
+
}
|
|
607
|
+
async ontologyExists(name) {
|
|
608
|
+
try {
|
|
609
|
+
await fs.access(this.metadataFile(name));
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
// --- Branches ---
|
|
617
|
+
async listBranches(name) {
|
|
618
|
+
const meta = await this.loadMetadata(name);
|
|
619
|
+
let entries;
|
|
620
|
+
try {
|
|
621
|
+
entries = await fs.readdir(this.branchesDir(name));
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
return [];
|
|
625
|
+
}
|
|
626
|
+
const branches = [];
|
|
627
|
+
for (const entry of entries) {
|
|
628
|
+
try {
|
|
629
|
+
const state = await this.loadBranchSnapshot(name, entry);
|
|
630
|
+
branches.push({
|
|
631
|
+
name: entry,
|
|
632
|
+
nodeCount: state.nodes.length,
|
|
633
|
+
edgeCount: state.edges.length,
|
|
634
|
+
active: entry === meta.defaultBranch,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
catch { }
|
|
638
|
+
}
|
|
639
|
+
return branches;
|
|
640
|
+
}
|
|
641
|
+
async createBranch(name, branchName, fromBranch) {
|
|
642
|
+
const meta = await this.loadMetadata(name);
|
|
643
|
+
const source = fromBranch ?? meta.defaultBranch;
|
|
644
|
+
const targetDir = this.branchDir(name, branchName);
|
|
645
|
+
try {
|
|
646
|
+
await fs.access(targetDir);
|
|
647
|
+
throw new Error(`Branch "${branchName}" already exists`);
|
|
648
|
+
}
|
|
649
|
+
catch (err) {
|
|
650
|
+
if (err.code !== "ENOENT")
|
|
651
|
+
throw err;
|
|
652
|
+
}
|
|
653
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
654
|
+
const sourceEvents = this.eventsFile(name, source);
|
|
655
|
+
const targetEvents = this.eventsFile(name, branchName);
|
|
656
|
+
try {
|
|
657
|
+
await fs.copyFile(sourceEvents, targetEvents);
|
|
658
|
+
}
|
|
659
|
+
catch (err) {
|
|
660
|
+
if (err.code !== "ENOENT")
|
|
661
|
+
throw err;
|
|
662
|
+
await this.writeAtomic(targetEvents, "");
|
|
663
|
+
}
|
|
664
|
+
const sourceSnap = this.snapshotFile(name, source);
|
|
665
|
+
const targetSnap = this.snapshotFile(name, branchName);
|
|
666
|
+
try {
|
|
667
|
+
await fs.copyFile(sourceSnap, targetSnap);
|
|
668
|
+
}
|
|
669
|
+
catch (err) {
|
|
670
|
+
if (err.code !== "ENOENT")
|
|
671
|
+
throw err;
|
|
672
|
+
await this.rebuildSnapshot(name, branchName);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
async switchBranch(name, branchName) {
|
|
676
|
+
const branchDir = this.branchDir(name, branchName);
|
|
677
|
+
try {
|
|
678
|
+
await fs.access(branchDir);
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
throw new Error(`Branch "${branchName}" does not exist`);
|
|
682
|
+
}
|
|
683
|
+
const meta = await this.loadMetadata(name);
|
|
684
|
+
meta.defaultBranch = branchName;
|
|
685
|
+
meta.updatedAt = new Date().toISOString();
|
|
686
|
+
await this.writeMetadata(name, meta);
|
|
687
|
+
}
|
|
688
|
+
async deleteBranch(name, branchName) {
|
|
689
|
+
const meta = await this.loadMetadata(name);
|
|
690
|
+
if (branchName === meta.defaultBranch) {
|
|
691
|
+
throw new Error(`Cannot delete the active branch "${branchName}"`);
|
|
692
|
+
}
|
|
693
|
+
if (branchName === DEFAULT_BRANCH) {
|
|
694
|
+
throw new Error(`Cannot delete the "${DEFAULT_BRANCH}" branch`);
|
|
695
|
+
}
|
|
696
|
+
await fs.rm(this.branchDir(name, branchName), { recursive: true, force: true });
|
|
697
|
+
}
|
|
698
|
+
async loadBranch(name, branchName) {
|
|
699
|
+
return this.loadBranchSnapshot(name, branchName);
|
|
700
|
+
}
|
|
701
|
+
// --- Snapshots (labeled events) ---
|
|
702
|
+
async createSnapshot(name, label) {
|
|
703
|
+
const meta = await this.loadMetadata(name);
|
|
704
|
+
const event = makeSnapshotLabelEvent(label, this.author);
|
|
705
|
+
await this.appendEvents(name, meta.defaultBranch, [event]);
|
|
706
|
+
return await this.eventCount(name, meta.defaultBranch);
|
|
707
|
+
}
|
|
708
|
+
async listSnapshots(name) {
|
|
709
|
+
const meta = await this.loadMetadata(name);
|
|
710
|
+
const events = await this.loadEvents(name, meta.defaultBranch);
|
|
711
|
+
const snapshots = [];
|
|
712
|
+
let runningState = emptyGraphData(meta.name, meta.description);
|
|
713
|
+
for (let i = 0; i < events.length; i++) {
|
|
714
|
+
runningState = applyEvents(runningState, [events[i]]);
|
|
715
|
+
if (events[i].op === "snapshot.label") {
|
|
716
|
+
const snapEvent = events[i];
|
|
717
|
+
snapshots.push({
|
|
718
|
+
version: i + 1,
|
|
719
|
+
timestamp: snapEvent.ts,
|
|
720
|
+
nodeCount: runningState.nodes.length,
|
|
721
|
+
edgeCount: runningState.edges.length,
|
|
722
|
+
label: snapEvent.label,
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
snapshots.reverse();
|
|
727
|
+
return snapshots;
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Load the materialized state at a specific snapshot version. The
|
|
731
|
+
* version is the position in the event log of a snapshot.label event,
|
|
732
|
+
* matching the value returned by createSnapshot and listSnapshots.
|
|
733
|
+
*/
|
|
734
|
+
async loadSnapshot(name, version) {
|
|
735
|
+
const meta = await this.loadMetadata(name);
|
|
736
|
+
const events = await this.loadEvents(name, meta.defaultBranch);
|
|
737
|
+
if (version < 1 || version > events.length) {
|
|
738
|
+
throw new Error(`Snapshot version ${version} not found`);
|
|
739
|
+
}
|
|
740
|
+
const initial = {
|
|
741
|
+
name: meta.name,
|
|
742
|
+
description: meta.description,
|
|
743
|
+
createdAt: meta.createdAt,
|
|
744
|
+
updatedAt: meta.updatedAt,
|
|
745
|
+
};
|
|
746
|
+
return replay(events.slice(0, version), initial);
|
|
747
|
+
}
|
|
748
|
+
async rollback(name, version) {
|
|
749
|
+
const meta = await this.loadMetadata(name);
|
|
750
|
+
const events = await this.loadEvents(name, meta.defaultBranch);
|
|
751
|
+
if (version < 1 || version > events.length) {
|
|
752
|
+
throw new Error(`Snapshot version ${version} not found`);
|
|
753
|
+
}
|
|
754
|
+
const truncated = events.slice(0, version);
|
|
755
|
+
await this.replaceEvents(name, meta.defaultBranch, truncated);
|
|
756
|
+
}
|
|
757
|
+
async getSnapshotLimit(_name) {
|
|
758
|
+
return Number.MAX_SAFE_INTEGER;
|
|
759
|
+
}
|
|
760
|
+
// --- Snippets (orthogonal to events) ---
|
|
761
|
+
async saveSnippet(graphName, snippet) {
|
|
762
|
+
const id = snippet.label
|
|
763
|
+
.toLowerCase()
|
|
764
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
765
|
+
.replace(/^-|-$/g, "")
|
|
766
|
+
.substring(0, 50) || "snippet";
|
|
767
|
+
const data = await this.loadOntology(graphName);
|
|
768
|
+
const meta = await this.loadMetadata(graphName);
|
|
769
|
+
const nodeSet = new Set(snippet.nodeIds);
|
|
770
|
+
let resolvedEdgeIds = snippet.edgeIds;
|
|
771
|
+
if (!resolvedEdgeIds || resolvedEdgeIds.length === 0) {
|
|
772
|
+
resolvedEdgeIds = data.edges
|
|
773
|
+
.filter((e) => nodeSet.has(e.sourceId) && nodeSet.has(e.targetId))
|
|
774
|
+
.map((e) => e.id);
|
|
775
|
+
}
|
|
776
|
+
const edgeSet = new Set(resolvedEdgeIds);
|
|
777
|
+
const snippetData = {
|
|
778
|
+
id,
|
|
779
|
+
label: snippet.label,
|
|
780
|
+
description: snippet.description ?? "",
|
|
781
|
+
parentGraph: graphName,
|
|
782
|
+
parentBranch: meta.defaultBranch,
|
|
783
|
+
nodeIds: snippet.nodeIds,
|
|
784
|
+
edgeIds: resolvedEdgeIds,
|
|
785
|
+
nodes: data.nodes.filter((n) => nodeSet.has(n.id)),
|
|
786
|
+
edges: data.edges.filter((e) => edgeSet.has(e.id)),
|
|
787
|
+
nodeCount: snippet.nodeIds.length,
|
|
788
|
+
edgeCount: resolvedEdgeIds.length,
|
|
789
|
+
createdAt: new Date().toISOString(),
|
|
790
|
+
};
|
|
791
|
+
await fs.mkdir(this.snippetsDir(graphName), { recursive: true });
|
|
792
|
+
await this.writeAtomic(this.snippetFile(graphName, id), JSON.stringify(snippetData, null, 2));
|
|
793
|
+
return id;
|
|
794
|
+
}
|
|
795
|
+
async listSnippets(graphName) {
|
|
796
|
+
let entries;
|
|
797
|
+
try {
|
|
798
|
+
entries = await fs.readdir(this.snippetsDir(graphName));
|
|
799
|
+
}
|
|
800
|
+
catch {
|
|
801
|
+
return [];
|
|
802
|
+
}
|
|
803
|
+
const snippets = [];
|
|
804
|
+
for (const entry of entries.sort()) {
|
|
805
|
+
if (!entry.endsWith(".json"))
|
|
806
|
+
continue;
|
|
807
|
+
try {
|
|
808
|
+
const raw = await fs.readFile(path.join(this.snippetsDir(graphName), entry), "utf8");
|
|
809
|
+
const data = JSON.parse(raw);
|
|
810
|
+
snippets.push({
|
|
811
|
+
id: data.id,
|
|
812
|
+
label: data.label,
|
|
813
|
+
description: data.description ?? "",
|
|
814
|
+
nodeCount: data.nodeCount ?? data.nodes?.length ?? 0,
|
|
815
|
+
edgeCount: data.edgeCount ?? data.edges?.length ?? 0,
|
|
816
|
+
createdAt: data.createdAt,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
catch { }
|
|
820
|
+
}
|
|
821
|
+
return snippets;
|
|
822
|
+
}
|
|
823
|
+
async loadSnippet(graphName, snippetId) {
|
|
824
|
+
const raw = await fs.readFile(this.snippetFile(graphName, snippetId), "utf8");
|
|
825
|
+
return JSON.parse(raw);
|
|
826
|
+
}
|
|
827
|
+
async deleteSnippet(graphName, snippetId) {
|
|
828
|
+
await fs.rm(this.snippetFile(graphName, snippetId));
|
|
829
|
+
}
|
|
830
|
+
// --- Terms (carry-forward from old backend, used by intelligence tools) ---
|
|
831
|
+
async loadTerms(name) {
|
|
832
|
+
try {
|
|
833
|
+
return await fs.readFile(this.termsFile(name), "utf8");
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
//# sourceMappingURL=event-sourced-backend.js.map
|