Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,29 @@ paragraph user get <user-id>
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.
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
140 changes: 140 additions & 0 deletions src/cli/commands/analytics.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> {
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");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since resolveSql is an async function and already awaits readStdin(), it is better to use the promise-based fs.promises.readFile instead of the synchronous fs.readFileSync. This maintains consistency and avoids blocking the event loop in an asynchronous context.

Suggested change
return fs.readFileSync(opts.file, "utf-8");
return await fs.promises.readFile(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 <query>", "SQL query string")
.option("--file <path>", "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<string, unknown>)[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);
}
});
}
2 changes: 2 additions & 0 deletions src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } };

Expand Down Expand Up @@ -39,6 +40,7 @@ export function createProgram(): Command {
registerSubscriberCommands(program);
registerCoinCommands(program);
registerUserCommands(program);
registerAnalyticsCommands(program);

return program;
}
17 changes: 17 additions & 0 deletions src/services/analytics.ts
Original file line number Diff line number Diff line change
@@ -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<AnalyticsQuery200> {
analyticsQueryBody.parse({ sql });
const client = createClient(apiKey);
return client.analytics.query({ sql });
}

export async function getSchema(apiKey: string): Promise<AnalyticsSchema200> {
const client = createClient(apiKey);
return client.analytics.schema();
}
25 changes: 25 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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")!;
Expand Down
Loading