feat: add sqlite persistence

This commit is contained in:
Codex
2026-06-21 23:42:37 +08:00
parent 8c24949bfa
commit cb56ff9ee7
4 changed files with 633 additions and 0 deletions
+352
View File
@@ -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<string, unknown>;
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<string, string>;
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<T>(value: string): T {
return JSON.parse(value) as T;
}
function withDb<T>(dbPath: string | undefined, action: (db: ReturnType<typeof createDatabase>) => 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<string[]>(row.company_short_names),
product_names: parseJson<string[]>(row.product_names),
target_industries: parseJson<string[]>(row.target_industries),
target_audience: parseJson<string[]>(row.target_audience),
verified_claims: parseJson<string[]>(row.verified_claims),
forbidden_claims: parseJson<string[]>(row.forbidden_claims),
tone_rules: parseJson<JsonObject>(row.tone_rules),
};
}
function toArticleJob(row: ArticleJobRow): ArticleJob {
return {
...row,
image_inputs: parseJson<ImageInput[]>(row.image_inputs),
export_paths: parseJson<Record<string, string>>(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<ConfirmedFactCard>(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<OptimizedArticle>(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<QaReport>(row.report) : null;
});
}