feat: implement SQLite persistent storage for analysis results

- Implemented database initialization and connection management in `database.ts` using Bun's SQLite.
- Created `runs` and `results` tables to track historical analysis metadata and detailed product performance.
- Updated `writer.ts` to persist analysis results to the database within a transaction, replacing the previous CSV output logic.
- Updated README and `.gitignore` to reflect the new persistent storage capability.
This commit is contained in:
Victor Noguera
2026-04-12 23:41:40 -04:00
parent 0162e54007
commit 8ffbd48c46
5 changed files with 223 additions and 25 deletions

View File

@@ -1,4 +1,4 @@
import * as XLSX from "xlsx";
import { getDb } from "./database.ts";
import type { AnalysisResult } from "./types.ts";
function buildRow(r: AnalysisResult) {
@@ -7,6 +7,12 @@ function buildRow(r: AnalysisResult) {
r.product.record.sellingPriceFromSheet ??
r.product.spApi.estimatedSalePrice;
const rank = r.product.keepa?.salesRank ?? r.product.record.amazonRank;
const canSellStatus =
r.product.spApi.canSell == null
? "unknown"
: r.product.spApi.canSell
? "yes"
: "no";
return {
ASIN: r.product.record.asin,
@@ -44,12 +50,7 @@ function buildRow(r: AnalysisResult) {
"FBA Fee": r.product.spApi.fbaFee,
"FBM Fee": r.product.spApi.fbmFee,
"Referral %": r.product.spApi.referralFeePercent,
"Can Sell":
r.product.spApi.canSell == null
? "unknown"
: r.product.spApi.canSell
? "yes"
: "no",
"Can Sell": canSellStatus,
Sellability: r.product.spApi.sellabilityStatus,
"Sellability Reason": r.product.spApi.sellabilityReason ?? "",
Verdict: r.verdict.verdict,
@@ -58,6 +59,113 @@ function buildRow(r: AnalysisResult) {
};
}
export function writeResultsToDb(
results: AnalysisResult[],
dbPath: string,
inputFile: string,
outputFile: string | undefined,
): void {
const database = getDb(dbPath);
const timestamp = new Date().toISOString();
const fbaCount = results.filter((r) => r.verdict.verdict === "FBA").length;
const fbmCount = results.filter((r) => r.verdict.verdict === "FBM").length;
const skipCount = results.filter((r) => r.verdict.verdict === "SKIP").length;
const insertRun = database.prepare(
`INSERT INTO runs (
timestamp,
input_file,
output_file,
total_products,
fba_count,
fbm_count,
skip_count
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
);
const runInfo = insertRun.run(
timestamp,
inputFile,
outputFile ?? null,
results.length,
fbaCount,
fbmCount,
skipCount,
);
const runId =
(runInfo.changes as number) > 0
? (runInfo.lastInsertRowid as number)
: null;
if (runId === null) {
console.error("Failed to insert run record into SQLite.");
return;
}
const insertResult = database.prepare(
`INSERT INTO results (
run_id, asin, product_name, brand, category, unit_cost, current_price,
avg_price_90d, avg_price_90d_sheet, selling_price_sheet, sales_rank, rank_avg_90d,
sellers, monthly_sold, rank_drops_30d, rank_drops_90d, fba_net_sheet,
gross_profit_dollar, gross_profit_pct, net_profit_sheet, roi_sheet, moq, moq_cost,
qty_available, supplier, source_url, asin_link, promo_coupon_code, notes, lead_date,
fba_fee, fbm_fee, referral_percent, can_sell, sellability_status, sellability_reason,
verdict, confidence, reasoning, fetched_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)`,
);
database.transaction(() => {
for (const r of results) {
const row = buildRow(r);
insertResult.run(
runId,
row.ASIN,
row.Name,
row.Brand,
row.Category,
row["Unit Cost"] ?? null,
row["Current Price"] ?? null,
row["Avg Price 90d"] ?? null,
row["Avg Price 90d (sheet)"] ?? null,
row["Selling Price (sheet)"] ?? null,
row["Sales Rank"] ?? null,
row["Rank Avg 90d"] ?? null,
row.Sellers ?? null,
row["Monthly Sold"] ?? null,
row["Rank Drops 30d"] ?? null,
row["Rank Drops 90d"] ?? null,
row["FBA Net (sheet)"] ?? null,
row["Gross Profit $"] ?? null,
row["Gross Profit %"] ?? null,
row["Net Profit (sheet)"] ?? null,
row["ROI (sheet)"] ?? null,
row.MOQ ?? null,
row["MOQ Cost"] ?? null,
row["Qty Available"] ?? null,
row.Supplier ?? null,
row["Source URL"] ?? null,
row["ASIN Link"] ?? null,
row["Promo/Coupon Code"] ?? null,
row.Notes ?? null,
row["Lead Date"] ?? null,
row["FBA Fee"] ?? null,
row["FBM Fee"] ?? null,
row["Referral %"] ?? null,
row["Can Sell"],
row.Sellability,
row["Sellability Reason"] ?? null,
row.Verdict,
row.Confidence ?? null,
row.Reasoning,
r.product.fetchedAt,
);
}
})();
console.log(`Results written to SQLite database for run_id: ${runId}`);
}
export function printResults(results: AnalysisResult[]): void {
const rows = results
.filter((r) => r.verdict.verdict === "FBA" || r.verdict.verdict === "FBM")
@@ -144,16 +252,3 @@ export function printResults(results: AnalysisResult[]): void {
`Sellability: ${summary.Available} available | ${summary.Restricted} restricted | ${summary.NotAvailable} not_available | ${summary.Unknown} unknown\n`,
);
}
export function writeResultsCsv(
results: AnalysisResult[],
outputPath: string,
): void {
const rows = results.map(buildRow);
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Results");
XLSX.writeFile(wb, outputPath);
console.log(`Results written to ${outputPath}`);
}