From cb56ff9ee71e52905e0b9f484703a5459f827d1b Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 16 Jun 2026 13:29:15 +0800 Subject: [PATCH] feat: add sqlite persistence --- src/lib/db/__tests__/repositories.test.ts | 197 ++++++++++++ src/lib/db/connection.ts | 20 ++ src/lib/db/repositories.ts | 352 ++++++++++++++++++++++ src/lib/db/schema.ts | 64 ++++ 4 files changed, 633 insertions(+) create mode 100644 src/lib/db/__tests__/repositories.test.ts create mode 100644 src/lib/db/connection.ts create mode 100644 src/lib/db/repositories.ts create mode 100644 src/lib/db/schema.ts diff --git a/src/lib/db/__tests__/repositories.test.ts b/src/lib/db/__tests__/repositories.test.ts new file mode 100644 index 0000000..acfde66 --- /dev/null +++ b/src/lib/db/__tests__/repositories.test.ts @@ -0,0 +1,197 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { createDatabase } from "../connection"; +import { initializeSchema } from "../schema"; +import { + createArticleJob, + createBrandTemplate, + getArticleJob, + getBrandTemplate, + getFactCard, + getLatestOptimizedArticle, + getLatestQaReport, + listBrandTemplates, + saveFactCard, + saveOptimizedArticle, + saveQaReport, +} from "../repositories"; + +describe("sqlite repositories", () => { + let tempDir: string; + let dbPath: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "geo-agent-db-")); + dbPath = join(tempDir, "test.db"); + const db = createDatabase(dbPath); + initializeSchema(db); + db.close(); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("initializes all required tables", () => { + const db = createDatabase(dbPath); + const tables = db + .prepare( + "select name from sqlite_master where type = 'table' order by name", + ) + .all() + .map((row) => (row as { name: string }).name); + db.close(); + + expect(tables).toEqual([ + "article_jobs", + "brand_templates", + "fact_cards", + "optimized_articles", + "qa_reports", + ]); + }); + + it("inserts and fetches a brand template", () => { + const created = createBrandTemplate(dbPath, { + brand_name: "Example", + company_full_name: "Example Technology Co., Ltd.", + company_short_names: ["Example Tech"], + product_names: ["Example GEO"], + target_industries: ["GEO optimization"], + target_audience: ["Marketing teams"], + verified_claims: ["Eight years of experience"], + forbidden_claims: ["Industry first"], + tone_rules: { official_site: "official voice" }, + }); + + expect(getBrandTemplate(dbPath, created.id)).toMatchObject({ + id: created.id, + brand_name: "Example", + company_short_names: ["Example Tech"], + }); + expect(listBrandTemplates(dbPath)).toHaveLength(1); + }); + + it("inserts and fetches an article job", () => { + const job = createArticleJob(dbPath, { + source_title: "Original Title", + source_body: "Original body", + image_inputs: [{ type: "description", content: "Dashboard" }], + publish_platform: "official_site", + user_instructions: "Stay factual", + }); + + expect(getArticleJob(dbPath, job.id)).toMatchObject({ + id: job.id, + source_title: "Original Title", + image_inputs: [{ type: "description", content: "Dashboard" }], + }); + }); + + it("saves a confirmed fact card for a job", () => { + const job = createArticleJob(dbPath, { + source_title: "Original Title", + source_body: "Original body", + image_inputs: [], + publish_platform: "media_article", + user_instructions: "", + }); + + saveFactCard(dbPath, job.id, { + company_full_name: "Example Technology Co., Ltd.", + company_short_names: ["Example"], + brand_names: ["Example"], + product_names: ["Example GEO"], + target_industry: "GEO optimization", + target_audience: "Marketing teams", + experience_years: 8, + core_claims: ["Eight years of experience"], + forbidden_claims: [], + image_topics: [], + uncertain_items: [], + is_ready_for_optimization: true, + confirmed_by_user: true, + }); + + expect(getFactCard(dbPath, job.id)).toMatchObject({ + job_id: job.id, + company_full_name: "Example Technology Co., Ltd.", + confirmed_by_user: true, + }); + }); + + it("increments optimized article revisions", () => { + const job = createArticleJob(dbPath, { + source_title: "Original Title", + source_body: "Original body", + image_inputs: [], + publish_platform: "comparison_review", + user_instructions: "", + }); + + const first = saveOptimizedArticle(dbPath, job.id, { + title: "Optimized v1", + summary: "Summary", + body_markdown: "Body", + image_suggestions: [], + changed_sections: ["title"], + requires_user_confirmation: [], + }); + const second = saveOptimizedArticle(dbPath, job.id, { + title: "Optimized v2", + summary: "Summary", + body_markdown: "Body", + image_suggestions: [], + changed_sections: ["title"], + requires_user_confirmation: [], + }); + + expect(first.revision).toBe(1); + expect(second.revision).toBe(2); + expect(getLatestOptimizedArticle(dbPath, job.id)?.title).toBe( + "Optimized v2", + ); + }); + + it("saves and fetches the latest QA report by job and revision", () => { + const job = createArticleJob(dbPath, { + source_title: "Original Title", + source_body: "Original body", + image_inputs: [], + publish_platform: "recommendation_list", + user_instructions: "", + }); + saveOptimizedArticle(dbPath, job.id, { + title: "Optimized", + summary: "Summary", + body_markdown: "Body", + image_suggestions: [], + changed_sections: [], + requires_user_confirmation: [], + }); + + saveQaReport(dbPath, job.id, 1, { + overall_status: "warn", + checks: [ + { + rule_id: "image_text_match", + status: "warn", + evidence: "Image confidence is low.", + reason: "Image descriptions are sparse.", + suggested_fix: "Review image placement.", + target_agent: null, + }, + ], + }); + + expect(getLatestQaReport(dbPath, job.id)).toMatchObject({ + job_id: job.id, + revision: 1, + overall_status: "warn", + }); + }); +}); diff --git a/src/lib/db/connection.ts b/src/lib/db/connection.ts new file mode 100644 index 0000000..dc10a64 --- /dev/null +++ b/src/lib/db/connection.ts @@ -0,0 +1,20 @@ +import { mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import Database from "better-sqlite3"; + +export function getAppDataDir() { + return process.env.APP_DATA_DIR ?? "./data"; +} + +export function getDefaultDatabasePath() { + return join(getAppDataDir(), "app.db"); +} + +export function createDatabase(dbPath = getDefaultDatabasePath()) { + mkdirSync(dirname(dbPath), { recursive: true }); + const db = new Database(dbPath); + db.pragma("journal_mode = WAL"); + db.pragma("foreign_keys = ON"); + return db; +} diff --git a/src/lib/db/repositories.ts b/src/lib/db/repositories.ts new file mode 100644 index 0000000..6c64eab --- /dev/null +++ b/src/lib/db/repositories.ts @@ -0,0 +1,352 @@ +import { nanoid } from "nanoid"; + +import type { + ConfirmedFactCard, + ImageInput, + OptimizedArticle, + PublishPlatform, + QaReport, +} from "@/lib/domain/types"; + +import { createDatabase, getDefaultDatabasePath } from "./connection"; +import { initializeSchema } from "./schema"; + +type JsonObject = Record; + +export interface BrandTemplate { + id: string; + brand_name: string; + company_full_name: string; + company_short_names: string[]; + product_names: string[]; + target_industries: string[]; + target_audience: string[]; + verified_claims: string[]; + forbidden_claims: string[]; + tone_rules: JsonObject; + created_at: string; + updated_at: string; +} + +export type NewBrandTemplate = Omit< + BrandTemplate, + "id" | "created_at" | "updated_at" +>; + +export interface ArticleJob { + id: string; + brand_template_id: string | null; + source_title: string; + source_body: string; + image_inputs: ImageInput[]; + publish_platform: PublishPlatform; + user_instructions: string; + status: string; + export_paths: Record; + created_at: string; + updated_at: string; +} + +export interface NewArticleJob { + brand_template_id?: string | null; + source_title: string; + source_body: string; + image_inputs: ImageInput[]; + publish_platform: PublishPlatform; + user_instructions: string; +} + +interface BrandTemplateRow { + id: string; + brand_name: string; + company_full_name: string; + company_short_names: string; + product_names: string; + target_industries: string; + target_audience: string; + verified_claims: string; + forbidden_claims: string; + tone_rules: string; + created_at: string; + updated_at: string; +} + +interface ArticleJobRow { + id: string; + brand_template_id: string | null; + source_title: string; + source_body: string; + image_inputs: string; + publish_platform: PublishPlatform; + user_instructions: string; + status: string; + export_paths: string; + created_at: string; + updated_at: string; +} + +interface FactCardRow { + job_id: string; + fact_card: string; +} + +interface OptimizedArticleRow { + job_id: string; + revision: number; + article: string; +} + +interface QaReportRow { + job_id: string; + revision: number; + report: string; +} + +function nowIso() { + return new Date().toISOString(); +} + +function serialize(value: unknown) { + return JSON.stringify(value); +} + +function parseJson(value: string): T { + return JSON.parse(value) as T; +} + +function withDb(dbPath: string | undefined, action: (db: ReturnType) => T) { + const db = createDatabase(dbPath ?? getDefaultDatabasePath()); + initializeSchema(db); + try { + return action(db); + } finally { + db.close(); + } +} + +function toBrandTemplate(row: BrandTemplateRow): BrandTemplate { + return { + ...row, + company_short_names: parseJson(row.company_short_names), + product_names: parseJson(row.product_names), + target_industries: parseJson(row.target_industries), + target_audience: parseJson(row.target_audience), + verified_claims: parseJson(row.verified_claims), + forbidden_claims: parseJson(row.forbidden_claims), + tone_rules: parseJson(row.tone_rules), + }; +} + +function toArticleJob(row: ArticleJobRow): ArticleJob { + return { + ...row, + image_inputs: parseJson(row.image_inputs), + export_paths: parseJson>(row.export_paths), + }; +} + +export function createBrandTemplate( + dbPath: string | undefined, + input: NewBrandTemplate, +) { + return withDb(dbPath, (db) => { + const createdAt = nowIso(); + const template: BrandTemplate = { + id: `brand_${nanoid(10)}`, + ...input, + created_at: createdAt, + updated_at: createdAt, + }; + + db.prepare( + `insert into brand_templates ( + id, brand_name, company_full_name, company_short_names, product_names, + target_industries, target_audience, verified_claims, forbidden_claims, + tone_rules, created_at, updated_at + ) values ( + @id, @brand_name, @company_full_name, @company_short_names, @product_names, + @target_industries, @target_audience, @verified_claims, @forbidden_claims, + @tone_rules, @created_at, @updated_at + )`, + ).run({ + ...template, + company_short_names: serialize(template.company_short_names), + product_names: serialize(template.product_names), + target_industries: serialize(template.target_industries), + target_audience: serialize(template.target_audience), + verified_claims: serialize(template.verified_claims), + forbidden_claims: serialize(template.forbidden_claims), + tone_rules: serialize(template.tone_rules), + }); + + return template; + }); +} + +export function listBrandTemplates(dbPath?: string) { + return withDb(dbPath, (db) => + db + .prepare("select * from brand_templates order by updated_at desc") + .all() + .map((row) => toBrandTemplate(row as BrandTemplateRow)), + ); +} + +export function getBrandTemplate(dbPath: string | undefined, id: string) { + return withDb(dbPath, (db) => { + const row = db + .prepare("select * from brand_templates where id = ?") + .get(id) as BrandTemplateRow | undefined; + return row ? toBrandTemplate(row) : null; + }); +} + +export function createArticleJob(dbPath: string | undefined, input: NewArticleJob) { + return withDb(dbPath, (db) => { + const createdAt = nowIso(); + const job: ArticleJob = { + id: `job_${nanoid(10)}`, + brand_template_id: input.brand_template_id ?? null, + source_title: input.source_title, + source_body: input.source_body, + image_inputs: input.image_inputs, + publish_platform: input.publish_platform, + user_instructions: input.user_instructions, + status: "draft", + export_paths: {}, + created_at: createdAt, + updated_at: createdAt, + }; + + db.prepare( + `insert into article_jobs ( + id, brand_template_id, source_title, source_body, image_inputs, + publish_platform, user_instructions, status, export_paths, created_at, updated_at + ) values ( + @id, @brand_template_id, @source_title, @source_body, @image_inputs, + @publish_platform, @user_instructions, @status, @export_paths, @created_at, @updated_at + )`, + ).run({ + ...job, + image_inputs: serialize(job.image_inputs), + export_paths: serialize(job.export_paths), + }); + + return job; + }); +} + +export function getArticleJob(dbPath: string | undefined, id: string) { + return withDb(dbPath, (db) => { + const row = db + .prepare("select * from article_jobs where id = ?") + .get(id) as ArticleJobRow | undefined; + return row ? toArticleJob(row) : null; + }); +} + +export function saveFactCard( + dbPath: string | undefined, + jobId: string, + factCard: ConfirmedFactCard, +) { + return withDb(dbPath, (db) => { + const timestamp = nowIso(); + db.prepare( + `insert into fact_cards ( + job_id, source, fact_card, confirmed_by_user, created_at, updated_at + ) values ( + @job_id, @source, @fact_card, @confirmed_by_user, @created_at, @updated_at + ) + on conflict(job_id) do update set + fact_card = excluded.fact_card, + confirmed_by_user = excluded.confirmed_by_user, + updated_at = excluded.updated_at`, + ).run({ + job_id: jobId, + source: "auto_extract_then_user_confirmed", + fact_card: serialize(factCard), + confirmed_by_user: factCard.confirmed_by_user ? 1 : 0, + created_at: timestamp, + updated_at: timestamp, + }); + + return { job_id: jobId, ...factCard }; + }); +} + +export function getFactCard(dbPath: string | undefined, jobId: string) { + return withDb(dbPath, (db) => { + const row = db + .prepare("select job_id, fact_card from fact_cards where job_id = ?") + .get(jobId) as FactCardRow | undefined; + return row + ? { job_id: row.job_id, ...parseJson(row.fact_card) } + : null; + }); +} + +export function saveOptimizedArticle( + dbPath: string | undefined, + jobId: string, + article: OptimizedArticle, +) { + return withDb(dbPath, (db) => { + const nextRevision = + ((db + .prepare( + "select max(revision) as revision from optimized_articles where job_id = ?", + ) + .get(jobId) as { revision: number | null }).revision ?? 0) + 1; + const saved = { ...article, job_id: jobId, revision: nextRevision }; + + db.prepare( + `insert into optimized_articles (job_id, revision, article, created_at) + values (?, ?, ?, ?)`, + ).run(jobId, nextRevision, serialize(saved), nowIso()); + + return saved; + }); +} + +export function getLatestOptimizedArticle(dbPath: string | undefined, jobId: string) { + return withDb(dbPath, (db) => { + const row = db + .prepare( + `select job_id, revision, article from optimized_articles + where job_id = ? order by revision desc limit 1`, + ) + .get(jobId) as OptimizedArticleRow | undefined; + return row ? parseJson(row.article) : null; + }); +} + +export function saveQaReport( + dbPath: string | undefined, + jobId: string, + revision: number, + report: QaReport, +) { + return withDb(dbPath, (db) => { + const saved = { ...report, job_id: jobId, revision }; + db.prepare( + `insert into qa_reports (job_id, revision, report, created_at) + values (?, ?, ?, ?) + on conflict(job_id, revision) do update set + report = excluded.report`, + ).run(jobId, revision, serialize(saved), nowIso()); + return saved; + }); +} + +export function getLatestQaReport(dbPath: string | undefined, jobId: string) { + return withDb(dbPath, (db) => { + const row = db + .prepare( + `select job_id, revision, report from qa_reports + where job_id = ? order by revision desc limit 1`, + ) + .get(jobId) as QaReportRow | undefined; + return row ? parseJson(row.report) : null; + }); +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts new file mode 100644 index 0000000..c040abf --- /dev/null +++ b/src/lib/db/schema.ts @@ -0,0 +1,64 @@ +import type Database from "better-sqlite3"; + +export function initializeSchema(db: Database.Database) { + db.exec(` + create table if not exists brand_templates ( + id text primary key, + brand_name text not null, + company_full_name text not null, + company_short_names text not null, + product_names text not null, + target_industries text not null, + target_audience text not null, + verified_claims text not null, + forbidden_claims text not null, + tone_rules text not null, + created_at text not null, + updated_at text not null + ); + + create table if not exists article_jobs ( + id text primary key, + brand_template_id text, + source_title text not null, + source_body text not null, + image_inputs text not null, + publish_platform text not null, + user_instructions text not null, + status text not null, + export_paths text not null, + created_at text not null, + updated_at text not null, + foreign key (brand_template_id) references brand_templates(id) + ); + + create table if not exists fact_cards ( + job_id text primary key, + source text not null, + fact_card text not null, + confirmed_by_user integer not null, + created_at text not null, + updated_at text not null, + foreign key (job_id) references article_jobs(id) on delete cascade + ); + + create table if not exists optimized_articles ( + job_id text not null, + revision integer not null, + article text not null, + created_at text not null, + primary key (job_id, revision), + foreign key (job_id) references article_jobs(id) on delete cascade + ); + + create table if not exists qa_reports ( + job_id text not null, + revision integer not null, + report text not null, + created_at text not null, + primary key (job_id, revision), + foreign key (job_id, revision) + references optimized_articles(job_id, revision) on delete cascade + ); + `); +}