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