Skip to content

Commit b110901

Browse files
committed
refactor(cli): use synchronous markdown fetch from checkup_report_file_data view
Based on API analysis, markdown is auto-generated synchronously when JSON is uploaded (for paid users). This simplifies the implementation: - Replace async polling with direct fetch from checkup_report_file_data view - Add fetchMarkdownContent() and fetchMarkdownByReportId() functions - Update uploadCheckupReportJson() to return markdown chunk IDs - Remove --md-timeout option (no longer needed) - Gracefully handle missing markdown for non-paid users
1 parent bc3c2c1 commit b110901

3 files changed

Lines changed: 183 additions & 152 deletions

File tree

cli/bin/postgres-ai.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { maskSecret } from "../lib/util";
1919
import { createInterface } from "readline";
2020
import * as childProcess from "child_process";
2121
import { REPORT_GENERATORS, CHECK_INFO, generateAllReports } from "../lib/checkup";
22-
import { createCheckupReport, uploadCheckupReportJson, RpcError, formatRpcErrorForDisplay, withRetry, fetchMarkdownReport } from "../lib/checkup-api";
22+
import { createCheckupReport, uploadCheckupReportJson, RpcError, formatRpcErrorForDisplay, withRetry, fetchMarkdownByReportId } from "../lib/checkup-api";
2323

2424
// Singleton readline interface for stdin prompts
2525
let rl: ReturnType<typeof createInterface> | null = null;
@@ -183,7 +183,6 @@ interface CheckupOptions {
183183
project?: string;
184184
json?: boolean;
185185
md?: boolean;
186-
mdTimeout?: string;
187186
}
188187

189188
interface UploadConfig {
@@ -894,7 +893,6 @@ program
894893
)
895894
.option("--json", "output JSON to stdout (implies --no-upload)")
896895
.option("--[no-]md", "fetch markdown report after upload (default: enabled; paid subscription only)")
897-
.option("--md-timeout <seconds>", "timeout for markdown generation (default: 120)", "120")
898896
.addHelpText(
899897
"after",
900898
[
@@ -993,31 +991,32 @@ program
993991
}
994992

995993
// Fetch markdown report (default when uploading, unless --no-md or --json)
994+
// Markdown is auto-generated during upload for paid users
996995
const shouldFetchMarkdown = uploadSummary && opts.md !== false && !shouldPrintJson;
997996
if (shouldFetchMarkdown && uploadCfg) {
998-
spinner.update("Generating markdown report...");
997+
spinner.update("Fetching markdown report...");
999998
try {
1000-
const timeoutMs = parseInt(opts.mdTimeout || "120", 10) * 1000;
1001-
const markdown = await fetchMarkdownReport({
999+
const markdown = await fetchMarkdownByReportId({
10021000
apiKey: uploadCfg.apiKey,
10031001
apiBaseUrl: uploadCfg.apiBaseUrl,
10041002
reportId: uploadSummary.reportId,
1005-
timeoutMs,
1006-
onProgress: (msg) => spinner.update(msg),
10071003
});
10081004
spinner.stop();
10091005
console.log(markdown);
10101006
} catch (mdError) {
10111007
spinner.stop();
1012-
if (mdError instanceof RpcError && mdError.statusCode === 402) {
1013-
// Non-paid user - fall back to regular summary
1014-
console.error("\nMarkdown reports require a paid subscription.");
1008+
const isNoMarkdown =
1009+
mdError instanceof Error && mdError.message.includes("No markdown content");
1010+
if (isNoMarkdown) {
1011+
// No markdown generated - likely non-paid user
1012+
console.error("\nNo markdown report available.");
1013+
console.error("Markdown reports require a paid subscription.");
10151014
console.error("Upgrade at console.postgres.ai to access markdown output.\n");
10161015
printUploadSummary(uploadSummary, projectWasGenerated, shouldPrintJson);
10171016
} else {
10181017
// Other error - show it but still print summary
10191018
const msg = mdError instanceof Error ? mdError.message : String(mdError);
1020-
console.error(`\nWarning: Could not generate markdown report: ${msg}\n`);
1019+
console.error(`\nWarning: Could not fetch markdown report: ${msg}\n`);
10211020
printUploadSummary(uploadSummary, projectWasGenerated, shouldPrintJson);
10221021
}
10231022
}

cli/lib/checkup-api.ts

Lines changed: 153 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ export async function createCheckupReport(params: {
340340
/**
341341
* Upload a JSON check result to an existing checkup report.
342342
* Each check (e.g., H001, A003) is uploaded as a separate JSON file.
343+
* For paid users, markdown is auto-generated and returned in the response.
343344
*
344345
* @param params - Configuration for the upload
345346
* @param params.apiKey - PostgresAI API access token
@@ -348,7 +349,7 @@ export async function createCheckupReport(params: {
348349
* @param params.filename - Filename for the uploaded JSON (e.g., "H001.json")
349350
* @param params.checkId - Check identifier (e.g., "H001", "A003")
350351
* @param params.jsonText - JSON content as a string
351-
* @returns Promise resolving to the created report chunk ID
352+
* @returns Promise resolving to chunk ID and optional markdown info (for paid users)
352353
* @throws {RpcError} On API failures (4xx/5xx responses)
353354
* @throws {Error} On network errors or unexpected response format
354355
*/
@@ -359,7 +360,12 @@ export async function uploadCheckupReportJson(params: {
359360
filename: string;
360361
checkId: string;
361362
jsonText: string;
362-
}): Promise<{ reportChunkId: number }> {
363+
}): Promise<{
364+
reportChunkId: number;
365+
markdownChunkId?: number;
366+
markdownChunkIds?: number[];
367+
skippedMarkdown?: boolean;
368+
}> {
363369
const { apiKey, apiBaseUrl, reportId, filename, checkId, jsonText } = params;
364370
const bodyObj: Record<string, unknown> = {
365371
access_token: apiKey,
@@ -382,152 +388,181 @@ export async function uploadCheckupReportJson(params: {
382388
if (!Number.isFinite(chunkId) || chunkId <= 0) {
383389
throw new Error(`Unexpected checkup_report_file_post response: ${JSON.stringify(resp)}`);
384390
}
385-
return { reportChunkId: chunkId };
386-
}
387-
388-
/**
389-
* Status of markdown generation request
390-
*/
391-
export type MarkdownStatus = "pending" | "processing" | "completed" | "failed";
392-
393-
/**
394-
* Response from markdown status check
395-
*/
396-
export interface MarkdownStatusResponse {
397-
status: MarkdownStatus;
398-
markdown?: string;
399-
error?: string;
400-
}
401-
402-
/**
403-
* Request markdown generation for an uploaded checkup report.
404-
* This initiates async markdown generation on the server.
405-
*
406-
* @param params - Configuration for the request
407-
* @param params.apiKey - PostgresAI API access token
408-
* @param params.apiBaseUrl - Base URL of the PostgresAI API
409-
* @param params.reportId - ID of the checkup report to generate markdown for
410-
* @returns Promise resolving to a request ID for polling status
411-
* @throws {RpcError} On API failures (402 for non-paid users, other 4xx/5xx)
412-
*/
413-
export async function requestMarkdownGeneration(params: {
414-
apiKey: string;
415-
apiBaseUrl: string;
416-
reportId: number;
417-
}): Promise<{ requestId: string }> {
418-
const { apiKey, apiBaseUrl, reportId } = params;
419391

420-
const resp = await postRpc<any>({
421-
apiKey,
422-
apiBaseUrl,
423-
rpcName: "checkup_markdown_request",
424-
bodyObj: {
425-
access_token: apiKey,
426-
checkup_report_id: reportId,
427-
},
428-
});
392+
const result: {
393+
reportChunkId: number;
394+
markdownChunkId?: number;
395+
markdownChunkIds?: number[];
396+
skippedMarkdown?: boolean;
397+
} = { reportChunkId: chunkId };
429398

430-
const requestId = resp?.request_id;
431-
if (typeof requestId !== "string" || !requestId) {
432-
throw new Error(`Unexpected checkup_markdown_request response: ${JSON.stringify(resp)}`);
399+
// Extract markdown chunk info (for paid users)
400+
if (resp?.markdown_chunk_id) {
401+
result.markdownChunkId = Number(resp.markdown_chunk_id);
402+
}
403+
if (Array.isArray(resp?.markdown_chunk_ids)) {
404+
result.markdownChunkIds = resp.markdown_chunk_ids.map(Number);
405+
}
406+
if (resp?.skipped_markdown) {
407+
result.skippedMarkdown = true;
433408
}
434-
return { requestId };
409+
410+
return result;
435411
}
436412

437413
/**
438-
* Check the status of a markdown generation request.
414+
* Fetch markdown content by chunk ID(s) from the checkup_report_file_data view.
415+
* Markdown is auto-generated when JSON is uploaded (for paid users).
439416
*
440-
* @param params - Configuration for the status check
417+
* @param params - Configuration for fetching markdown
441418
* @param params.apiKey - PostgresAI API access token
442419
* @param params.apiBaseUrl - Base URL of the PostgresAI API
443-
* @param params.requestId - Request ID from requestMarkdownGeneration
444-
* @returns Promise resolving to the current status and markdown content if completed
420+
* @param params.markdownChunkIds - Array of markdown chunk IDs to fetch
421+
* @returns Promise resolving to concatenated markdown content
445422
* @throws {RpcError} On API failures
423+
* @throws {Error} If no markdown found
446424
*/
447-
export async function getMarkdownStatus(params: {
425+
export async function fetchMarkdownContent(params: {
448426
apiKey: string;
449427
apiBaseUrl: string;
450-
requestId: string;
451-
}): Promise<MarkdownStatusResponse> {
452-
const { apiKey, apiBaseUrl, requestId } = params;
453-
454-
const resp = await postRpc<any>({
455-
apiKey,
456-
apiBaseUrl,
457-
rpcName: "checkup_markdown_status",
458-
bodyObj: {
459-
access_token: apiKey,
460-
request_id: requestId,
461-
},
462-
});
428+
markdownChunkIds: number[];
429+
}): Promise<string> {
430+
const { apiKey, apiBaseUrl, markdownChunkIds } = params;
463431

464-
const status = resp?.status as MarkdownStatus;
465-
if (!["pending", "processing", "completed", "failed"].includes(status)) {
466-
throw new Error(`Unexpected checkup_markdown_status response: ${JSON.stringify(resp)}`);
432+
if (markdownChunkIds.length === 0) {
433+
throw new Error("No markdown chunk IDs provided");
467434
}
468435

469-
return {
470-
status,
471-
markdown: typeof resp?.markdown === "string" ? resp.markdown : undefined,
472-
error: typeof resp?.error === "string" ? resp.error : undefined,
473-
};
436+
const base = normalizeBaseUrl(apiBaseUrl);
437+
// Query the view for markdown chunks by ID
438+
const idsFilter = markdownChunkIds.map((id) => `id.eq.${id}`).join(",");
439+
const url = new URL(`${base}/checkup_report_file_data?or=(${idsFilter})&type=eq.md&select=data,filename,check_id`);
440+
441+
return new Promise((resolve, reject) => {
442+
const headers: Record<string, string> = {
443+
"access-token": apiKey,
444+
Accept: "application/json",
445+
};
446+
447+
const req = https.request(
448+
url,
449+
{ method: "GET", headers },
450+
(res) => {
451+
let data = "";
452+
res.on("data", (chunk) => (data += chunk));
453+
res.on("end", () => {
454+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
455+
try {
456+
const rows = JSON.parse(data);
457+
if (!Array.isArray(rows) || rows.length === 0) {
458+
reject(new Error("No markdown content found for the specified chunk IDs"));
459+
return;
460+
}
461+
// Concatenate all markdown content, separated by newlines
462+
const markdown = rows
463+
.map((row: { data: string; check_id?: string }) => row.data)
464+
.join("\n\n---\n\n");
465+
resolve(markdown);
466+
} catch {
467+
reject(new Error(`Failed to parse markdown response: ${data}`));
468+
}
469+
} else {
470+
let payloadJson: any = null;
471+
try {
472+
payloadJson = JSON.parse(data);
473+
} catch {
474+
// ignore
475+
}
476+
reject(
477+
new RpcError({
478+
rpcName: "checkup_report_file_data",
479+
statusCode: res.statusCode || 0,
480+
payloadText: data,
481+
payloadJson,
482+
})
483+
);
484+
}
485+
});
486+
res.on("error", reject);
487+
}
488+
);
489+
req.on("error", reject);
490+
req.end();
491+
});
474492
}
475493

476494
/**
477-
* Request markdown generation and poll until complete or timeout.
478-
* This is a convenience function that combines requestMarkdownGeneration
479-
* and getMarkdownStatus with polling logic.
495+
* Fetch all markdown files for a checkup report by report ID.
480496
*
481497
* @param params - Configuration for fetching markdown
482498
* @param params.apiKey - PostgresAI API access token
483499
* @param params.apiBaseUrl - Base URL of the PostgresAI API
484500
* @param params.reportId - ID of the checkup report
485-
* @param params.timeoutMs - Maximum time to wait for generation (default: 120000ms)
486-
* @param params.pollIntervalMs - Interval between status checks (default: 2000ms)
487-
* @param params.onProgress - Optional callback for progress updates
488-
* @returns Promise resolving to the generated markdown content
489-
* @throws {RpcError} On API failures (402 for non-paid users)
490-
* @throws {Error} On timeout or generation failure
501+
* @returns Promise resolving to markdown content
502+
* @throws {RpcError} On API failures
503+
* @throws {Error} If no markdown found
491504
*/
492-
export async function fetchMarkdownReport(params: {
505+
export async function fetchMarkdownByReportId(params: {
493506
apiKey: string;
494507
apiBaseUrl: string;
495508
reportId: number;
496-
timeoutMs?: number;
497-
pollIntervalMs?: number;
498-
onProgress?: (message: string) => void;
499509
}): Promise<string> {
500-
const {
501-
apiKey,
502-
apiBaseUrl,
503-
reportId,
504-
timeoutMs = 120_000,
505-
pollIntervalMs = 2000,
506-
onProgress,
507-
} = params;
508-
509-
// Request markdown generation
510-
const { requestId } = await requestMarkdownGeneration({ apiKey, apiBaseUrl, reportId });
511-
512-
const startTime = Date.now();
513-
514-
// Poll for completion
515-
while (Date.now() - startTime < timeoutMs) {
516-
const result = await getMarkdownStatus({ apiKey, apiBaseUrl, requestId });
517-
518-
if (result.status === "completed" && result.markdown) {
519-
return result.markdown;
520-
}
521-
522-
if (result.status === "failed") {
523-
throw new Error(`Markdown generation failed: ${result.error || "Unknown error"}`);
524-
}
510+
const { apiKey, apiBaseUrl, reportId } = params;
525511

526-
const elapsed = Math.round((Date.now() - startTime) / 1000);
527-
onProgress?.(`Generating markdown report... (${elapsed}s)`);
512+
const base = normalizeBaseUrl(apiBaseUrl);
513+
const url = new URL(
514+
`${base}/checkup_report_file_data?checkup_report_id=eq.${reportId}&type=eq.md&select=data,filename,check_id&order=check_id`
515+
);
528516

529-
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
530-
}
517+
return new Promise((resolve, reject) => {
518+
const headers: Record<string, string> = {
519+
"access-token": apiKey,
520+
Accept: "application/json",
521+
};
531522

532-
throw new Error(`Markdown generation timed out after ${Math.round(timeoutMs / 1000)} seconds`);
523+
const req = https.request(
524+
url,
525+
{ method: "GET", headers },
526+
(res) => {
527+
let data = "";
528+
res.on("data", (chunk) => (data += chunk));
529+
res.on("end", () => {
530+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
531+
try {
532+
const rows = JSON.parse(data);
533+
if (!Array.isArray(rows) || rows.length === 0) {
534+
reject(new Error("No markdown content found for this report"));
535+
return;
536+
}
537+
// Concatenate all markdown content, separated by newlines
538+
const markdown = rows
539+
.map((row: { data: string; check_id?: string }) => row.data)
540+
.join("\n\n---\n\n");
541+
resolve(markdown);
542+
} catch {
543+
reject(new Error(`Failed to parse markdown response: ${data}`));
544+
}
545+
} else {
546+
let payloadJson: any = null;
547+
try {
548+
payloadJson = JSON.parse(data);
549+
} catch {
550+
// ignore
551+
}
552+
reject(
553+
new RpcError({
554+
rpcName: "checkup_report_file_data",
555+
statusCode: res.statusCode || 0,
556+
payloadText: data,
557+
payloadJson,
558+
})
559+
);
560+
}
561+
});
562+
res.on("error", reject);
563+
}
564+
);
565+
req.on("error", reject);
566+
req.end();
567+
});
533568
}

0 commit comments

Comments
 (0)