diff --git a/README.md b/README.md index 7d6d5f9..05379b5 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,29 @@ paragraph user get paragraph user get 0x1234... # by wallet address ``` +### Analytics + +Run read-only SQL against your publication's analytics schema (open rates, CTR, subscriber counts, post views, engagement, etc.). Queries are scoped to your publication automatically -- no blog ID filter needed. + +```bash +# Discover available tables and columns +paragraph analytics schema + +# One-liner +paragraph analytics query "SELECT active_subscriber_count FROM blog_subscriber_counts" + +# From a file (useful for multi-line queries) +paragraph analytics query --file ./top-posts.sql + +# Piped from stdin +cat query.sql | paragraph analytics query + +# JSON + jq +paragraph analytics query "SELECT title, open_rate FROM post_analytics_summary LIMIT 5" --json | jq '.rows' +``` + +Rules: `SELECT` / `WITH` (CTE) only, no semicolons, 30-second timeout, 10,000-row cap. Prefer the pre-aggregated views (`post_analytics_summary`, `subscriber_engagement_scores`, `blog_subscriber_counts`) over raw tables for speed. + ## Interactive TUI Running `paragraph` with no arguments launches an interactive terminal UI with menus, scrollable lists, and keyboard navigation. diff --git a/package-lock.json b/package-lock.json index 4cc4878..cd4a514 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@inkjs/ui": "^2.0.0", - "@paragraph-com/sdk": "^2.0.1", + "@paragraph-com/sdk": "^2.0.2", "cli-table3": "^0.6.5", "commander": "^12.0.0", "ink": "^5.1.0", @@ -255,9 +255,9 @@ } }, "node_modules/@paragraph-com/sdk": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@paragraph-com/sdk/-/sdk-2.0.1.tgz", - "integrity": "sha512-Z/MrZtO2whapD3Qff8uHWnrSLq58KxdYdibQo0ywkb/qBSLv6NZWq3BUHO5DKqs0A4yDtu4d7MAMSd63+4bk5Q==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@paragraph-com/sdk/-/sdk-2.0.2.tgz", + "integrity": "sha512-qaPOLsLobJ30A03lVln/LWyHXtqzASpwvnXm+ZUEJBdMg9G7e4W1OjGLjPGidJI1AJinsXhA93hYca0uswfhYw==", "peerDependencies": { "@whetstone-research/doppler-sdk": "^0.0.1-alpha.62", "doppler-router": "^1.0.15", diff --git a/package.json b/package.json index 6222ae9..121d2a4 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ }, "dependencies": { "@inkjs/ui": "^2.0.0", - "@paragraph-com/sdk": "^2.0.1", + "@paragraph-com/sdk": "^2.0.2", "cli-table3": "^0.6.5", "commander": "^12.0.0", "ink": "^5.1.0", diff --git a/src/cli/commands/analytics.ts b/src/cli/commands/analytics.ts new file mode 100644 index 0000000..c2c89b3 --- /dev/null +++ b/src/cli/commands/analytics.ts @@ -0,0 +1,140 @@ +import * as fs from "fs"; +import { Command } from "commander"; +import { requireApiKey } from "../../services/auth.js"; +import * as analytics from "../../services/analytics.js"; +import { + outputTable, + writeInfo, + isJsonMode, +} from "../lib/output.js"; +import { handleError } from "../lib/error.js"; +import { readStdin } from "../lib/stdin.js"; + +async function resolveSql( + positional: string | undefined, + opts: { sql?: string; file?: string } +): Promise { + if (opts.sql) return opts.sql; + if (positional) return positional; + if (opts.file) { + if (!fs.existsSync(opts.file)) { + throw new Error( + `File not found: "${opts.file}". Check the path, or pass the SQL inline via --sql or as a positional argument.` + ); + } + return fs.readFileSync(opts.file, "utf-8"); + } + const stdin = await readStdin(); + return stdin?.trim() || undefined; +} + +function formatCell(value: unknown): string { + if (value === null) return "NULL"; + if (value === undefined) return ""; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +export function registerAnalyticsCommands(program: Command): void { + const root = program + .command("analytics") + .description("Run SQL queries against your publication's analytics"); + + root + .command("query [sql]") + .description( + "Run a read-only SQL query against your publication's analytics schema" + ) + .option("--sql ", "SQL query string") + .option("--file ", "Read SQL from a file") + .addHelpText( + "after", + ` +Examples: + $ paragraph analytics query "SELECT active_subscriber_count FROM blog_subscriber_counts" + $ paragraph analytics query --file ./top-posts.sql + $ cat query.sql | paragraph analytics query + $ paragraph analytics query "SELECT title, open_rate FROM post_analytics_summary LIMIT 5" --json | jq '.rows' + +Rules: + - SELECT / WITH (CTE) statements only + - Tables are scoped to your publication automatically + - No semicolons; 30-second timeout; 10,000-row cap + - Run \`paragraph analytics schema\` to discover tables and columns` + ) + .action(async function ( + this: Command, + positionalSql: string | undefined, + opts + ) { + try { + const apiKey = requireApiKey(); + const sql = await resolveSql(positionalSql, opts); + if (!sql) { + throw new Error( + "Provide a SQL query via positional argument, --sql, --file, or pipe to stdin." + ); + } + + const result = await analytics.runQuery(sql, apiKey); + + if (isJsonMode(this)) { + process.stdout.write(JSON.stringify(result, null, 2) + "\n"); + return; + } + + const headers = result.fields.map((f) => f.name); + const rows = result.rows.map((row) => + headers.map((h) => formatCell((row as Record)[h])) + ); + outputTable(this, headers, rows, result.rows); + + const rowLabel = result.rowCount === 1 ? "row" : "rows"; + const truncatedSuffix = result.truncated ? " (truncated at 10,000)" : ""; + writeInfo(`${result.rowCount} ${rowLabel} returned${truncatedSuffix}`); + } catch (err) { + handleError(err); + } + }); + + root + .command("schema") + .description( + "List tables and columns available in your publication's analytics schema" + ) + .addHelpText( + "after", + ` +Examples: + $ paragraph analytics schema + $ paragraph analytics schema --json | jq '.tables[] | select(.table_name == "post_analytics_summary")'` + ) + .action(async function (this: Command) { + try { + const apiKey = requireApiKey(); + const result = await analytics.getSchema(apiKey); + + if (isJsonMode(this)) { + process.stdout.write(JSON.stringify(result, null, 2) + "\n"); + return; + } + + const sorted = [...result.tables].sort((a, b) => { + const byTable = a.table_name.localeCompare(b.table_name); + return byTable !== 0 + ? byTable + : a.column_name.localeCompare(b.column_name); + }); + const headers = ["Table", "Column", "Type", "Nullable"]; + const rows = sorted.map((t) => [ + t.table_name, + t.column_name, + t.data_type, + t.is_nullable, + ]); + outputTable(this, headers, rows, sorted); + } catch (err) { + handleError(err); + } + }); +} diff --git a/src/cli/program.ts b/src/cli/program.ts index cd1595b..65a6873 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -6,6 +6,7 @@ import { registerSearchCommands } from "./commands/search.js"; import { registerSubscriberCommands } from "./commands/subscriber.js"; import { registerCoinCommands } from "./commands/coin.js"; import { registerUserCommands } from "./commands/user.js"; +import { registerAnalyticsCommands } from "./commands/analytics.js"; declare const process: NodeJS.Process & { env: { CLI_VERSION?: string } }; @@ -39,6 +40,7 @@ export function createProgram(): Command { registerSubscriberCommands(program); registerCoinCommands(program); registerUserCommands(program); + registerAnalyticsCommands(program); return program; } diff --git a/src/services/analytics.ts b/src/services/analytics.ts new file mode 100644 index 0000000..080c0eb --- /dev/null +++ b/src/services/analytics.ts @@ -0,0 +1,17 @@ +import { analyticsQueryBody } from "@paragraph-com/sdk/zod"; +import type { AnalyticsQuery200, AnalyticsSchema200 } from "@paragraph-com/sdk"; +import { createClient } from "./client.js"; + +export async function runQuery( + sql: string, + apiKey: string +): Promise { + analyticsQueryBody.parse({ sql }); + const client = createClient(apiKey); + return client.analytics.query({ sql }); +} + +export async function getSchema(apiKey: string): Promise { + const client = createClient(apiKey); + return client.analytics.schema(); +} diff --git a/test/cli.test.ts b/test/cli.test.ts index a9c0260..a7de651 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -28,6 +28,7 @@ describe("CLI program", () => { expect(names).toContain("subscriber"); expect(names).toContain("coin"); expect(names).toContain("user"); + expect(names).toContain("analytics"); }); it("registers top-level aliases for create, update, delete", () => { @@ -117,6 +118,30 @@ describe("CLI program", () => { }); }); + describe("analytics subcommands", () => { + it("registers query and schema subcommands", () => { + const analytics = program.commands.find((c) => c.name() === "analytics")!; + const names = analytics.commands.map((c) => c.name()); + expect(names).toContain("query"); + expect(names).toContain("schema"); + }); + + it("analytics query has --sql and --file flags", () => { + const analytics = program.commands.find((c) => c.name() === "analytics")!; + const query = analytics.commands.find((c) => c.name() === "query")!; + const opts = query.options.map((o) => o.long); + expect(opts).toContain("--sql"); + expect(opts).toContain("--file"); + }); + + it("analytics schema takes no required options", () => { + const analytics = program.commands.find((c) => c.name() === "analytics")!; + const schema = analytics.commands.find((c) => c.name() === "schema")!; + const required = schema.options.filter((o) => o.required); + expect(required).toHaveLength(0); + }); + }); + describe("--help includes examples via addHelpText", () => { it("post create has afterHelp text registered", () => { const post = program.commands.find((c) => c.name() === "post")!;