cypher.ts raw
1 #!/usr/bin/env bun
2 /**
3 * cypher.ts — Interactive Cypher query tool for ORLY's Neo4j backend.
4 *
5 * Connect to a Neo4j instance (local or remote bolt+s) and run Cypher
6 * queries against the Nostr event graph.
7 *
8 * Usage:
9 * bun run cypher.ts # interactive REPL
10 * bun run cypher.ts --uri "bolt+s://relay.example.com:7687" # remote
11 * echo "MATCH (e:Event) RETURN count(e)" | bun run cypher.ts # piped
12 * bun run cypher.ts --example kinds # run built-in example
13 * bun run cypher.ts --file my_query.cypher # run from file
14 */
15
16 import neo4j, { type Record as Neo4jRecord } from "neo4j-driver";
17 import { createInterface } from "readline";
18 import { parseArgs } from "util";
19
20 // ── Built-in example queries ────────────────────────────────────
21 const EXAMPLES: Record<string, { name: string; description: string; query: string }> = {
22 count: {
23 name: "Count everything",
24 description: "How many events are in the database?",
25 query: "MATCH (e:Event) RETURN count(e) AS events",
26 },
27 kinds: {
28 name: "Event kind distribution",
29 description: "What kinds of Nostr events are stored?",
30 query: `
31 MATCH (e:Event)
32 RETURN e.kind AS kind, count(e) AS total
33 ORDER BY total DESC LIMIT 15`,
34 },
35 "top-authors": {
36 name: "Most active authors",
37 description: "Who has published the most events?",
38 query: `
39 MATCH (e:Event)-[:AUTHORED_BY]->(a:NostrUser)
40 RETURN a.pubkey AS pubkey, count(e) AS events
41 ORDER BY events DESC LIMIT 10`,
42 },
43 "popular-tags": {
44 name: "Popular hashtags",
45 description: "Which #t hashtags are used most often?",
46 query: `
47 MATCH (e:Event)-[:TAGGED_WITH]->(t:Tag {type: 't'})
48 RETURN t.value AS hashtag, count(e) AS usage
49 ORDER BY usage DESC LIMIT 20`,
50 },
51 "most-mentioned": {
52 name: "Most mentioned users",
53 description: "Which pubkeys get mentioned (p-tagged) the most?",
54 query: `
55 MATCH (e:Event)-[:MENTIONS]->(u:NostrUser)
56 RETURN u.pubkey AS pubkey, count(e) AS mentions
57 ORDER BY mentions DESC LIMIT 10`,
58 },
59 "reply-chains": {
60 name: "Reply chain depth",
61 description: "How deep do reply chains go? (max 5 hops)",
62 query: `
63 MATCH path = (reply:Event)-[:REFERENCES*1..5]->(root:Event)
64 WHERE NOT (root)-[:REFERENCES]->()
65 RETURN length(path) AS depth, count(path) AS chains
66 ORDER BY depth`,
67 },
68 "social-graph": {
69 name: "Follow graph (kind 3 contact lists)",
70 description: "Who follows the most people?",
71 query: `
72 MATCH (e:Event {kind: 3})-[:AUTHORED_BY]->(follower:NostrUser)
73 MATCH (e)-[:MENTIONS]->(followed:NostrUser)
74 RETURN follower.pubkey AS follower,
75 count(DISTINCT followed) AS following
76 ORDER BY following DESC LIMIT 10`,
77 },
78 recent: {
79 name: "Most recent events",
80 description: "The newest events by created_at timestamp.",
81 query: `
82 MATCH (e:Event)-[:AUTHORED_BY]->(a:NostrUser)
83 RETURN e.id AS id,
84 e.kind AS kind,
85 a.pubkey AS author,
86 e.created_at AS timestamp,
87 substring(e.content, 0, 60) AS preview
88 ORDER BY e.created_at DESC LIMIT 10`,
89 },
90 schema: {
91 name: "Database labels",
92 description: "What node types exist in the graph?",
93 query: "CALL db.labels() YIELD label RETURN label ORDER BY label",
94 },
95 relationships: {
96 name: "Relationship types and counts",
97 description: "What relationship types exist and how many of each?",
98 query: `
99 MATCH ()-[r]->()
100 RETURN type(r) AS relationship, count(r) AS total
101 ORDER BY total DESC`,
102 },
103 };
104
105 // ── Formatting ──────────────────────────────────────────────────
106 function formatValue(val: any): string {
107 if (val === null || val === undefined) return "<null>";
108 // neo4j integers
109 if (neo4j.isInt(val)) return val.toString();
110 if (Array.isArray(val) || typeof val === "object") {
111 return JSON.stringify(val);
112 }
113 const s = String(val);
114 return s.length > 80 ? s.slice(0, 77) + "..." : s;
115 }
116
117 function printResults(records: Neo4jRecord[]) {
118 if (!records.length) {
119 console.log(" (no results)");
120 return;
121 }
122
123 const keys = records[0].keys;
124 if (!keys.length) {
125 console.log(" (empty result set)");
126 return;
127 }
128
129 const rows = records.map((r) => keys.map((k) => formatValue(r.get(k))));
130 const widths = keys.map((k, i) => {
131 let w = k.length;
132 for (const row of rows) w = Math.max(w, row[i].length);
133 return Math.min(w, 60);
134 });
135
136 const header = keys.map((k, i) => String(k).padEnd(widths[i])).join(" | ");
137 const sep = widths.map((w) => "-".repeat(w)).join("-+-");
138 console.log(` ${header}`);
139 console.log(` ${sep}`);
140
141 for (const row of rows) {
142 const cells = row.map((cell, i) => {
143 if (cell.length > widths[i]) cell = cell.slice(0, widths[i] - 3) + "...";
144 return cell.padEnd(widths[i]);
145 });
146 console.log(` ${cells.join(" | ")}`);
147 }
148 console.log(`\n (${records.length} row${records.length !== 1 ? "s" : ""})`);
149 }
150
151 // ── Query execution ─────────────────────────────────────────────
152 async function runQuery(session: neo4j.Session, query: string) {
153 query = query.trim();
154 if (!query) return;
155
156 const t0 = Date.now();
157 try {
158 const result = await session.run(query);
159 printResults(result.records);
160 } catch (e: any) {
161 const msg = e.message || String(e);
162 if (msg.includes("SyntaxError") || msg.includes("Invalid input")) {
163 console.log(`\n Syntax error: ${msg}`);
164 console.log(" Tip: Labels are case-sensitive — use Event, not event.");
165 } else if (msg.includes("write") || msg.includes("read only")) {
166 console.log(`\n Write error: ${msg}`);
167 console.log(" Tip: Use --write to allow write queries.");
168 } else {
169 console.log(`\n Error: ${msg}`);
170 }
171 return;
172 }
173 const elapsed = ((Date.now() - t0) / 1000).toFixed(3);
174 console.log(` Query took ${elapsed}s`);
175 }
176
177 // ── CLI argument parsing ────────────────────────────────────────
178 const { values: cliArgs } = parseArgs({
179 options: {
180 uri: { type: "string", default: "bolt://localhost:7687" },
181 user: { type: "string", default: "neo4j" },
182 password: { type: "string", default: "nostr-demo-2024" },
183 write: { type: "boolean", default: false },
184 file: { type: "string", short: "f" },
185 example: { type: "string", short: "e" },
186 "list-examples": { type: "boolean", default: false },
187 help: { type: "boolean", default: false },
188 },
189 });
190
191 function showExamples() {
192 console.log("\n Built-in example queries:");
193 console.log(" " + "=".repeat(60));
194 for (const [key, ex] of Object.entries(EXAMPLES)) {
195 console.log(`\n [${key}] ${ex.name}`);
196 console.log(` ${ex.description}`);
197 }
198 console.log(`\n Run one: bun run cypher.ts --example kinds`);
199 console.log(` Or in REPL: type 'run kinds'\n`);
200 }
201
202 function showHelp() {
203 console.log(`
204 cypher.ts — Interactive Cypher query tool for ORLY Neo4j
205
206 Usage:
207 bun run cypher.ts [options]
208
209 Options:
210 --uri <uri> Neo4j bolt URI (default: bolt://localhost:7687)
211 --user <user> Username (default: neo4j)
212 --password <pass> Password (default: nostr-demo-2024)
213 --write Allow write queries (default: read-only)
214 --file, -f <path> Run query from a .cypher file
215 --example, -e <name> Run a built-in example
216 --list-examples List built-in examples and exit
217 --help Show this help
218
219 Interactive commands:
220 examples List built-in example queries
221 run <name> Run a built-in example
222 show <name> Print an example query without running it
223 help Show interactive help
224 quit Exit
225 `);
226 }
227
228 // ── Interactive REPL ────────────────────────────────────────────
229 async function repl(session: neo4j.Session) {
230 const mode = cliArgs.write ? "read-write" : "read-only";
231 console.log(`
232 ┌──────────────────────────────────────────────┐
233 │ ORLY Cypher Bridge — Interactive Query Tool │
234 │ │
235 │ Mode: ${mode.padEnd(10)} │
236 │ Type 'help' for commands │
237 │ Type 'examples' for sample queries │
238 │ Type 'quit' to exit │
239 └──────────────────────────────────────────────┘
240 `);
241
242 const rl = createInterface({
243 input: process.stdin,
244 output: process.stdout,
245 terminal: process.stdin.isTTY ?? false,
246 });
247
248 let buffer: string[] = [];
249 let prompt = "cypher> ";
250
251 const askLine = () =>
252 new Promise<string | null>((resolve) => {
253 rl.question(prompt, (answer) => resolve(answer));
254 rl.once("close", () => resolve(null));
255 });
256
257 while (true) {
258 const line = await askLine();
259 if (line === null) {
260 console.log("\nBye!");
261 break;
262 }
263
264 const stripped = line.trim();
265
266 // Handle commands when buffer is empty
267 if (!buffer.length) {
268 const lower = stripped.toLowerCase();
269
270 if (lower === "quit" || lower === "exit" || lower === "q") {
271 console.log("Bye!");
272 break;
273 }
274 if (lower === "help") {
275 showHelp();
276 continue;
277 }
278 if (lower === "examples") {
279 showExamples();
280 continue;
281 }
282 if (lower.startsWith("run ")) {
283 const name = stripped.slice(4).trim();
284 const ex = EXAMPLES[name];
285 if (ex) {
286 console.log(`\n Running: ${ex.name}`);
287 console.log(` ${ex.description}\n`);
288 await runQuery(session, ex.query);
289 console.log();
290 } else {
291 console.log(` Unknown example: '${name}'. Type 'examples' to see available ones.`);
292 }
293 continue;
294 }
295 if (lower.startsWith("show ")) {
296 const name = stripped.slice(5).trim();
297 const ex = EXAMPLES[name];
298 if (ex) {
299 console.log(`\n [${name}] ${ex.name}`);
300 console.log(` ${ex.description}\n`);
301 for (const qline of ex.query.trim().split("\n")) {
302 console.log(` ${qline.trim()}`);
303 }
304 console.log();
305 } else {
306 console.log(` Unknown example: '${name}'.`);
307 }
308 continue;
309 }
310 if (!stripped) continue;
311 }
312
313 // Accumulate multi-line queries
314 buffer.push(line);
315 const full = buffer.join("\n");
316
317 // Query is complete if it ends with ; or is a single short statement
318 const MULTILINE_STARTERS = [
319 "MATCH", "WITH", "OPTIONAL", "CALL", "UNWIND",
320 "CREATE", "MERGE", "DELETE", "SET", "REMOVE", "FOREACH", "LOAD",
321 ];
322 const isMultilineStart = MULTILINE_STARTERS.some((kw) =>
323 stripped.toUpperCase().startsWith(kw)
324 );
325
326 if (
327 stripped.endsWith(";") ||
328 (buffer.length === 1 && !isMultilineStart)
329 ) {
330 const query = full.replace(/;\s*$/, "");
331 console.log();
332 await runQuery(session, query);
333 console.log();
334 buffer = [];
335 prompt = "cypher> ";
336 } else {
337 prompt = " ...> ";
338 }
339 }
340
341 rl.close();
342 }
343
344 // ── Main ────────────────────────────────────────────────────────
345 async function main() {
346 if (cliArgs.help) {
347 showHelp();
348 process.exit(0);
349 }
350
351 if (cliArgs["list-examples"]) {
352 showExamples();
353 process.exit(0);
354 }
355
356 // Connect
357 let driver: neo4j.Driver;
358 try {
359 driver = neo4j.driver(
360 cliArgs.uri!,
361 neo4j.auth.basic(cliArgs.user!, cliArgs.password!)
362 );
363 await driver.verifyConnectivity();
364 } catch (e: any) {
365 console.error(`Error: Cannot connect to Neo4j at ${cliArgs.uri}`);
366 console.error(` ${e.message}`);
367 console.error();
368 console.error("Troubleshooting:");
369 console.error(" 1. Is Neo4j running? docker ps | grep neo4j");
370 console.error(" 2. Correct URI? bolt://localhost:7687 (local)");
371 console.error(" bolt+s://relay.example.com:7687 (remote)");
372 console.error(" 3. Correct password? Check ORLY_NEO4J_PASSWORD");
373 process.exit(1);
374 }
375
376 const session = driver.session();
377
378 try {
379 // --example: run a built-in example
380 if (cliArgs.example) {
381 const ex = EXAMPLES[cliArgs.example];
382 if (!ex) {
383 console.error(`Unknown example: '${cliArgs.example}'`);
384 console.error(`Available: ${Object.keys(EXAMPLES).join(", ")}`);
385 process.exit(1);
386 }
387 console.log(`\n ${ex.name}: ${ex.description}\n`);
388 await runQuery(session, ex.query);
389 console.log();
390 return;
391 }
392
393 // --file: run from .cypher file
394 if (cliArgs.file) {
395 const file = Bun.file(cliArgs.file);
396 if (!(await file.exists())) {
397 console.error(`File not found: ${cliArgs.file}`);
398 process.exit(1);
399 }
400 const query = await file.text();
401 console.log(` Running query from ${cliArgs.file}\n`);
402 await runQuery(session, query);
403 console.log();
404 return;
405 }
406
407 // Piped input
408 if (!process.stdin.isTTY) {
409 const chunks: Buffer[] = [];
410 for await (const chunk of process.stdin) {
411 chunks.push(chunk as Buffer);
412 }
413 const query = Buffer.concat(chunks).toString().trim();
414 if (query) {
415 await runQuery(session, query);
416 console.log();
417 }
418 return;
419 }
420
421 // Interactive REPL
422 await repl(session);
423 } finally {
424 await session.close();
425 await driver.close();
426 }
427 }
428
429 main();
430