opentology 0.2.7 → 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/dist/commands/context.js +14 -0
- package/dist/commands/drop.js +2 -0
- package/dist/commands/rollback.d.ts +2 -0
- package/dist/commands/rollback.js +75 -0
- package/dist/index.js +2 -0
- package/dist/lib/snapshot.d.ts +40 -0
- package/dist/lib/snapshot.js +136 -0
- package/dist/mcp/server.d.ts +8 -0
- package/dist/mcp/server.js +108 -3
- package/package.json +1 -1
package/dist/commands/context.js
CHANGED
|
@@ -217,6 +217,20 @@ export function registerContext(program) {
|
|
|
217
217
|
}
|
|
218
218
|
settings.hooks = hooks;
|
|
219
219
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
220
|
+
// Ensure .opentology/snapshots/ is in .gitignore
|
|
221
|
+
const gitignorePath = join(process.cwd(), '.gitignore');
|
|
222
|
+
const snapshotIgnore = '.opentology/snapshots/';
|
|
223
|
+
if (existsSync(gitignorePath)) {
|
|
224
|
+
const gitignoreContent = readFileSync(gitignorePath, 'utf-8');
|
|
225
|
+
if (!gitignoreContent.includes(snapshotIgnore)) {
|
|
226
|
+
writeFileSync(gitignorePath, gitignoreContent.trimEnd() + '\n' + snapshotIgnore + '\n', 'utf-8');
|
|
227
|
+
console.log(pc.green(' Added .opentology/snapshots/ to .gitignore'));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
writeFileSync(gitignorePath, snapshotIgnore + '\n', 'utf-8');
|
|
232
|
+
console.log(pc.green(' Created .gitignore with .opentology/snapshots/'));
|
|
233
|
+
}
|
|
220
234
|
console.log('');
|
|
221
235
|
console.log(pc.dim('Consider adding .opentology/hooks/ to version control so team members share the hook.'));
|
|
222
236
|
}
|
package/dist/commands/drop.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import pc from 'picocolors';
|
|
2
2
|
import { loadConfig, resolveGraphUri, saveConfig } from '../lib/config.js';
|
|
3
3
|
import { createReadyAdapter } from '../lib/store-factory.js';
|
|
4
|
+
import { snapshotGraph } from '../lib/snapshot.js';
|
|
4
5
|
export function registerDrop(program) {
|
|
5
6
|
program
|
|
6
7
|
.command('drop')
|
|
@@ -23,6 +24,7 @@ export function registerDrop(program) {
|
|
|
23
24
|
}
|
|
24
25
|
try {
|
|
25
26
|
const adapter = await createReadyAdapter(config);
|
|
27
|
+
await snapshotGraph(adapter, config, graphUri);
|
|
26
28
|
await adapter.dropGraph(graphUri);
|
|
27
29
|
// In embedded mode, clear tracked files for this graph
|
|
28
30
|
if (config.mode === 'embedded') {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { loadConfig, resolveGraphUri } from '../lib/config.js';
|
|
3
|
+
import { createReadyAdapter } from '../lib/store-factory.js';
|
|
4
|
+
import { listSnapshots, restoreSnapshot } from '../lib/snapshot.js';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
function ask(question) {
|
|
7
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
rl.question(question, (answer) => {
|
|
10
|
+
rl.close();
|
|
11
|
+
resolve(answer.trim().toLowerCase());
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export function registerRollback(program) {
|
|
16
|
+
program
|
|
17
|
+
.command('rollback')
|
|
18
|
+
.description('List or restore graph snapshots')
|
|
19
|
+
.option('--list', 'List available snapshots')
|
|
20
|
+
.option('--to <timestamp>', 'Restore to a specific snapshot timestamp')
|
|
21
|
+
.option('--graph <name>', 'Target a specific named graph')
|
|
22
|
+
.action(async (opts) => {
|
|
23
|
+
let config;
|
|
24
|
+
try {
|
|
25
|
+
config = loadConfig();
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
console.error(`Error: ${err.message}`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
const graphUri = opts.graph ? resolveGraphUri(config, opts.graph) : config.graphUri;
|
|
32
|
+
if (opts.list) {
|
|
33
|
+
const snapshots = listSnapshots(graphUri);
|
|
34
|
+
if (snapshots.length === 0) {
|
|
35
|
+
console.log(pc.dim('No snapshots found for this graph.'));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
console.log(pc.bold(`Snapshots for ${graphUri}:`));
|
|
39
|
+
console.log('');
|
|
40
|
+
for (const s of snapshots) {
|
|
41
|
+
const sizeKb = (s.sizeBytes / 1024).toFixed(1);
|
|
42
|
+
const label = s.isPreRollback ? pc.yellow(' (pre-rollback)') : '';
|
|
43
|
+
console.log(` ${pc.dim(s.timestamp)} ${sizeKb} KB${label}`);
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const adapter = await createReadyAdapter(config);
|
|
49
|
+
if (opts.to) {
|
|
50
|
+
await restoreSnapshot(adapter, config, graphUri, opts.to);
|
|
51
|
+
console.log(pc.green(`Restored graph to snapshot: ${opts.to}`));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// No flags: restore latest snapshot with confirmation
|
|
55
|
+
const snapshots = listSnapshots(graphUri).filter((s) => !s.isPreRollback);
|
|
56
|
+
if (snapshots.length === 0) {
|
|
57
|
+
console.log(pc.dim('No snapshots found for this graph.'));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const latest = snapshots[0];
|
|
61
|
+
const answer = await ask(`Restore to latest snapshot ${pc.bold(latest.timestamp)}? [y/N] `);
|
|
62
|
+
if (answer === 'y' || answer === 'yes') {
|
|
63
|
+
await restoreSnapshot(adapter, config, graphUri, latest.timestamp);
|
|
64
|
+
console.log(pc.green(`Restored graph to snapshot: ${latest.timestamp}`));
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
console.log(pc.dim('Rollback cancelled.'));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
console.error(pc.red(`Error: ${err.message}`));
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import { registerPrefix } from './commands/prefix.js';
|
|
|
17
17
|
import { registerContext } from './commands/context.js';
|
|
18
18
|
import { registerViz } from './commands/viz.js';
|
|
19
19
|
import { registerDoctor } from './commands/doctor.js';
|
|
20
|
+
import { registerRollback } from './commands/rollback.js';
|
|
20
21
|
const program = new Command();
|
|
21
22
|
program
|
|
22
23
|
.name('opentology')
|
|
@@ -39,4 +40,5 @@ registerPrefix(program);
|
|
|
39
40
|
registerContext(program);
|
|
40
41
|
registerViz(program);
|
|
41
42
|
registerDoctor(program);
|
|
43
|
+
registerRollback(program);
|
|
42
44
|
program.parse(process.argv);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { StoreAdapter } from './store-adapter.js';
|
|
2
|
+
import type { OpenTologyConfig } from './config.js';
|
|
3
|
+
/**
|
|
4
|
+
* Derive a filesystem-safe slug from a graph URI.
|
|
5
|
+
* Reuses the same pattern as persistGraph().
|
|
6
|
+
*/
|
|
7
|
+
export declare function graphSlug(graphUri: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Generate an ISO timestamp suitable for filenames.
|
|
10
|
+
* Format: 2026-04-05T14-30-22-123Z (colons replaced with dashes, ms included).
|
|
11
|
+
*/
|
|
12
|
+
export declare function toFilenameTimestamp(date?: Date): string;
|
|
13
|
+
/**
|
|
14
|
+
* Snapshot a named graph before a destructive operation.
|
|
15
|
+
* No-op if not embedded mode or if the graph is empty.
|
|
16
|
+
* Automatically prunes old snapshots after saving.
|
|
17
|
+
*/
|
|
18
|
+
export declare function snapshotGraph(adapter: StoreAdapter, config: OpenTologyConfig, graphUri: string, retention?: number): Promise<string | null>;
|
|
19
|
+
export interface SnapshotInfo {
|
|
20
|
+
timestamp: string;
|
|
21
|
+
path: string;
|
|
22
|
+
sizeBytes: number;
|
|
23
|
+
isPreRollback: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* List snapshots for a graph, sorted by timestamp descending (newest first).
|
|
27
|
+
*/
|
|
28
|
+
export declare function listSnapshots(graphUri: string): SnapshotInfo[];
|
|
29
|
+
/**
|
|
30
|
+
* Restore a graph to a specific snapshot.
|
|
31
|
+
* 1. Saves current state as {timestamp}_pre-rollback.ttl (snapshot-before-rollback)
|
|
32
|
+
* 2. Drops the graph and loads the snapshot
|
|
33
|
+
* 3. Persists the restored state to .opentology/data/
|
|
34
|
+
*/
|
|
35
|
+
export declare function restoreSnapshot(adapter: StoreAdapter, config: OpenTologyConfig, graphUri: string, timestamp: string): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Prune old snapshots, keeping only the most recent `keep` count.
|
|
38
|
+
* Pre-rollback snapshots are pruned separately (keep 2).
|
|
39
|
+
*/
|
|
40
|
+
export declare function pruneSnapshots(graphUri: string, keep?: number): Promise<number>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { persistGraph } from '../mcp/server.js';
|
|
4
|
+
const DEFAULT_RETENTION = 5;
|
|
5
|
+
/**
|
|
6
|
+
* Derive a filesystem-safe slug from a graph URI.
|
|
7
|
+
* Reuses the same pattern as persistGraph().
|
|
8
|
+
*/
|
|
9
|
+
export function graphSlug(graphUri) {
|
|
10
|
+
return graphUri.replace(/[^a-zA-Z0-9-]/g, '_').replace(/_+/g, '_');
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Generate an ISO timestamp suitable for filenames.
|
|
14
|
+
* Format: 2026-04-05T14-30-22-123Z (colons replaced with dashes, ms included).
|
|
15
|
+
*/
|
|
16
|
+
export function toFilenameTimestamp(date = new Date()) {
|
|
17
|
+
return date.toISOString().replace(/:/g, '-').replace(/\./g, '-');
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Parse a filename timestamp back to a Date.
|
|
21
|
+
*/
|
|
22
|
+
function parseFilenameTimestamp(filename) {
|
|
23
|
+
// Remove .ttl suffix and _pre-rollback suffix
|
|
24
|
+
const base = filename.replace(/\.ttl$/, '').replace(/_pre-rollback$/, '');
|
|
25
|
+
// Reverse the filename encoding: dashes back to colons/dots
|
|
26
|
+
// Format: 2026-04-05T14-30-22-123Z → 2026-04-05T14:30:22.123Z
|
|
27
|
+
const parts = base.match(/^(\d{4}-\d{2}-\d{2}T)(\d{2})-(\d{2})-(\d{2})-(\d{3}Z)$/);
|
|
28
|
+
if (!parts)
|
|
29
|
+
return null;
|
|
30
|
+
const iso = `${parts[1]}${parts[2]}:${parts[3]}:${parts[4]}.${parts[5]}`;
|
|
31
|
+
const d = new Date(iso);
|
|
32
|
+
return isNaN(d.getTime()) ? null : d;
|
|
33
|
+
}
|
|
34
|
+
function snapshotDir(graphUri) {
|
|
35
|
+
return join(process.cwd(), '.opentology', 'snapshots', graphSlug(graphUri));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Snapshot a named graph before a destructive operation.
|
|
39
|
+
* No-op if not embedded mode or if the graph is empty.
|
|
40
|
+
* Automatically prunes old snapshots after saving.
|
|
41
|
+
*/
|
|
42
|
+
export async function snapshotGraph(adapter, config, graphUri, retention = DEFAULT_RETENTION) {
|
|
43
|
+
if (config.mode !== 'embedded')
|
|
44
|
+
return null;
|
|
45
|
+
const exported = await adapter.exportGraph(graphUri);
|
|
46
|
+
if (!exported.trim())
|
|
47
|
+
return null;
|
|
48
|
+
const dir = snapshotDir(graphUri);
|
|
49
|
+
mkdirSync(dir, { recursive: true });
|
|
50
|
+
const timestamp = toFilenameTimestamp();
|
|
51
|
+
const filename = `${timestamp}.ttl`;
|
|
52
|
+
const filePath = join(dir, filename);
|
|
53
|
+
writeFileSync(filePath, exported, 'utf-8');
|
|
54
|
+
// Verify the file was written
|
|
55
|
+
if (!existsSync(filePath)) {
|
|
56
|
+
throw new Error(`Snapshot write failed: ${filePath}`);
|
|
57
|
+
}
|
|
58
|
+
// Prune old snapshots (create → verify → prune order)
|
|
59
|
+
await pruneSnapshots(graphUri, retention);
|
|
60
|
+
return filePath;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* List snapshots for a graph, sorted by timestamp descending (newest first).
|
|
64
|
+
*/
|
|
65
|
+
export function listSnapshots(graphUri) {
|
|
66
|
+
const dir = snapshotDir(graphUri);
|
|
67
|
+
if (!existsSync(dir))
|
|
68
|
+
return [];
|
|
69
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.ttl'));
|
|
70
|
+
return files
|
|
71
|
+
.map((f) => {
|
|
72
|
+
const fullPath = join(dir, f);
|
|
73
|
+
const stat = statSync(fullPath);
|
|
74
|
+
return {
|
|
75
|
+
timestamp: f.replace(/\.ttl$/, ''),
|
|
76
|
+
path: fullPath,
|
|
77
|
+
sizeBytes: stat.size,
|
|
78
|
+
isPreRollback: f.includes('_pre-rollback'),
|
|
79
|
+
};
|
|
80
|
+
})
|
|
81
|
+
.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Restore a graph to a specific snapshot.
|
|
85
|
+
* 1. Saves current state as {timestamp}_pre-rollback.ttl (snapshot-before-rollback)
|
|
86
|
+
* 2. Drops the graph and loads the snapshot
|
|
87
|
+
* 3. Persists the restored state to .opentology/data/
|
|
88
|
+
*/
|
|
89
|
+
export async function restoreSnapshot(adapter, config, graphUri, timestamp) {
|
|
90
|
+
const dir = snapshotDir(graphUri);
|
|
91
|
+
const snapshotPath = join(dir, `${timestamp}.ttl`);
|
|
92
|
+
if (!existsSync(snapshotPath)) {
|
|
93
|
+
throw new Error(`Snapshot not found: ${timestamp}`);
|
|
94
|
+
}
|
|
95
|
+
// Step 1: Snapshot-before-rollback — save current state
|
|
96
|
+
const currentExport = await adapter.exportGraph(graphUri);
|
|
97
|
+
if (currentExport.trim()) {
|
|
98
|
+
mkdirSync(dir, { recursive: true });
|
|
99
|
+
const preRollbackTimestamp = toFilenameTimestamp();
|
|
100
|
+
const preRollbackPath = join(dir, `${preRollbackTimestamp}_pre-rollback.ttl`);
|
|
101
|
+
writeFileSync(preRollbackPath, currentExport, 'utf-8');
|
|
102
|
+
}
|
|
103
|
+
// Step 2: Drop and restore
|
|
104
|
+
const snapshotTurtle = readFileSync(snapshotPath, 'utf-8');
|
|
105
|
+
await adapter.dropGraph(graphUri);
|
|
106
|
+
await adapter.insertTurtle(graphUri, snapshotTurtle);
|
|
107
|
+
// Step 3: Persist restored state
|
|
108
|
+
await persistGraph(adapter, config, graphUri);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Prune old snapshots, keeping only the most recent `keep` count.
|
|
112
|
+
* Pre-rollback snapshots are pruned separately (keep 2).
|
|
113
|
+
*/
|
|
114
|
+
export async function pruneSnapshots(graphUri, keep = DEFAULT_RETENTION) {
|
|
115
|
+
const dir = snapshotDir(graphUri);
|
|
116
|
+
if (!existsSync(dir))
|
|
117
|
+
return 0;
|
|
118
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.ttl'));
|
|
119
|
+
// Separate regular snapshots and pre-rollback snapshots
|
|
120
|
+
const regular = files.filter((f) => !f.includes('_pre-rollback')).sort();
|
|
121
|
+
const preRollback = files.filter((f) => f.includes('_pre-rollback')).sort();
|
|
122
|
+
let deleted = 0;
|
|
123
|
+
// Prune regular snapshots
|
|
124
|
+
while (regular.length > keep) {
|
|
125
|
+
const oldest = regular.shift();
|
|
126
|
+
unlinkSync(join(dir, oldest));
|
|
127
|
+
deleted++;
|
|
128
|
+
}
|
|
129
|
+
// Prune pre-rollback snapshots (keep max 2)
|
|
130
|
+
while (preRollback.length > 2) {
|
|
131
|
+
const oldest = preRollback.shift();
|
|
132
|
+
unlinkSync(join(dir, oldest));
|
|
133
|
+
deleted++;
|
|
134
|
+
}
|
|
135
|
+
return deleted;
|
|
136
|
+
}
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import type { OpenTologyConfig } from '../lib/config.js';
|
|
2
|
+
import type { StoreAdapter } from '../lib/store-adapter.js';
|
|
1
3
|
export declare const MAX_TRIPLES_PER_PUSH = 100;
|
|
2
4
|
export declare function assertTripleLimit(tripleCount: number): void;
|
|
5
|
+
/**
|
|
6
|
+
* Persist a named graph to a .ttl file in embedded mode.
|
|
7
|
+
* Exports the full graph, writes to .opentology/data/{slug}.ttl, and tracks
|
|
8
|
+
* the file in config — mirroring CLI push behavior.
|
|
9
|
+
*/
|
|
10
|
+
export declare function persistGraph(adapter: StoreAdapter, config: OpenTologyConfig, graphUri: string): Promise<void>;
|
|
3
11
|
export declare function startMcpServer(): Promise<void>;
|
package/dist/mcp/server.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
-
import { loadConfig, saveConfig, configExists, resolveGraphUri } from '../lib/config.js';
|
|
4
|
+
import { loadConfig, saveConfig, configExists, resolveGraphUri, addTrackedFile } from '../lib/config.js';
|
|
5
5
|
import { deepScan } from '../lib/deep-scanner.js';
|
|
6
6
|
import { pushSymbolTriples } from '../lib/deep-scan-triples.js';
|
|
7
7
|
import { createReadyAdapter } from '../lib/store-factory.js';
|
|
8
8
|
import { normalizeModuleUri } from '../lib/module-uri.js';
|
|
9
|
+
import { snapshotGraph, listSnapshots, restoreSnapshot } from '../lib/snapshot.js';
|
|
9
10
|
import { hasGraphScope, autoScopeQuery, getInferenceGraphUri } from '../lib/sparql-utils.js';
|
|
10
11
|
import { validateTurtle } from '../lib/validator.js';
|
|
11
12
|
import { discoverShapes, validateWithShacl, hasShapes } from '../lib/shacl.js';
|
|
@@ -41,6 +42,26 @@ function resolveConfig(params) {
|
|
|
41
42
|
throw new Error('No config found. Run opentology init first.');
|
|
42
43
|
}
|
|
43
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Persist a named graph to a .ttl file in embedded mode.
|
|
47
|
+
* Exports the full graph, writes to .opentology/data/{slug}.ttl, and tracks
|
|
48
|
+
* the file in config — mirroring CLI push behavior.
|
|
49
|
+
*/
|
|
50
|
+
export async function persistGraph(adapter, config, graphUri) {
|
|
51
|
+
if (config.mode !== 'embedded')
|
|
52
|
+
return;
|
|
53
|
+
const exported = await adapter.exportGraph(graphUri);
|
|
54
|
+
if (!exported.trim())
|
|
55
|
+
return;
|
|
56
|
+
// Derive a filename slug from the graph URI
|
|
57
|
+
const slug = graphUri.replace(/[^a-zA-Z0-9-]/g, '_').replace(/_+/g, '_');
|
|
58
|
+
const dataDir = join(process.cwd(), '.opentology', 'data');
|
|
59
|
+
const filePath = join(dataDir, `${slug}.ttl`);
|
|
60
|
+
mkdirSync(dataDir, { recursive: true });
|
|
61
|
+
writeFileSync(filePath, exported, 'utf-8');
|
|
62
|
+
addTrackedFile(config, graphUri, filePath);
|
|
63
|
+
saveConfig(config);
|
|
64
|
+
}
|
|
44
65
|
async function handleInit(args) {
|
|
45
66
|
const projectId = args.projectId;
|
|
46
67
|
const mode = args.mode || 'http';
|
|
@@ -100,6 +121,7 @@ async function handlePush(args) {
|
|
|
100
121
|
});
|
|
101
122
|
const adapter = await createReadyAdapter(config);
|
|
102
123
|
if (replace) {
|
|
124
|
+
await snapshotGraph(adapter, config, graphUri);
|
|
103
125
|
await adapter.dropGraph(graphUri);
|
|
104
126
|
}
|
|
105
127
|
await adapter.insertTurtle(graphUri, content);
|
|
@@ -108,6 +130,7 @@ async function handlePush(args) {
|
|
|
108
130
|
if (infer !== false) {
|
|
109
131
|
inference = await materializeInferences(adapter, graphUri);
|
|
110
132
|
}
|
|
133
|
+
await persistGraph(adapter, config, graphUri);
|
|
111
134
|
return {
|
|
112
135
|
success: true,
|
|
113
136
|
tripleCount: validation.tripleCount,
|
|
@@ -199,7 +222,9 @@ async function handleDrop(args) {
|
|
|
199
222
|
graph: args.graph,
|
|
200
223
|
});
|
|
201
224
|
const adapter = await createReadyAdapter(config);
|
|
225
|
+
await snapshotGraph(adapter, config, graphUri);
|
|
202
226
|
await adapter.dropGraph(graphUri);
|
|
227
|
+
await persistGraph(adapter, config, graphUri);
|
|
203
228
|
return { success: true, graphUri };
|
|
204
229
|
}
|
|
205
230
|
async function handleDelete(args) {
|
|
@@ -213,9 +238,32 @@ async function handleDelete(args) {
|
|
|
213
238
|
graph: args.graph,
|
|
214
239
|
});
|
|
215
240
|
const adapter = await createReadyAdapter(config);
|
|
241
|
+
await snapshotGraph(adapter, config, graphUri);
|
|
216
242
|
await adapter.deleteTriples(graphUri, { turtle: content, where });
|
|
243
|
+
await persistGraph(adapter, config, graphUri);
|
|
217
244
|
return { success: true };
|
|
218
245
|
}
|
|
246
|
+
async function handleRollback(args) {
|
|
247
|
+
const action = args.action;
|
|
248
|
+
const { config, graphUri } = resolveConfig({
|
|
249
|
+
graphUri: args.graphUri,
|
|
250
|
+
graph: args.graph,
|
|
251
|
+
});
|
|
252
|
+
if (action === 'list') {
|
|
253
|
+
const snapshots = listSnapshots(graphUri);
|
|
254
|
+
return { graphUri, snapshots };
|
|
255
|
+
}
|
|
256
|
+
if (action === 'restore') {
|
|
257
|
+
const to = args.to;
|
|
258
|
+
if (!to) {
|
|
259
|
+
throw new Error('timestamp (to) is required for restore action');
|
|
260
|
+
}
|
|
261
|
+
const adapter = await createReadyAdapter(config);
|
|
262
|
+
await restoreSnapshot(adapter, config, graphUri, to);
|
|
263
|
+
return { success: true, graphUri, restoredTo: to };
|
|
264
|
+
}
|
|
265
|
+
throw new Error(`Unknown rollback action: ${action}. Use 'list' or 'restore'.`);
|
|
266
|
+
}
|
|
219
267
|
async function handleDiff(args) {
|
|
220
268
|
const content = args.content;
|
|
221
269
|
if (!content) {
|
|
@@ -287,7 +335,9 @@ async function handleGraphDrop(args) {
|
|
|
287
335
|
const config = loadConfig();
|
|
288
336
|
const graphUri = resolveGraphUri(config, name);
|
|
289
337
|
const adapter = await createReadyAdapter(config);
|
|
338
|
+
await snapshotGraph(adapter, config, graphUri);
|
|
290
339
|
await adapter.dropGraph(graphUri);
|
|
340
|
+
await persistGraph(adapter, config, graphUri);
|
|
291
341
|
const graphs = config.graphs ?? {};
|
|
292
342
|
delete graphs[name];
|
|
293
343
|
config.graphs = Object.keys(graphs).length > 0 ? graphs : undefined;
|
|
@@ -302,6 +352,7 @@ async function handleInfer(args) {
|
|
|
302
352
|
});
|
|
303
353
|
const adapter = await createReadyAdapter(config);
|
|
304
354
|
if (clear) {
|
|
355
|
+
await snapshotGraph(adapter, config, graphUri);
|
|
305
356
|
await clearInferences(adapter, graphUri);
|
|
306
357
|
return { success: true, cleared: true };
|
|
307
358
|
}
|
|
@@ -328,6 +379,7 @@ async function handleContextScan(args) {
|
|
|
328
379
|
const contextUri = `${config.graphUri}/context`;
|
|
329
380
|
const adapter = await createReadyAdapter(config);
|
|
330
381
|
pushStats = await pushSymbolTriples(adapter, contextUri, scanResult);
|
|
382
|
+
await persistGraph(adapter, config, contextUri);
|
|
331
383
|
}
|
|
332
384
|
catch {
|
|
333
385
|
// Non-fatal: push is best-effort
|
|
@@ -351,6 +403,8 @@ async function handleContextScan(args) {
|
|
|
351
403
|
const adapter = await createReadyAdapter(config);
|
|
352
404
|
if (snapshot.dependencyGraph && snapshot.dependencyGraph.modules.length > 0) {
|
|
353
405
|
const dg = snapshot.dependencyGraph;
|
|
406
|
+
// Snapshot before destructive scan
|
|
407
|
+
await snapshotGraph(adapter, config, contextUri);
|
|
354
408
|
// Scoped delete: clear stale module triples before re-insert
|
|
355
409
|
await adapter.sparqlUpdate(`DELETE WHERE { GRAPH <${contextUri}> { ?m a <https://opentology.dev/vocab#Module> . ?m ?p ?o } }`);
|
|
356
410
|
// Insert fresh triples
|
|
@@ -363,6 +417,7 @@ async function handleContextScan(args) {
|
|
|
363
417
|
}
|
|
364
418
|
await adapter.sparqlUpdate(`INSERT DATA { GRAPH <${contextUri}> {\n${sparqlTriples.join('\n')}\n} }`);
|
|
365
419
|
moduleStats = { modules: dg.modules.length, edges: dg.edges.length };
|
|
420
|
+
await persistGraph(adapter, config, contextUri);
|
|
366
421
|
}
|
|
367
422
|
}
|
|
368
423
|
catch {
|
|
@@ -499,6 +554,20 @@ async function handleContextInit(args) {
|
|
|
499
554
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
500
555
|
actions.push('Auto-registered hooks in .claude/settings.json');
|
|
501
556
|
}
|
|
557
|
+
// Ensure .opentology/snapshots/ is in .gitignore
|
|
558
|
+
const gitignorePath = join(process.cwd(), '.gitignore');
|
|
559
|
+
const snapshotIgnore = '.opentology/snapshots/';
|
|
560
|
+
if (existsSync(gitignorePath)) {
|
|
561
|
+
const gitignoreContent = readFileSync(gitignorePath, 'utf-8');
|
|
562
|
+
if (!gitignoreContent.includes(snapshotIgnore)) {
|
|
563
|
+
writeFileSync(gitignorePath, gitignoreContent.trimEnd() + '\n' + snapshotIgnore + '\n', 'utf-8');
|
|
564
|
+
actions.push('Added .opentology/snapshots/ to .gitignore');
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
writeFileSync(gitignorePath, snapshotIgnore + '\n', 'utf-8');
|
|
569
|
+
actions.push('Created .gitignore with .opentology/snapshots/');
|
|
570
|
+
}
|
|
502
571
|
// Auto-push Module triples from dependency graph
|
|
503
572
|
let moduleStats = null;
|
|
504
573
|
try {
|
|
@@ -515,6 +584,7 @@ async function handleContextInit(args) {
|
|
|
515
584
|
}
|
|
516
585
|
await adapter.sparqlUpdate(`INSERT DATA { GRAPH <${contextUri}> {\n${sparqlTriples.join('\n')}\n} }`);
|
|
517
586
|
moduleStats = { modules: dg.modules.length, edges: dg.edges.length };
|
|
587
|
+
await persistGraph(adapter, config, contextUri);
|
|
518
588
|
actions.push(`Pushed ${dg.modules.length} Module triples with ${dg.edges.length} dependsOn edges`);
|
|
519
589
|
}
|
|
520
590
|
}
|
|
@@ -1235,6 +1305,33 @@ export async function startMcpServer() {
|
|
|
1235
1305
|
properties: {},
|
|
1236
1306
|
},
|
|
1237
1307
|
},
|
|
1308
|
+
{
|
|
1309
|
+
name: 'rollback',
|
|
1310
|
+
description: 'List or restore graph snapshots. Snapshots are automatically created before destructive operations (drop, delete, scan, etc.). Use action=list to see available snapshots, action=restore with a timestamp to restore.',
|
|
1311
|
+
inputSchema: {
|
|
1312
|
+
type: 'object',
|
|
1313
|
+
properties: {
|
|
1314
|
+
action: {
|
|
1315
|
+
type: 'string',
|
|
1316
|
+
enum: ['list', 'restore'],
|
|
1317
|
+
description: 'Action: list available snapshots or restore a specific one',
|
|
1318
|
+
},
|
|
1319
|
+
to: {
|
|
1320
|
+
type: 'string',
|
|
1321
|
+
description: 'Timestamp of the snapshot to restore (required for action=restore)',
|
|
1322
|
+
},
|
|
1323
|
+
graph: {
|
|
1324
|
+
type: 'string',
|
|
1325
|
+
description: 'Logical graph name (as created by graph_create). Resolves to a graph URI via config.',
|
|
1326
|
+
},
|
|
1327
|
+
graphUri: {
|
|
1328
|
+
type: 'string',
|
|
1329
|
+
description: 'Named graph URI (uses config default if omitted)',
|
|
1330
|
+
},
|
|
1331
|
+
},
|
|
1332
|
+
required: ['action'],
|
|
1333
|
+
},
|
|
1334
|
+
},
|
|
1238
1335
|
],
|
|
1239
1336
|
}));
|
|
1240
1337
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
@@ -1308,8 +1405,16 @@ export async function startMcpServer() {
|
|
|
1308
1405
|
case 'context_impact':
|
|
1309
1406
|
result = await handleContextImpact(args);
|
|
1310
1407
|
break;
|
|
1311
|
-
case 'context_sync':
|
|
1312
|
-
|
|
1408
|
+
case 'context_sync': {
|
|
1409
|
+
const syncConfig = loadConfig();
|
|
1410
|
+
const syncContextUri = `${syncConfig.graphUri}/context`;
|
|
1411
|
+
const syncAdapter = await createReadyAdapter(syncConfig);
|
|
1412
|
+
await snapshotGraph(syncAdapter, syncConfig, syncContextUri);
|
|
1413
|
+
result = await syncContext(syncConfig, process.cwd());
|
|
1414
|
+
break;
|
|
1415
|
+
}
|
|
1416
|
+
case 'rollback':
|
|
1417
|
+
result = await handleRollback(args);
|
|
1313
1418
|
break;
|
|
1314
1419
|
case 'doctor':
|
|
1315
1420
|
result = await runDoctor();
|