@aprovan/hardcopy 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +22 -0
- package/.github/workflows/publish.yml +41 -0
- package/.prettierignore +17 -0
- package/LICENSE +21 -0
- package/README.md +183 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +2950 -0
- package/dist/index.d.ts +406 -0
- package/dist/index.js +2737 -0
- package/dist/mcp-server.d.ts +7 -0
- package/dist/mcp-server.js +2665 -0
- package/docs/research/crdt.md +777 -0
- package/docs/research/github-issues.md +684 -0
- package/docs/research/gql.md +876 -0
- package/docs/research/index.md +19 -0
- package/docs/specs/conflict-resolution.md +1254 -0
- package/docs/specs/hardcopy.md +742 -0
- package/docs/specs/patchwork-integration.md +227 -0
- package/docs/specs/plugin-architecture.md +747 -0
- package/mcp.json +8 -0
- package/package.json +64 -0
- package/scripts/install-graphqlite.ts +156 -0
- package/src/cli.ts +356 -0
- package/src/config.ts +104 -0
- package/src/conflict-store.ts +136 -0
- package/src/conflict.ts +147 -0
- package/src/crdt.ts +100 -0
- package/src/db.ts +600 -0
- package/src/env.ts +34 -0
- package/src/format.ts +72 -0
- package/src/formats/github-issue.ts +55 -0
- package/src/hardcopy/core.ts +78 -0
- package/src/hardcopy/diff.ts +188 -0
- package/src/hardcopy/index.ts +67 -0
- package/src/hardcopy/init.ts +24 -0
- package/src/hardcopy/push.ts +444 -0
- package/src/hardcopy/sync.ts +37 -0
- package/src/hardcopy/types.ts +49 -0
- package/src/hardcopy/views.ts +199 -0
- package/src/hardcopy.ts +1 -0
- package/src/index.ts +13 -0
- package/src/llm-merge.ts +109 -0
- package/src/mcp-server.ts +388 -0
- package/src/merge.ts +75 -0
- package/src/provider.ts +40 -0
- package/src/providers/a2a/index.ts +166 -0
- package/src/providers/git/index.ts +212 -0
- package/src/providers/github/index.ts +236 -0
- package/src/providers/github/issues.ts +66 -0
- package/src/providers.ts +7 -0
- package/src/types.ts +101 -0
- package/tsconfig.json +21 -0
- package/tsup.config.ts +10 -0
package/src/db.ts
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
import BetterSqlite3 from "better-sqlite3";
|
|
2
|
+
import type { Database as BetterSqlite3Database } from "better-sqlite3";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import type { Node, Edge } from "./types";
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
|
|
10
|
+
// Re-export for consumers who need to type the raw database
|
|
11
|
+
export type { BetterSqlite3Database };
|
|
12
|
+
|
|
13
|
+
const SCHEMA = `
|
|
14
|
+
CREATE TABLE IF NOT EXISTS hc_nodes (
|
|
15
|
+
id TEXT PRIMARY KEY,
|
|
16
|
+
type TEXT NOT NULL,
|
|
17
|
+
attrs TEXT NOT NULL,
|
|
18
|
+
synced_at INTEGER,
|
|
19
|
+
version_token TEXT,
|
|
20
|
+
cursor TEXT
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE INDEX IF NOT EXISTS hc_idx_nodes_type ON hc_nodes(type);
|
|
24
|
+
CREATE INDEX IF NOT EXISTS hc_idx_nodes_synced ON hc_nodes(synced_at);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS hc_edges (
|
|
27
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
28
|
+
type TEXT NOT NULL,
|
|
29
|
+
from_id TEXT NOT NULL,
|
|
30
|
+
to_id TEXT NOT NULL,
|
|
31
|
+
attrs TEXT,
|
|
32
|
+
UNIQUE(type, from_id, to_id)
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE INDEX IF NOT EXISTS hc_idx_edges_from ON hc_edges(from_id);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS hc_idx_edges_to ON hc_edges(to_id);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS hc_idx_edges_type ON hc_edges(type);
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
const GRAPHQLITE_ENV_PATH = "GRAPHQLITE_EXTENSION_PATH";
|
|
41
|
+
const GRAPHQLITE_TEST_QUERY = "SELECT graphqlite_test() AS result";
|
|
42
|
+
|
|
43
|
+
export class HardcopyDatabase {
|
|
44
|
+
private db: BetterSqlite3Database;
|
|
45
|
+
private graphqliteLoaded = false;
|
|
46
|
+
|
|
47
|
+
constructor(db: BetterSqlite3Database) {
|
|
48
|
+
this.db = db;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static async open(path: string): Promise<HardcopyDatabase> {
|
|
52
|
+
const db = new BetterSqlite3(path);
|
|
53
|
+
const hcdb = new HardcopyDatabase(db);
|
|
54
|
+
await hcdb.initialize();
|
|
55
|
+
return hcdb;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private async initialize(): Promise<void> {
|
|
59
|
+
await this.migrateLegacySchema();
|
|
60
|
+
const statements = SCHEMA.split(";")
|
|
61
|
+
.map((s) => s.trim())
|
|
62
|
+
.filter((s) => s.length > 0);
|
|
63
|
+
for (const sql of statements) {
|
|
64
|
+
this.db.exec(sql);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private async migrateLegacySchema(): Promise<void> {
|
|
69
|
+
const legacyNodes = this.getTableColumns("nodes");
|
|
70
|
+
const legacyEdges = this.getTableColumns("edges");
|
|
71
|
+
|
|
72
|
+
if (legacyNodes) {
|
|
73
|
+
const isLegacy =
|
|
74
|
+
legacyNodes.includes("type") || legacyNodes.includes("attrs");
|
|
75
|
+
if (isLegacy) {
|
|
76
|
+
this.renameTableIfNeeded("nodes", "hc_nodes");
|
|
77
|
+
this.dropLegacyIndexes(["idx_nodes_type", "idx_nodes_synced"]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (legacyEdges) {
|
|
82
|
+
const isLegacy =
|
|
83
|
+
legacyEdges.includes("from_id") || legacyEdges.includes("to_id");
|
|
84
|
+
if (isLegacy) {
|
|
85
|
+
this.renameTableIfNeeded("edges", "hc_edges");
|
|
86
|
+
this.dropLegacyIndexes([
|
|
87
|
+
"idx_edges_from",
|
|
88
|
+
"idx_edges_to",
|
|
89
|
+
"idx_edges_type",
|
|
90
|
+
]);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private getTableColumns(table: string): string[] | null {
|
|
96
|
+
const stmt = this.db.prepare(
|
|
97
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name = ?",
|
|
98
|
+
);
|
|
99
|
+
const result = stmt.all(table) as { name: string }[];
|
|
100
|
+
if (result.length === 0) return null;
|
|
101
|
+
|
|
102
|
+
const columns = this.db.pragma(`table_info(${table})`) as {
|
|
103
|
+
name: string;
|
|
104
|
+
}[];
|
|
105
|
+
return columns.map((row) => row.name);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private renameTableIfNeeded(from: string, to: string): void {
|
|
109
|
+
const stmt = this.db.prepare(
|
|
110
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name = ?",
|
|
111
|
+
);
|
|
112
|
+
const existing = stmt.all(to);
|
|
113
|
+
if (existing.length > 0) return;
|
|
114
|
+
this.db.exec(`ALTER TABLE ${from} RENAME TO ${to}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private dropLegacyIndexes(names: string[]): void {
|
|
118
|
+
for (const name of names) {
|
|
119
|
+
this.db.exec(`DROP INDEX IF EXISTS ${name}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private resolveGraphqliteLoadPath(): string | null {
|
|
124
|
+
// 1. Check environment variable
|
|
125
|
+
const envPath = process.env[GRAPHQLITE_ENV_PATH];
|
|
126
|
+
if (envPath) {
|
|
127
|
+
return envPath;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 2. Auto-discover in .hardcopy/extensions/
|
|
131
|
+
const extensionCandidates = this.getExtensionCandidates();
|
|
132
|
+
for (const candidate of extensionCandidates) {
|
|
133
|
+
if (existsSync(candidate)) {
|
|
134
|
+
return candidate;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private getExtensionCandidates(): string[] {
|
|
142
|
+
const platform = process.platform;
|
|
143
|
+
const arch = process.arch;
|
|
144
|
+
|
|
145
|
+
const filenames: string[] = [];
|
|
146
|
+
if (platform === "darwin" && arch === "arm64") {
|
|
147
|
+
filenames.push("graphqlite-macos-arm64.dylib");
|
|
148
|
+
} else if (platform === "darwin" && arch === "x64") {
|
|
149
|
+
filenames.push("graphqlite-macos-x86_64.dylib");
|
|
150
|
+
} else if (platform === "linux" && arch === "arm64") {
|
|
151
|
+
filenames.push("graphqlite-linux-aarch64.so");
|
|
152
|
+
} else if (platform === "linux" && arch === "x64") {
|
|
153
|
+
filenames.push("graphqlite-linux-x86_64.so");
|
|
154
|
+
} else if (platform === "win32" && arch === "x64") {
|
|
155
|
+
filenames.push("graphqlite-windows-x86_64.dll");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Search in project .hardcopy/extensions and cwd .hardcopy/extensions
|
|
159
|
+
const searchDirs = [
|
|
160
|
+
join(__dirname, "..", ".hardcopy", "extensions"),
|
|
161
|
+
join(process.cwd(), ".hardcopy", "extensions"),
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
const candidates: string[] = [];
|
|
165
|
+
for (const dir of searchDirs) {
|
|
166
|
+
for (const filename of filenames) {
|
|
167
|
+
candidates.push(join(dir, filename));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return candidates;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private ensureGraphqliteLoaded(): void {
|
|
174
|
+
if (this.graphqliteLoaded) return;
|
|
175
|
+
|
|
176
|
+
// Check if already loaded
|
|
177
|
+
try {
|
|
178
|
+
const stmt = this.db.prepare(GRAPHQLITE_TEST_QUERY);
|
|
179
|
+
const result = stmt.all() as { result: string }[];
|
|
180
|
+
const value = String(result[0]?.result ?? "");
|
|
181
|
+
if (value.toLowerCase().includes("successfully")) {
|
|
182
|
+
this.graphqliteLoaded = true;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
// Extension not loaded yet.
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const loadPath = this.resolveGraphqliteLoadPath();
|
|
190
|
+
if (!loadPath) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`GraphQLite extension not found. Run \`pnpm setup:graphqlite\` or set ${GRAPHQLITE_ENV_PATH}.`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Use native loadExtension method with explicit entry point
|
|
197
|
+
// Note: better-sqlite3 types don't include the entryPoint parameter, but it's supported
|
|
198
|
+
(
|
|
199
|
+
this.db as unknown as {
|
|
200
|
+
loadExtension: (path: string, entryPoint: string) => void;
|
|
201
|
+
}
|
|
202
|
+
).loadExtension(loadPath, "sqlite3_graphqlite_init");
|
|
203
|
+
|
|
204
|
+
// Verify it loaded
|
|
205
|
+
const verifyStmt = this.db.prepare(GRAPHQLITE_TEST_QUERY);
|
|
206
|
+
const verify = verifyStmt.all() as { result: string }[];
|
|
207
|
+
const value = String(verify[0]?.result ?? "");
|
|
208
|
+
if (!value.toLowerCase().includes("successfully")) {
|
|
209
|
+
throw new Error("GraphQLite extension loaded but verification failed.");
|
|
210
|
+
}
|
|
211
|
+
this.graphqliteLoaded = true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private normalizeCypher(query: string): string {
|
|
215
|
+
// Convert SQL JSON path syntax to property access
|
|
216
|
+
let normalized = query.replace(/->>'(\w+)'/g, ".$1");
|
|
217
|
+
// Remove .attrs prefix since we flatten attributes directly on nodes
|
|
218
|
+
normalized = normalized.replace(/\.attrs\.(\w+)/g, ".$1");
|
|
219
|
+
// Escape dotted labels (e.g., github.Issue -> `github.Issue`)
|
|
220
|
+
normalized = normalized.replace(
|
|
221
|
+
/:([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+)/g,
|
|
222
|
+
(_match, label) => `:\`${label}\``,
|
|
223
|
+
);
|
|
224
|
+
return normalized;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private escapeCypherType(value: string): string {
|
|
228
|
+
const escaped = value.replace(/`/g, "``");
|
|
229
|
+
return `\`${escaped}\``;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private extractNodeIds(rows: Record<string, unknown>[]): string[] {
|
|
233
|
+
const ids = new Set<string>();
|
|
234
|
+
for (const row of rows) {
|
|
235
|
+
for (const [key, value] of Object.entries(row)) {
|
|
236
|
+
// Check for node_id (our flattened property)
|
|
237
|
+
if (key === "node_id" && typeof value === "string") {
|
|
238
|
+
ids.add(value);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (key.endsWith(".node_id") && typeof value === "string") {
|
|
242
|
+
ids.add(value);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
// Also check for id (legacy/fallback)
|
|
246
|
+
if (key === "id" && typeof value === "string") {
|
|
247
|
+
ids.add(value);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (key.endsWith(".id") && typeof value === "string") {
|
|
251
|
+
ids.add(value);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
// Check nested objects - GraphQLite returns nodes as {id, labels, properties}
|
|
255
|
+
if (value && typeof value === "object") {
|
|
256
|
+
const obj = value as Record<string, unknown>;
|
|
257
|
+
// Direct node_id on object
|
|
258
|
+
if (typeof obj["node_id"] === "string") {
|
|
259
|
+
ids.add(obj["node_id"] as string);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
// GraphQLite structure: node.properties.node_id
|
|
263
|
+
const props = obj["properties"];
|
|
264
|
+
if (props && typeof props === "object") {
|
|
265
|
+
const propsObj = props as Record<string, unknown>;
|
|
266
|
+
if (typeof propsObj["node_id"] === "string") {
|
|
267
|
+
ids.add(propsObj["node_id"] as string);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// Fallback: check id
|
|
272
|
+
if (typeof obj["id"] === "string") ids.add(obj["id"] as string);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return Array.from(ids);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private parseCypherRows(
|
|
280
|
+
rows: { result: string | null }[],
|
|
281
|
+
): Record<string, unknown>[] {
|
|
282
|
+
if (!rows.length) return [];
|
|
283
|
+
const row = rows[0]!;
|
|
284
|
+
const payload = row.result;
|
|
285
|
+
if (payload === null || payload === undefined) return [];
|
|
286
|
+
if (typeof payload !== "string") return [];
|
|
287
|
+
try {
|
|
288
|
+
const parsed = JSON.parse(payload);
|
|
289
|
+
return Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [];
|
|
290
|
+
} catch {
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async cypher(
|
|
296
|
+
query: string,
|
|
297
|
+
params?: Record<string, unknown>,
|
|
298
|
+
): Promise<Record<string, unknown>[]> {
|
|
299
|
+
this.ensureGraphqliteLoaded();
|
|
300
|
+
const normalized = this.normalizeCypher(query);
|
|
301
|
+
const stmt = params
|
|
302
|
+
? this.db.prepare("SELECT cypher(?, ?) AS result")
|
|
303
|
+
: this.db.prepare("SELECT cypher(?) AS result");
|
|
304
|
+
const args = params ? [normalized, JSON.stringify(params)] : [normalized];
|
|
305
|
+
const result = stmt.all(...args) as { result: string | null }[];
|
|
306
|
+
return this.parseCypherRows(result);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async queryViewNodes(
|
|
310
|
+
query: string,
|
|
311
|
+
params?: Record<string, unknown>,
|
|
312
|
+
): Promise<Node[]> {
|
|
313
|
+
const rows = await this.cypher(query, params);
|
|
314
|
+
const ids = this.extractNodeIds(rows);
|
|
315
|
+
if (ids.length === 0) return [];
|
|
316
|
+
return this.getNodesByIds(ids);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async getNodesByIds(ids: string[]): Promise<Node[]> {
|
|
320
|
+
if (ids.length === 0) return [];
|
|
321
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
322
|
+
const stmt = this.db.prepare(
|
|
323
|
+
`SELECT * FROM hc_nodes WHERE id IN (${placeholders})`,
|
|
324
|
+
);
|
|
325
|
+
const result = stmt.all(...ids) as Record<string, unknown>[];
|
|
326
|
+
const byId = new Map(
|
|
327
|
+
result.map((row) => [
|
|
328
|
+
row["id"] as string,
|
|
329
|
+
{
|
|
330
|
+
id: row["id"] as string,
|
|
331
|
+
type: row["type"] as string,
|
|
332
|
+
attrs: JSON.parse(row["attrs"] as string),
|
|
333
|
+
syncedAt: row["synced_at"] as number | undefined,
|
|
334
|
+
versionToken: row["version_token"] as string | undefined,
|
|
335
|
+
cursor: row["cursor"] as string | undefined,
|
|
336
|
+
},
|
|
337
|
+
]),
|
|
338
|
+
);
|
|
339
|
+
return ids.map((id) => byId.get(id)).filter(Boolean) as Node[];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async upsertNode(node: Node): Promise<void> {
|
|
343
|
+
const stmt = this.db.prepare(
|
|
344
|
+
`INSERT INTO hc_nodes (id, type, attrs, synced_at, version_token, cursor)
|
|
345
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
346
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
347
|
+
type = excluded.type,
|
|
348
|
+
attrs = excluded.attrs,
|
|
349
|
+
synced_at = excluded.synced_at,
|
|
350
|
+
version_token = excluded.version_token,
|
|
351
|
+
cursor = excluded.cursor`,
|
|
352
|
+
);
|
|
353
|
+
stmt.run(
|
|
354
|
+
node.id,
|
|
355
|
+
node.type,
|
|
356
|
+
JSON.stringify(node.attrs),
|
|
357
|
+
node.syncedAt ?? null,
|
|
358
|
+
node.versionToken ?? null,
|
|
359
|
+
node.cursor ?? null,
|
|
360
|
+
);
|
|
361
|
+
await this.upsertGraphNode(node);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async upsertNodes(nodes: Node[]): Promise<void> {
|
|
365
|
+
if (nodes.length === 0) return;
|
|
366
|
+
const stmt = this.db.prepare(
|
|
367
|
+
`INSERT INTO hc_nodes (id, type, attrs, synced_at, version_token, cursor)
|
|
368
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
369
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
370
|
+
type = excluded.type,
|
|
371
|
+
attrs = excluded.attrs,
|
|
372
|
+
synced_at = excluded.synced_at,
|
|
373
|
+
version_token = excluded.version_token,
|
|
374
|
+
cursor = excluded.cursor`,
|
|
375
|
+
);
|
|
376
|
+
const insertMany = this.db.transaction((nodes: Node[]) => {
|
|
377
|
+
for (const node of nodes) {
|
|
378
|
+
stmt.run(
|
|
379
|
+
node.id,
|
|
380
|
+
node.type,
|
|
381
|
+
JSON.stringify(node.attrs),
|
|
382
|
+
node.syncedAt ?? null,
|
|
383
|
+
node.versionToken ?? null,
|
|
384
|
+
node.cursor ?? null,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
insertMany(nodes);
|
|
389
|
+
for (const node of nodes) {
|
|
390
|
+
await this.upsertGraphNode(node);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async getNode(id: string): Promise<Node | null> {
|
|
395
|
+
const stmt = this.db.prepare("SELECT * FROM hc_nodes WHERE id = ?");
|
|
396
|
+
const result = stmt.all(id) as Record<string, unknown>[];
|
|
397
|
+
if (result.length === 0) return null;
|
|
398
|
+
const row = result[0]!;
|
|
399
|
+
return {
|
|
400
|
+
id: row["id"] as string,
|
|
401
|
+
type: row["type"] as string,
|
|
402
|
+
attrs: JSON.parse(row["attrs"] as string),
|
|
403
|
+
syncedAt: row["synced_at"] as number | undefined,
|
|
404
|
+
versionToken: row["version_token"] as string | undefined,
|
|
405
|
+
cursor: row["cursor"] as string | undefined,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async queryNodes(type?: string): Promise<Node[]> {
|
|
410
|
+
const sql = type
|
|
411
|
+
? "SELECT * FROM hc_nodes WHERE type = ?"
|
|
412
|
+
: "SELECT * FROM hc_nodes";
|
|
413
|
+
const stmt = this.db.prepare(sql);
|
|
414
|
+
const result = type ? stmt.all(type) : stmt.all();
|
|
415
|
+
return (result as Record<string, unknown>[]).map((row) => ({
|
|
416
|
+
id: row["id"] as string,
|
|
417
|
+
type: row["type"] as string,
|
|
418
|
+
attrs: JSON.parse(row["attrs"] as string),
|
|
419
|
+
syncedAt: row["synced_at"] as number | undefined,
|
|
420
|
+
versionToken: row["version_token"] as string | undefined,
|
|
421
|
+
cursor: row["cursor"] as string | undefined,
|
|
422
|
+
}));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async deleteNode(id: string): Promise<void> {
|
|
426
|
+
const deleteEdgesStmt = this.db.prepare(
|
|
427
|
+
"DELETE FROM hc_edges WHERE from_id = ? OR to_id = ?",
|
|
428
|
+
);
|
|
429
|
+
deleteEdgesStmt.run(id, id);
|
|
430
|
+
|
|
431
|
+
const deleteNodeStmt = this.db.prepare("DELETE FROM hc_nodes WHERE id = ?");
|
|
432
|
+
deleteNodeStmt.run(id);
|
|
433
|
+
|
|
434
|
+
await this.deleteGraphNode(id);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async upsertEdge(edge: Edge): Promise<void> {
|
|
438
|
+
const stmt = this.db.prepare(
|
|
439
|
+
`INSERT INTO hc_edges (type, from_id, to_id, attrs)
|
|
440
|
+
VALUES (?, ?, ?, ?)
|
|
441
|
+
ON CONFLICT(type, from_id, to_id) DO UPDATE SET
|
|
442
|
+
attrs = excluded.attrs`,
|
|
443
|
+
);
|
|
444
|
+
stmt.run(
|
|
445
|
+
edge.type,
|
|
446
|
+
edge.fromId,
|
|
447
|
+
edge.toId,
|
|
448
|
+
edge.attrs ? JSON.stringify(edge.attrs) : null,
|
|
449
|
+
);
|
|
450
|
+
await this.upsertGraphEdge(edge);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async upsertEdges(edges: Edge[]): Promise<void> {
|
|
454
|
+
if (edges.length === 0) return;
|
|
455
|
+
const stmt = this.db.prepare(
|
|
456
|
+
`INSERT INTO hc_edges (type, from_id, to_id, attrs)
|
|
457
|
+
VALUES (?, ?, ?, ?)
|
|
458
|
+
ON CONFLICT(type, from_id, to_id) DO UPDATE SET
|
|
459
|
+
attrs = excluded.attrs`,
|
|
460
|
+
);
|
|
461
|
+
const insertMany = this.db.transaction((edges: Edge[]) => {
|
|
462
|
+
for (const edge of edges) {
|
|
463
|
+
stmt.run(
|
|
464
|
+
edge.type,
|
|
465
|
+
edge.fromId,
|
|
466
|
+
edge.toId,
|
|
467
|
+
edge.attrs ? JSON.stringify(edge.attrs) : null,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
insertMany(edges);
|
|
472
|
+
for (const edge of edges) {
|
|
473
|
+
await this.upsertGraphEdge(edge);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async getEdges(
|
|
478
|
+
fromId?: string,
|
|
479
|
+
toId?: string,
|
|
480
|
+
type?: string,
|
|
481
|
+
): Promise<Edge[]> {
|
|
482
|
+
const conditions: string[] = [];
|
|
483
|
+
const args: (string | null)[] = [];
|
|
484
|
+
|
|
485
|
+
if (fromId) {
|
|
486
|
+
conditions.push("from_id = ?");
|
|
487
|
+
args.push(fromId);
|
|
488
|
+
}
|
|
489
|
+
if (toId) {
|
|
490
|
+
conditions.push("to_id = ?");
|
|
491
|
+
args.push(toId);
|
|
492
|
+
}
|
|
493
|
+
if (type) {
|
|
494
|
+
conditions.push("type = ?");
|
|
495
|
+
args.push(type);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const where =
|
|
499
|
+
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
500
|
+
const stmt = this.db.prepare(`SELECT * FROM hc_edges ${where}`);
|
|
501
|
+
const result = stmt.all(...args) as Record<string, unknown>[];
|
|
502
|
+
|
|
503
|
+
return result.map((row) => ({
|
|
504
|
+
id: row["id"] as number,
|
|
505
|
+
type: row["type"] as string,
|
|
506
|
+
fromId: row["from_id"] as string,
|
|
507
|
+
toId: row["to_id"] as string,
|
|
508
|
+
attrs: row["attrs"] ? JSON.parse(row["attrs"] as string) : undefined,
|
|
509
|
+
}));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async deleteEdge(fromId: string, toId: string, type: string): Promise<void> {
|
|
513
|
+
const stmt = this.db.prepare(
|
|
514
|
+
"DELETE FROM hc_edges WHERE from_id = ? AND to_id = ? AND type = ?",
|
|
515
|
+
);
|
|
516
|
+
stmt.run(fromId, toId, type);
|
|
517
|
+
await this.deleteGraphEdge(fromId, toId, type);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private escapeCypherString(value: string): string {
|
|
521
|
+
// Escape backslashes and single quotes for Cypher string literals
|
|
522
|
+
return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private async upsertGraphNode(node: Node): Promise<void> {
|
|
526
|
+
const label = this.escapeCypherType(node.type);
|
|
527
|
+
// GraphQLite doesn't support parameterized properties in MERGE patterns,
|
|
528
|
+
// so we must inline the node_id with proper escaping
|
|
529
|
+
const escapedNodeId = this.escapeCypherString(node.id);
|
|
530
|
+
|
|
531
|
+
// Flatten top-level attrs for graph storage - GraphQLite doesn't support nested objects
|
|
532
|
+
const flatAttrs: Record<string, string | number | boolean | null> = {
|
|
533
|
+
node_id: node.id,
|
|
534
|
+
node_type: node.type,
|
|
535
|
+
};
|
|
536
|
+
if (node.attrs && typeof node.attrs === "object") {
|
|
537
|
+
for (const [key, value] of Object.entries(node.attrs)) {
|
|
538
|
+
if (value === null || value === undefined) {
|
|
539
|
+
flatAttrs[key] = null;
|
|
540
|
+
} else if (
|
|
541
|
+
typeof value === "string" ||
|
|
542
|
+
typeof value === "number" ||
|
|
543
|
+
typeof value === "boolean"
|
|
544
|
+
) {
|
|
545
|
+
flatAttrs[key] = value;
|
|
546
|
+
} else if (Array.isArray(value)) {
|
|
547
|
+
flatAttrs[key] = JSON.stringify(value);
|
|
548
|
+
} else {
|
|
549
|
+
flatAttrs[key] = JSON.stringify(value);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// Build SET clause with explicit property assignments
|
|
554
|
+
const setClause = Object.keys(flatAttrs)
|
|
555
|
+
.map((k) => `n.${k} = $${k}`)
|
|
556
|
+
.join(", ");
|
|
557
|
+
// GraphQLite doesn't support plain SET after MERGE, but ON CREATE/MATCH SET works
|
|
558
|
+
// Use inline escaped value in MERGE pattern (GraphQLite limitation with params in patterns)
|
|
559
|
+
await this.cypher(
|
|
560
|
+
`MERGE (n:${label} {node_id: '${escapedNodeId}'}) ON CREATE SET ${setClause} ON MATCH SET ${setClause}`,
|
|
561
|
+
flatAttrs,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private async upsertGraphEdge(edge: Edge): Promise<void> {
|
|
566
|
+
const relType = this.escapeCypherType(edge.type);
|
|
567
|
+
// GraphQLite doesn't support parameterized MATCH patterns, use inline escaped values
|
|
568
|
+
const escapedFromId = this.escapeCypherString(edge.fromId);
|
|
569
|
+
const escapedToId = this.escapeCypherString(edge.toId);
|
|
570
|
+
// Note: We match on node_id (our flattened property), not id
|
|
571
|
+
await this.cypher(
|
|
572
|
+
`MATCH (a {node_id: '${escapedFromId}'}), (b {node_id: '${escapedToId}'}) MERGE (a)-[r:${relType}]->(b)`,
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private async deleteGraphNode(id: string): Promise<void> {
|
|
577
|
+
const escapedId = this.escapeCypherString(id);
|
|
578
|
+
await this.cypher(`MATCH (n {node_id: '${escapedId}'}) DETACH DELETE n`);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private async deleteGraphEdge(
|
|
582
|
+
fromId: string,
|
|
583
|
+
toId: string,
|
|
584
|
+
type: string,
|
|
585
|
+
): Promise<void> {
|
|
586
|
+
const relType = this.escapeCypherType(type);
|
|
587
|
+
const escapedFromId = this.escapeCypherString(fromId);
|
|
588
|
+
const escapedToId = this.escapeCypherString(toId);
|
|
589
|
+
await this.cypher(
|
|
590
|
+
`MATCH (a {node_id: '${escapedFromId}'})-[r:${relType}]->(b {node_id: '${escapedToId}'}) DELETE r`,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async close(): Promise<void> {
|
|
595
|
+
this.db.close();
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Keep backward compatibility alias
|
|
600
|
+
export { HardcopyDatabase as Database };
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFile } from "fs/promises";
|
|
2
|
+
|
|
3
|
+
export async function loadEnvFile(path: string): Promise<void> {
|
|
4
|
+
let content: string;
|
|
5
|
+
try {
|
|
6
|
+
content = await readFile(path, "utf-8");
|
|
7
|
+
} catch {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
12
|
+
const line = rawLine.trim();
|
|
13
|
+
if (!line || line.startsWith("#")) continue;
|
|
14
|
+
const index = line.indexOf("=");
|
|
15
|
+
if (index <= 0) continue;
|
|
16
|
+
|
|
17
|
+
const key = line.slice(0, index).trim();
|
|
18
|
+
const rawValue = line.slice(index + 1).trim();
|
|
19
|
+
if (!key) continue;
|
|
20
|
+
if (process.env[key] !== undefined) continue;
|
|
21
|
+
|
|
22
|
+
process.env[key] = stripQuotes(rawValue);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function stripQuotes(value: string): string {
|
|
27
|
+
if (
|
|
28
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
29
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
30
|
+
) {
|
|
31
|
+
return value.slice(1, -1);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Node } from "./types";
|
|
2
|
+
import matter from "gray-matter";
|
|
3
|
+
|
|
4
|
+
export interface ParsedFile {
|
|
5
|
+
attrs: Record<string, unknown>;
|
|
6
|
+
body: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface FormatHandler {
|
|
10
|
+
type: string;
|
|
11
|
+
editableFields: string[];
|
|
12
|
+
render(node: Node): string;
|
|
13
|
+
parse(content: string): ParsedFile;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const handlers = new Map<string, FormatHandler>();
|
|
17
|
+
|
|
18
|
+
export function registerFormat(handler: FormatHandler): void {
|
|
19
|
+
handlers.set(handler.type, handler);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getFormat(type: string): FormatHandler | undefined {
|
|
23
|
+
return handlers.get(type);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function listFormats(): string[] {
|
|
27
|
+
return Array.from(handlers.keys());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function renderNode(node: Node, template?: string): string {
|
|
31
|
+
if (template) {
|
|
32
|
+
return renderTemplate(template, node);
|
|
33
|
+
}
|
|
34
|
+
const handler = handlers.get(node.type);
|
|
35
|
+
if (!handler) {
|
|
36
|
+
throw new Error(`No format handler for type: ${node.type}`);
|
|
37
|
+
}
|
|
38
|
+
return handler.render(node);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function parseFile(content: string, type: string): ParsedFile {
|
|
42
|
+
const handler = handlers.get(type);
|
|
43
|
+
if (!handler) {
|
|
44
|
+
return parseGeneric(content);
|
|
45
|
+
}
|
|
46
|
+
return handler.parse(content);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseGeneric(content: string): ParsedFile {
|
|
50
|
+
const { data, content: body } = matter(content);
|
|
51
|
+
return { attrs: data, body: body.trim() };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderTemplate(template: string, node: Node): string {
|
|
55
|
+
return template.replace(/\{\{([^}]+)\}\}/g, (_, path: string) => {
|
|
56
|
+
const value = resolvePath(
|
|
57
|
+
node as unknown as Record<string, unknown>,
|
|
58
|
+
path.trim(),
|
|
59
|
+
);
|
|
60
|
+
return value?.toString() ?? "";
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolvePath(obj: Record<string, unknown>, path: string): unknown {
|
|
65
|
+
const parts = path.split(".");
|
|
66
|
+
let current: unknown = obj;
|
|
67
|
+
for (const part of parts) {
|
|
68
|
+
if (current === null || current === undefined) return undefined;
|
|
69
|
+
current = (current as Record<string, unknown>)[part];
|
|
70
|
+
}
|
|
71
|
+
return current;
|
|
72
|
+
}
|