@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.
Files changed (53) hide show
  1. package/.eslintrc.json +22 -0
  2. package/.github/workflows/publish.yml +41 -0
  3. package/.prettierignore +17 -0
  4. package/LICENSE +21 -0
  5. package/README.md +183 -0
  6. package/dist/cli.d.ts +1 -0
  7. package/dist/cli.js +2950 -0
  8. package/dist/index.d.ts +406 -0
  9. package/dist/index.js +2737 -0
  10. package/dist/mcp-server.d.ts +7 -0
  11. package/dist/mcp-server.js +2665 -0
  12. package/docs/research/crdt.md +777 -0
  13. package/docs/research/github-issues.md +684 -0
  14. package/docs/research/gql.md +876 -0
  15. package/docs/research/index.md +19 -0
  16. package/docs/specs/conflict-resolution.md +1254 -0
  17. package/docs/specs/hardcopy.md +742 -0
  18. package/docs/specs/patchwork-integration.md +227 -0
  19. package/docs/specs/plugin-architecture.md +747 -0
  20. package/mcp.json +8 -0
  21. package/package.json +64 -0
  22. package/scripts/install-graphqlite.ts +156 -0
  23. package/src/cli.ts +356 -0
  24. package/src/config.ts +104 -0
  25. package/src/conflict-store.ts +136 -0
  26. package/src/conflict.ts +147 -0
  27. package/src/crdt.ts +100 -0
  28. package/src/db.ts +600 -0
  29. package/src/env.ts +34 -0
  30. package/src/format.ts +72 -0
  31. package/src/formats/github-issue.ts +55 -0
  32. package/src/hardcopy/core.ts +78 -0
  33. package/src/hardcopy/diff.ts +188 -0
  34. package/src/hardcopy/index.ts +67 -0
  35. package/src/hardcopy/init.ts +24 -0
  36. package/src/hardcopy/push.ts +444 -0
  37. package/src/hardcopy/sync.ts +37 -0
  38. package/src/hardcopy/types.ts +49 -0
  39. package/src/hardcopy/views.ts +199 -0
  40. package/src/hardcopy.ts +1 -0
  41. package/src/index.ts +13 -0
  42. package/src/llm-merge.ts +109 -0
  43. package/src/mcp-server.ts +388 -0
  44. package/src/merge.ts +75 -0
  45. package/src/provider.ts +40 -0
  46. package/src/providers/a2a/index.ts +166 -0
  47. package/src/providers/git/index.ts +212 -0
  48. package/src/providers/github/index.ts +236 -0
  49. package/src/providers/github/issues.ts +66 -0
  50. package/src/providers.ts +7 -0
  51. package/src/types.ts +101 -0
  52. package/tsconfig.json +21 -0
  53. 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
+ }