1531 lines
42 KiB
Markdown
1531 lines
42 KiB
Markdown
# Cloudflare Workers Deployment Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Make the app deployable to Cloudflare Workers with OpenNext, D1-backed data, private R2 exports, explicit local/online separation, and API-key protection.
|
|
|
|
**Architecture:** Keep the existing local Node path intact while adding a Cloudflare path selected through a small runtime boundary. Route handlers use async repository and export-store interfaces; local implementation uses the existing SQLite/filesystem behavior, while Cloudflare implementation uses `getCloudflareContext().env.DB` and `env.EXPORT_BUCKET`.
|
|
|
|
**Tech Stack:** Next.js 16, TypeScript, OpenNext Cloudflare adapter, Wrangler, Cloudflare D1, Cloudflare R2, Vitest.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
- Modify `package.json`: add Cloudflare dependencies and explicit scripts.
|
|
- Modify `.gitignore`: ignore `.open-next`, `.wrangler`, `.dev.vars`, and generated Cloudflare type files if needed.
|
|
- Modify `next.config.ts`: initialize OpenNext dev integration.
|
|
- Create `open-next.config.ts`: OpenNext Cloudflare adapter config.
|
|
- Create `wrangler.jsonc`: Worker, assets, D1, R2, env, and observability config.
|
|
- Create `migrations/0001_initial_schema.sql`: D1 initial schema copied from the current SQLite schema.
|
|
- Create `src/lib/runtime/cloudflare.ts`: typed access to Cloudflare bindings.
|
|
- Create `src/lib/api/auth.ts`: API key guard shared by route handlers.
|
|
- Create `src/lib/db/repository.ts`: async repository interface and runtime selector.
|
|
- Create `src/lib/db/sqlite-repository.ts`: async wrapper around existing SQLite functions for local runtime.
|
|
- Create `src/lib/db/d1-repository.ts`: D1 implementation.
|
|
- Create `src/lib/workflow/export-store.ts`: local and R2 export-store implementations.
|
|
- Modify API routes under `src/app/api/jobs/**/route.ts`: use auth, async repository, and export store.
|
|
- Modify tests under `src/app/api/__tests__`, `src/lib/db/__tests__`, and `src/lib/workflow/__tests__`.
|
|
- Modify `README.md` and `.env.example`: document local vs staging vs production deployment.
|
|
|
|
## Task 1: Add Cloudflare Build Configuration
|
|
|
|
**Files:**
|
|
- Modify: `package.json`
|
|
- Modify: `.gitignore`
|
|
- Modify: `next.config.ts`
|
|
- Create: `open-next.config.ts`
|
|
- Create: `wrangler.jsonc`
|
|
- Create: `public/_headers`
|
|
- Create: `.dev.vars.example`
|
|
|
|
- [ ] **Step 1: Install dependencies**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm install @opennextjs/cloudflare@latest
|
|
npm install --save-dev wrangler@latest @cloudflare/workers-types@latest
|
|
```
|
|
|
|
Expected: `package.json` and `package-lock.json` include the new packages.
|
|
|
|
- [ ] **Step 2: Update `package.json` scripts**
|
|
|
|
Edit the scripts block to include these entries while preserving existing scripts:
|
|
|
|
```json
|
|
{
|
|
"test": "vitest run",
|
|
"dev": "next dev",
|
|
"build": "next build",
|
|
"start": "next start",
|
|
"lint": "next lint",
|
|
"test:watch": "vitest",
|
|
"build:worker": "opennextjs-cloudflare build",
|
|
"preview:worker": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
|
"deploy:worker:staging": "opennextjs-cloudflare build && opennextjs-cloudflare deploy --env staging",
|
|
"deploy:worker:production": "opennextjs-cloudflare build && opennextjs-cloudflare deploy --env production",
|
|
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
|
|
"d1:migrate:local": "wrangler d1 migrations apply geo-agent-article-optimizer-local --local",
|
|
"d1:migrate:staging": "wrangler d1 migrations apply geo-agent-article-optimizer-staging --env staging --remote",
|
|
"d1:migrate:production": "wrangler d1 migrations apply geo-agent-article-optimizer-production --env production --remote"
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Add ignored generated/local files**
|
|
|
|
Append these entries to `.gitignore`:
|
|
|
|
```gitignore
|
|
.open-next
|
|
.wrangler
|
|
.dev.vars
|
|
cloudflare-env.d.ts
|
|
```
|
|
|
|
- [ ] **Step 4: Add OpenNext config**
|
|
|
|
Create `open-next.config.ts`:
|
|
|
|
```typescript
|
|
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
|
|
|
|
export default defineCloudflareConfig({});
|
|
```
|
|
|
|
- [ ] **Step 5: Initialize OpenNext dev support in `next.config.ts`**
|
|
|
|
Replace `next.config.ts` with:
|
|
|
|
```typescript
|
|
import type { NextConfig } from "next";
|
|
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
|
|
|
|
const nextConfig: NextConfig = {};
|
|
|
|
export default nextConfig;
|
|
|
|
initOpenNextCloudflareForDev();
|
|
```
|
|
|
|
- [ ] **Step 6: Add `wrangler.jsonc`**
|
|
|
|
Create `wrangler.jsonc` with local bindings only. Add staging and production entries after creating the real Cloudflare resources in Task 9, so no fake remote database IDs are committed.
|
|
|
|
```jsonc
|
|
{
|
|
"$schema": "node_modules/wrangler/config-schema.json",
|
|
"main": ".open-next/worker.js",
|
|
"name": "geo-agent-article-optimizer",
|
|
"compatibility_date": "2026-06-16",
|
|
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
|
"assets": {
|
|
"directory": ".open-next/assets",
|
|
"binding": "ASSETS"
|
|
},
|
|
"observability": {
|
|
"enabled": true,
|
|
"head_sampling_rate": 0.1
|
|
},
|
|
"services": [
|
|
{
|
|
"binding": "WORKER_SELF_REFERENCE",
|
|
"service": "geo-agent-article-optimizer"
|
|
}
|
|
],
|
|
"vars": {
|
|
"APP_RUNTIME": "cloudflare",
|
|
"LLM_PROVIDER": "deepseek",
|
|
"DEEPSEEK_BASE_URL": "https://api.deepseek.com",
|
|
"DEEPSEEK_MODEL": "deepseek-v4-pro",
|
|
"DEEPSEEK_THINKING": "disabled",
|
|
"OPENAI_MODEL": "gpt-4.1-mini"
|
|
},
|
|
"d1_databases": [
|
|
{
|
|
"binding": "DB",
|
|
"database_name": "geo-agent-article-optimizer-local",
|
|
"database_id": "00000000-0000-0000-0000-000000000001",
|
|
"migrations_dir": "migrations"
|
|
}
|
|
],
|
|
"r2_buckets": [
|
|
{
|
|
"binding": "EXPORT_BUCKET",
|
|
"bucket_name": "geo-agent-article-optimizer-local"
|
|
}
|
|
],
|
|
"env": {}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7: Add static asset caching headers**
|
|
|
|
Create `public/_headers`:
|
|
|
|
```text
|
|
/_next/static/*
|
|
Cache-Control: public,max-age=31536000,immutable
|
|
```
|
|
|
|
- [ ] **Step 8: Add local Worker env example**
|
|
|
|
Create `.dev.vars.example`:
|
|
|
|
```text
|
|
NEXTJS_ENV=development
|
|
API_ACCESS_KEY=local-worker-dev-key
|
|
DEEPSEEK_API_KEY=
|
|
OPENAI_API_KEY=
|
|
```
|
|
|
|
- [ ] **Step 9: Generate Cloudflare binding types**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run cf-typegen
|
|
```
|
|
|
|
Expected: command exits 0. The generated `cloudflare-env.d.ts` remains ignored.
|
|
|
|
## Task 2: Add D1 Migration
|
|
|
|
**Files:**
|
|
- Create: `migrations/0001_initial_schema.sql`
|
|
|
|
- [ ] **Step 1: Create initial migration**
|
|
|
|
Create `migrations/0001_initial_schema.sql`:
|
|
|
|
```sql
|
|
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
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_article_jobs_updated_at
|
|
ON article_jobs(updated_at);
|
|
```
|
|
|
|
- [ ] **Step 2: Apply migration locally**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run d1:migrate:local
|
|
```
|
|
|
|
Expected: Wrangler applies `0001_initial_schema.sql` to the local D1 database. Runtime code must not call `CREATE TABLE`, `DROP TABLE`, or table-clearing SQL for the Cloudflare path.
|
|
|
|
## Task 3: Introduce API Key Guard
|
|
|
|
**Files:**
|
|
- Create: `src/lib/api/auth.ts`
|
|
- Test: `src/lib/api/__tests__/auth.test.ts`
|
|
|
|
- [ ] **Step 1: Write failing auth tests**
|
|
|
|
Create `src/lib/api/__tests__/auth.test.ts`:
|
|
|
|
```typescript
|
|
import { describe, expect, test } from "vitest";
|
|
|
|
import { requireApiAccess } from "../auth";
|
|
|
|
describe("requireApiAccess", () => {
|
|
test("allows local test requests when auth is explicitly disabled", () => {
|
|
const request = new Request("http://localhost/api/jobs");
|
|
|
|
const result = requireApiAccess(request, {
|
|
apiAccessKey: undefined,
|
|
authDisabled: true,
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
test("rejects requests when the configured key is missing", () => {
|
|
const request = new Request("http://localhost/api/jobs");
|
|
|
|
const result = requireApiAccess(request, {
|
|
apiAccessKey: "secret",
|
|
authDisabled: false,
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.response.status).toBe(401);
|
|
}
|
|
});
|
|
|
|
test("rejects requests with the wrong key", () => {
|
|
const request = new Request("http://localhost/api/jobs", {
|
|
headers: { "x-api-key": "wrong" },
|
|
});
|
|
|
|
const result = requireApiAccess(request, {
|
|
apiAccessKey: "secret",
|
|
authDisabled: false,
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
test("allows requests with the correct key", () => {
|
|
const request = new Request("http://localhost/api/jobs", {
|
|
headers: { "x-api-key": "secret" },
|
|
});
|
|
|
|
const result = requireApiAccess(request, {
|
|
apiAccessKey: "secret",
|
|
authDisabled: false,
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run auth tests and verify RED**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/api/__tests__/auth.test.ts
|
|
```
|
|
|
|
Expected: FAIL because `src/lib/api/auth.ts` does not exist.
|
|
|
|
- [ ] **Step 3: Implement auth guard**
|
|
|
|
Create `src/lib/api/auth.ts`:
|
|
|
|
```typescript
|
|
import { NextResponse } from "next/server";
|
|
|
|
interface ApiAccessOptions {
|
|
apiAccessKey?: string;
|
|
authDisabled?: boolean;
|
|
}
|
|
|
|
type ApiAccessResult =
|
|
| { ok: true }
|
|
| { ok: false; response: NextResponse<{ error: string }> };
|
|
|
|
export function requireApiAccess(
|
|
request: Request,
|
|
options: ApiAccessOptions = {
|
|
apiAccessKey: process.env.API_ACCESS_KEY,
|
|
authDisabled: process.env.API_AUTH_DISABLED === "true",
|
|
},
|
|
): ApiAccessResult {
|
|
if (options.authDisabled) {
|
|
return { ok: true };
|
|
}
|
|
|
|
if (!options.apiAccessKey) {
|
|
return {
|
|
ok: false,
|
|
response: NextResponse.json(
|
|
{ error: "API access key is not configured" },
|
|
{ status: 401 },
|
|
),
|
|
};
|
|
}
|
|
|
|
const provided = request.headers.get("x-api-key");
|
|
if (provided !== options.apiAccessKey) {
|
|
return {
|
|
ok: false,
|
|
response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
|
|
};
|
|
}
|
|
|
|
return { ok: true };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run auth tests and verify GREEN**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/api/__tests__/auth.test.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
## Task 4: Add Repository Boundary And Local Adapter
|
|
|
|
**Files:**
|
|
- Create: `src/lib/db/repository.ts`
|
|
- Create: `src/lib/db/sqlite-repository.ts`
|
|
- Modify: `src/lib/db/repositories.ts`
|
|
- Test: `src/lib/db/__tests__/repository.test.ts`
|
|
|
|
- [ ] **Step 1: Write failing repository adapter test**
|
|
|
|
Create `src/lib/db/__tests__/repository.test.ts`:
|
|
|
|
```typescript
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
|
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
|
|
import { createSqliteRepository } from "../sqlite-repository";
|
|
|
|
describe("createSqliteRepository", () => {
|
|
let tempDir: string;
|
|
let dbPath: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = mkdtempSync(join(tmpdir(), "geo-repository-"));
|
|
dbPath = join(tempDir, "app.db");
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("creates and reads an article job through the async repository interface", async () => {
|
|
const repository = createSqliteRepository(dbPath);
|
|
|
|
const job = await repository.createArticleJob({
|
|
source_title: "Title",
|
|
source_body: "Body",
|
|
image_inputs: [],
|
|
publish_platform: "official_site",
|
|
user_instructions: "",
|
|
});
|
|
|
|
await expect(repository.getArticleJob(job.id)).resolves.toMatchObject({
|
|
id: job.id,
|
|
source_title: "Title",
|
|
export_paths: {},
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run repository adapter test and verify RED**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/db/__tests__/repository.test.ts
|
|
```
|
|
|
|
Expected: FAIL because `sqlite-repository` does not exist.
|
|
|
|
- [ ] **Step 3: Create async repository interface**
|
|
|
|
Create `src/lib/db/repository.ts`:
|
|
|
|
```typescript
|
|
import type { ConfirmedFactCard, OptimizedArticle, QaReport } from "../domain/types";
|
|
import type {
|
|
ArticleJob,
|
|
BrandTemplate,
|
|
NewArticleJob,
|
|
NewBrandTemplate,
|
|
} from "./repositories";
|
|
|
|
export interface AppRepository {
|
|
createBrandTemplate(input: NewBrandTemplate): Promise<BrandTemplate>;
|
|
listBrandTemplates(): Promise<BrandTemplate[]>;
|
|
getBrandTemplate(id: string): Promise<BrandTemplate | null>;
|
|
createArticleJob(input: NewArticleJob): Promise<ArticleJob>;
|
|
getArticleJob(id: string): Promise<ArticleJob | null>;
|
|
updateArticleJob(
|
|
id: string,
|
|
changes: Partial<Pick<ArticleJob, "brand_template_id" | "status" | "export_paths">>,
|
|
): Promise<ArticleJob | null>;
|
|
saveFactCard(jobId: string, factCard: ConfirmedFactCard): Promise<{ job_id: string } & ConfirmedFactCard>;
|
|
getFactCard(jobId: string): Promise<({ job_id: string } & ConfirmedFactCard) | null>;
|
|
saveOptimizedArticle(jobId: string, article: OptimizedArticle): Promise<OptimizedArticle>;
|
|
getLatestOptimizedArticle(jobId: string): Promise<OptimizedArticle | null>;
|
|
saveQaReport(jobId: string, revision: number, report: QaReport): Promise<QaReport>;
|
|
getQaReport(jobId: string, revision: number): Promise<QaReport | null>;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Create local SQLite adapter**
|
|
|
|
Create `src/lib/db/sqlite-repository.ts`:
|
|
|
|
```typescript
|
|
import type { ConfirmedFactCard, OptimizedArticle, QaReport } from "../domain/types";
|
|
import type { AppRepository } from "./repository";
|
|
import {
|
|
createArticleJob,
|
|
createBrandTemplate,
|
|
getArticleJob,
|
|
getBrandTemplate,
|
|
getFactCard,
|
|
getLatestOptimizedArticle,
|
|
getQaReport,
|
|
listBrandTemplates,
|
|
saveFactCard,
|
|
saveOptimizedArticle,
|
|
saveQaReport,
|
|
updateArticleJob,
|
|
type ArticleJob,
|
|
type NewArticleJob,
|
|
type NewBrandTemplate,
|
|
} from "./repositories";
|
|
|
|
export function createSqliteRepository(dbPath?: string): AppRepository {
|
|
return {
|
|
createBrandTemplate(input: NewBrandTemplate) {
|
|
return Promise.resolve(createBrandTemplate(dbPath, input));
|
|
},
|
|
listBrandTemplates() {
|
|
return Promise.resolve(listBrandTemplates(dbPath));
|
|
},
|
|
getBrandTemplate(id: string) {
|
|
return Promise.resolve(getBrandTemplate(dbPath, id));
|
|
},
|
|
createArticleJob(input: NewArticleJob) {
|
|
return Promise.resolve(createArticleJob(dbPath, input));
|
|
},
|
|
getArticleJob(id: string) {
|
|
return Promise.resolve(getArticleJob(dbPath, id));
|
|
},
|
|
updateArticleJob(
|
|
id: string,
|
|
changes: Partial<Pick<ArticleJob, "brand_template_id" | "status" | "export_paths">>,
|
|
) {
|
|
return Promise.resolve(updateArticleJob(dbPath, id, changes));
|
|
},
|
|
saveFactCard(jobId: string, factCard: ConfirmedFactCard) {
|
|
return Promise.resolve(saveFactCard(dbPath, jobId, factCard));
|
|
},
|
|
getFactCard(jobId: string) {
|
|
return Promise.resolve(getFactCard(dbPath, jobId));
|
|
},
|
|
saveOptimizedArticle(jobId: string, article: OptimizedArticle) {
|
|
return Promise.resolve(saveOptimizedArticle(dbPath, jobId, article));
|
|
},
|
|
getLatestOptimizedArticle(jobId: string) {
|
|
return Promise.resolve(getLatestOptimizedArticle(dbPath, jobId));
|
|
},
|
|
saveQaReport(jobId: string, revision: number, report: QaReport) {
|
|
return Promise.resolve(saveQaReport(dbPath, jobId, revision, report));
|
|
},
|
|
getQaReport(jobId: string, revision: number) {
|
|
return Promise.resolve(getQaReport(dbPath, jobId, revision));
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run repository adapter test and verify GREEN**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/db/__tests__/repository.test.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
## Task 5: Add D1 Repository
|
|
|
|
**Files:**
|
|
- Create: `src/lib/db/d1-repository.ts`
|
|
- Test: `src/lib/db/__tests__/d1-repository.test.ts`
|
|
|
|
- [ ] **Step 1: Write D1 repository tests with a fake D1 database**
|
|
|
|
Create `src/lib/db/__tests__/d1-repository.test.ts` with a fake prepared-statement database that records SQL and returns controlled rows:
|
|
|
|
```typescript
|
|
import { describe, expect, test, vi } from "vitest";
|
|
|
|
import { createD1Repository } from "../d1-repository";
|
|
|
|
describe("createD1Repository", () => {
|
|
test("creates an article job using D1 prepare and bind", async () => {
|
|
const run = vi.fn().mockResolvedValue({ success: true });
|
|
const bind = vi.fn().mockReturnValue({ run });
|
|
const prepare = vi.fn().mockReturnValue({ bind });
|
|
const db = { prepare } as unknown as D1Database;
|
|
|
|
const repository = createD1Repository(db);
|
|
|
|
const job = await repository.createArticleJob({
|
|
source_title: "Title",
|
|
source_body: "Body",
|
|
image_inputs: [],
|
|
publish_platform: "official_site",
|
|
user_instructions: "",
|
|
});
|
|
|
|
expect(job.id).toMatch(/^job_/);
|
|
expect(prepare).toHaveBeenCalledWith(expect.stringContaining("insert into article_jobs"));
|
|
expect(bind).toHaveBeenCalledWith(
|
|
job.id,
|
|
null,
|
|
"Title",
|
|
"Body",
|
|
"[]",
|
|
"official_site",
|
|
"",
|
|
"draft",
|
|
"{}",
|
|
job.created_at,
|
|
job.updated_at,
|
|
);
|
|
});
|
|
|
|
test("parses article job JSON fields returned from D1", async () => {
|
|
const first = vi.fn().mockResolvedValue({
|
|
id: "job_123",
|
|
brand_template_id: null,
|
|
source_title: "Title",
|
|
source_body: "Body",
|
|
image_inputs: "[]",
|
|
publish_platform: "official_site",
|
|
user_instructions: "",
|
|
status: "draft",
|
|
export_paths: "{}",
|
|
created_at: "2026-06-16T00:00:00.000Z",
|
|
updated_at: "2026-06-16T00:00:00.000Z",
|
|
});
|
|
const bind = vi.fn().mockReturnValue({ first });
|
|
const prepare = vi.fn().mockReturnValue({ bind });
|
|
const db = { prepare } as unknown as D1Database;
|
|
|
|
const repository = createD1Repository(db);
|
|
|
|
await expect(repository.getArticleJob("job_123")).resolves.toMatchObject({
|
|
id: "job_123",
|
|
image_inputs: [],
|
|
export_paths: {},
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run D1 repository tests and verify RED**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/db/__tests__/d1-repository.test.ts
|
|
```
|
|
|
|
Expected: FAIL because `d1-repository` does not exist.
|
|
|
|
- [ ] **Step 3: Implement D1 repository**
|
|
|
|
Create `src/lib/db/d1-repository.ts` with the full async D1 implementation below. Use `prepare(...).bind(...).run()`, `first<T>()`, and `all<T>()`. Do not include schema creation or destructive SQL in this file.
|
|
|
|
The implementation must include these helpers:
|
|
|
|
```typescript
|
|
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;
|
|
}
|
|
```
|
|
|
|
The implementation must expose:
|
|
|
|
```typescript
|
|
export function createD1Repository(db: D1Database): AppRepository {
|
|
return {
|
|
async createBrandTemplate(input) {
|
|
const createdAt = nowIso();
|
|
const template = {
|
|
id: `brand_${nanoid(10)}`,
|
|
...input,
|
|
created_at: createdAt,
|
|
updated_at: createdAt,
|
|
};
|
|
|
|
await 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
)
|
|
.bind(
|
|
template.id,
|
|
template.brand_name,
|
|
template.company_full_name,
|
|
serialize(template.company_short_names),
|
|
serialize(template.product_names),
|
|
serialize(template.target_industries),
|
|
serialize(template.target_audience),
|
|
serialize(template.verified_claims),
|
|
serialize(template.forbidden_claims),
|
|
serialize(template.tone_rules),
|
|
template.created_at,
|
|
template.updated_at,
|
|
)
|
|
.run();
|
|
|
|
return template;
|
|
},
|
|
async listBrandTemplates() {
|
|
const result = await db
|
|
.prepare("select * from brand_templates order by updated_at desc")
|
|
.all<BrandTemplateRow>();
|
|
return result.results.map(toBrandTemplate);
|
|
},
|
|
async getBrandTemplate(id) {
|
|
const row = await db
|
|
.prepare("select * from brand_templates where id = ?")
|
|
.bind(id)
|
|
.first<BrandTemplateRow>();
|
|
return row ? toBrandTemplate(row) : null;
|
|
},
|
|
async createArticleJob(input) {
|
|
const createdAt = nowIso();
|
|
const job = {
|
|
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,
|
|
};
|
|
|
|
await 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
)
|
|
.bind(
|
|
job.id,
|
|
job.brand_template_id,
|
|
job.source_title,
|
|
job.source_body,
|
|
serialize(job.image_inputs),
|
|
job.publish_platform,
|
|
job.user_instructions,
|
|
job.status,
|
|
serialize(job.export_paths),
|
|
job.created_at,
|
|
job.updated_at,
|
|
)
|
|
.run();
|
|
|
|
return job;
|
|
},
|
|
async getArticleJob(id) {
|
|
const row = await db
|
|
.prepare("select * from article_jobs where id = ?")
|
|
.bind(id)
|
|
.first<ArticleJobRow>();
|
|
return row ? toArticleJob(row) : null;
|
|
},
|
|
async updateArticleJob(id, changes) {
|
|
const existing = await this.getArticleJob(id);
|
|
if (!existing) return null;
|
|
const updated = {
|
|
brand_template_id: changes.brand_template_id ?? existing.brand_template_id,
|
|
status: changes.status ?? existing.status,
|
|
export_paths: changes.export_paths ?? existing.export_paths,
|
|
updated_at: nowIso(),
|
|
};
|
|
await db
|
|
.prepare(
|
|
`update article_jobs set
|
|
brand_template_id = ?,
|
|
status = ?,
|
|
export_paths = ?,
|
|
updated_at = ?
|
|
where id = ?`,
|
|
)
|
|
.bind(
|
|
updated.brand_template_id,
|
|
updated.status,
|
|
serialize(updated.export_paths),
|
|
updated.updated_at,
|
|
id,
|
|
)
|
|
.run();
|
|
return this.getArticleJob(id);
|
|
},
|
|
async saveFactCard(jobId, factCard) {
|
|
const timestamp = nowIso();
|
|
await db
|
|
.prepare(
|
|
`insert into fact_cards (
|
|
job_id, source, fact_card, confirmed_by_user, created_at, updated_at
|
|
) values (?, ?, ?, ?, ?, ?)
|
|
on conflict(job_id) do update set
|
|
fact_card = excluded.fact_card,
|
|
confirmed_by_user = excluded.confirmed_by_user,
|
|
updated_at = excluded.updated_at`,
|
|
)
|
|
.bind(
|
|
jobId,
|
|
"auto_extract_then_user_confirmed",
|
|
serialize(factCard),
|
|
factCard.confirmed_by_user ? 1 : 0,
|
|
timestamp,
|
|
timestamp,
|
|
)
|
|
.run();
|
|
return { job_id: jobId, ...factCard };
|
|
},
|
|
async getFactCard(jobId) {
|
|
const row = await db
|
|
.prepare("select job_id, fact_card from fact_cards where job_id = ?")
|
|
.bind(jobId)
|
|
.first<FactCardRow>();
|
|
return row ? { job_id: row.job_id, ...parseJson<ConfirmedFactCard>(row.fact_card) } : null;
|
|
},
|
|
async saveOptimizedArticle(jobId, article) {
|
|
const latest = await this.getLatestOptimizedArticle(jobId);
|
|
const revision = (latest?.revision ?? 0) + 1;
|
|
const saved = { ...article, revision };
|
|
await db
|
|
.prepare(
|
|
`insert into optimized_articles (job_id, revision, article, created_at)
|
|
values (?, ?, ?, ?)`,
|
|
)
|
|
.bind(jobId, revision, serialize(saved), nowIso())
|
|
.run();
|
|
return saved;
|
|
},
|
|
async getLatestOptimizedArticle(jobId) {
|
|
const row = await db
|
|
.prepare(
|
|
`select job_id, revision, article
|
|
from optimized_articles
|
|
where job_id = ?
|
|
order by revision desc
|
|
limit 1`,
|
|
)
|
|
.bind(jobId)
|
|
.first<OptimizedArticleRow>();
|
|
return row ? { ...parseJson<OptimizedArticle>(row.article), revision: row.revision } : null;
|
|
},
|
|
async saveQaReport(jobId, revision, report) {
|
|
await db
|
|
.prepare(
|
|
`insert into qa_reports (job_id, revision, report, created_at)
|
|
values (?, ?, ?, ?)
|
|
on conflict(job_id, revision) do update set report = excluded.report`,
|
|
)
|
|
.bind(jobId, revision, serialize(report), nowIso())
|
|
.run();
|
|
return report;
|
|
},
|
|
async getQaReport(jobId, revision) {
|
|
const row = await db
|
|
.prepare("select job_id, revision, report from qa_reports where job_id = ? and revision = ?")
|
|
.bind(jobId, revision)
|
|
.first<QaReportRow>();
|
|
return row ? parseJson<QaReport>(row.report) : null;
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run D1 repository tests and verify GREEN**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/db/__tests__/d1-repository.test.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
## Task 6: Add Runtime Repository Selection
|
|
|
|
**Files:**
|
|
- Create: `src/lib/runtime/cloudflare.ts`
|
|
- Modify: `src/lib/db/repository.ts`
|
|
- Test: `src/lib/db/__tests__/repository-selection.test.ts`
|
|
|
|
- [ ] **Step 1: Write runtime selection tests**
|
|
|
|
Create `src/lib/db/__tests__/repository-selection.test.ts`:
|
|
|
|
```typescript
|
|
import { describe, expect, test } from "vitest";
|
|
|
|
import { getRepositoryFromRuntime } from "../repository";
|
|
|
|
describe("getRepositoryFromRuntime", () => {
|
|
test("returns local repository when APP_RUNTIME is not cloudflare", () => {
|
|
const repository = getRepositoryFromRuntime({
|
|
appRuntime: "local",
|
|
dbPath: ":memory:",
|
|
});
|
|
|
|
expect(repository).toBeDefined();
|
|
});
|
|
|
|
test("throws clearly for cloudflare runtime without D1 binding", () => {
|
|
expect(() =>
|
|
getRepositoryFromRuntime({
|
|
appRuntime: "cloudflare",
|
|
cloudflareEnv: {},
|
|
}),
|
|
).toThrow("Cloudflare D1 binding DB is required");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run runtime selection tests and verify RED**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/db/__tests__/repository-selection.test.ts
|
|
```
|
|
|
|
Expected: FAIL because `getRepositoryFromRuntime` does not exist.
|
|
|
|
- [ ] **Step 3: Implement runtime access helper**
|
|
|
|
Create `src/lib/runtime/cloudflare.ts`:
|
|
|
|
```typescript
|
|
import { getCloudflareContext } from "@opennextjs/cloudflare";
|
|
|
|
export interface AppCloudflareEnv {
|
|
DB?: D1Database;
|
|
EXPORT_BUCKET?: R2Bucket;
|
|
API_ACCESS_KEY?: string;
|
|
}
|
|
|
|
export function getAppCloudflareEnv(): AppCloudflareEnv | null {
|
|
if (process.env.APP_RUNTIME !== "cloudflare") {
|
|
return null;
|
|
}
|
|
|
|
return getCloudflareContext().env as AppCloudflareEnv;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Implement repository selection**
|
|
|
|
Append to `src/lib/db/repository.ts`:
|
|
|
|
```typescript
|
|
import { getAppCloudflareEnv, type AppCloudflareEnv } from "../runtime/cloudflare";
|
|
import { createD1Repository } from "./d1-repository";
|
|
import { createSqliteRepository } from "./sqlite-repository";
|
|
|
|
interface RuntimeRepositoryOptions {
|
|
appRuntime?: string;
|
|
dbPath?: string;
|
|
cloudflareEnv?: AppCloudflareEnv;
|
|
}
|
|
|
|
export function getRepositoryFromRuntime(options: RuntimeRepositoryOptions = {}): AppRepository {
|
|
const appRuntime = options.appRuntime ?? process.env.APP_RUNTIME;
|
|
if (appRuntime === "cloudflare") {
|
|
const env = options.cloudflareEnv ?? getAppCloudflareEnv();
|
|
if (!env?.DB) {
|
|
throw new Error("Cloudflare D1 binding DB is required");
|
|
}
|
|
return createD1Repository(env.DB);
|
|
}
|
|
|
|
return createSqliteRepository(options.dbPath);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run runtime selection tests and verify GREEN**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/db/__tests__/repository-selection.test.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
## Task 7: Add Local And R2 Export Stores
|
|
|
|
**Files:**
|
|
- Modify: `src/lib/workflow/exporter.ts`
|
|
- Create: `src/lib/workflow/export-store.ts`
|
|
- Test: `src/lib/workflow/__tests__/export-store.test.ts`
|
|
|
|
- [ ] **Step 1: Write export-store tests**
|
|
|
|
Create `src/lib/workflow/__tests__/export-store.test.ts`:
|
|
|
|
```typescript
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
|
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
|
|
import type { OptimizedArticle, QaReport } from "../../domain/types";
|
|
import { createLocalExportStore, createR2ExportStore } from "../export-store";
|
|
|
|
const article: OptimizedArticle = {
|
|
title: "Example",
|
|
summary: "Summary",
|
|
body_markdown: "Body",
|
|
image_suggestions: [],
|
|
requires_user_confirmation: [],
|
|
revision: 1,
|
|
};
|
|
|
|
const report: QaReport = {
|
|
overall_status: "pass",
|
|
checks: [],
|
|
blocked_reasons: [],
|
|
};
|
|
|
|
describe("export stores", () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = mkdtempSync(join(tmpdir(), "geo-export-store-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("local store writes and reads exports", async () => {
|
|
const store = createLocalExportStore(tempDir);
|
|
|
|
const paths = await store.writeJobExports({
|
|
jobId: "job_123",
|
|
article,
|
|
qaReport: report,
|
|
});
|
|
|
|
expect(paths.markdown).toContain("optimized.md");
|
|
const file = await store.readJobExport("job_123", "optimized.md");
|
|
expect(await file?.text()).toContain("# Example");
|
|
});
|
|
|
|
test("R2 store writes private export objects through binding", async () => {
|
|
const put = vi.fn().mockResolvedValue(undefined);
|
|
const bucket = { put } as unknown as R2Bucket;
|
|
const store = createR2ExportStore(bucket);
|
|
|
|
const paths = await store.writeJobExports({
|
|
jobId: "job_123",
|
|
article,
|
|
qaReport: report,
|
|
});
|
|
|
|
expect(paths.markdown).toBe("r2://exports/job_123/optimized.md");
|
|
expect(put).toHaveBeenCalledWith(
|
|
"exports/job_123/optimized.md",
|
|
expect.any(String),
|
|
expect.objectContaining({
|
|
httpMetadata: { contentType: "text/markdown; charset=utf-8" },
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run export-store tests and verify RED**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/workflow/__tests__/export-store.test.ts
|
|
```
|
|
|
|
Expected: FAIL because `export-store` does not exist.
|
|
|
|
- [ ] **Step 3: Implement export-store boundary**
|
|
|
|
Create `src/lib/workflow/export-store.ts`:
|
|
|
|
```typescript
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
import { getAppDataDir } from "../db/connection";
|
|
import type { OptimizedArticle, QaReport } from "../domain/types";
|
|
import {
|
|
renderOptimizedDocx,
|
|
renderOptimizedMarkdown,
|
|
renderQaReportJson,
|
|
} from "./exporter";
|
|
|
|
const CONTENT_TYPES: Record<string, string> = {
|
|
"optimized.md": "text/markdown; charset=utf-8",
|
|
"optimized.docx":
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
"qa_report.json": "application/json; charset=utf-8",
|
|
};
|
|
|
|
export interface WriteJobExportsInput {
|
|
jobId: string;
|
|
article: OptimizedArticle;
|
|
qaReport: QaReport;
|
|
}
|
|
|
|
export interface ExportFile {
|
|
body: BodyInit;
|
|
contentType: string;
|
|
}
|
|
|
|
export interface ExportStore {
|
|
writeJobExports(input: WriteJobExportsInput): Promise<Record<string, string>>;
|
|
readJobExport(jobId: string, fileName: string): Promise<Response | null>;
|
|
}
|
|
|
|
export function createLocalExportStore(dataDir = getAppDataDir()): ExportStore {
|
|
return {
|
|
async writeJobExports({ jobId, article, qaReport }) {
|
|
const exportDir = join(dataDir, "exports", jobId);
|
|
mkdirSync(exportDir, { recursive: true });
|
|
|
|
const markdown = join(exportDir, "optimized.md");
|
|
const docx = join(exportDir, "optimized.docx");
|
|
const qaJson = join(exportDir, "qa_report.json");
|
|
|
|
writeFileSync(markdown, renderOptimizedMarkdown(article), "utf8");
|
|
writeFileSync(qaJson, renderQaReportJson(qaReport), "utf8");
|
|
writeFileSync(docx, await renderOptimizedDocx(article));
|
|
|
|
return { markdown, docx, qaJson };
|
|
},
|
|
async readJobExport(jobId, fileName) {
|
|
const contentType = CONTENT_TYPES[fileName];
|
|
if (!contentType) return null;
|
|
|
|
const path = join(dataDir, "exports", jobId, fileName);
|
|
if (!existsSync(path)) return null;
|
|
|
|
return new Response(readFileSync(path), {
|
|
headers: {
|
|
"content-type": contentType,
|
|
"content-disposition": `attachment; filename="${fileName}"`,
|
|
},
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createR2ExportStore(bucket: R2Bucket): ExportStore {
|
|
return {
|
|
async writeJobExports({ jobId, article, qaReport }) {
|
|
const prefix = `exports/${jobId}`;
|
|
const markdownKey = `${prefix}/optimized.md`;
|
|
const docxKey = `${prefix}/optimized.docx`;
|
|
const qaJsonKey = `${prefix}/qa_report.json`;
|
|
|
|
await bucket.put(markdownKey, renderOptimizedMarkdown(article), {
|
|
httpMetadata: { contentType: CONTENT_TYPES["optimized.md"] },
|
|
});
|
|
await bucket.put(qaJsonKey, renderQaReportJson(qaReport), {
|
|
httpMetadata: { contentType: CONTENT_TYPES["qa_report.json"] },
|
|
});
|
|
await bucket.put(docxKey, await renderOptimizedDocx(article), {
|
|
httpMetadata: { contentType: CONTENT_TYPES["optimized.docx"] },
|
|
});
|
|
|
|
return {
|
|
markdown: `r2://${markdownKey}`,
|
|
docx: `r2://${docxKey}`,
|
|
qaJson: `r2://${qaJsonKey}`,
|
|
};
|
|
},
|
|
async readJobExport(jobId, fileName) {
|
|
const contentType = CONTENT_TYPES[fileName];
|
|
if (!contentType) return null;
|
|
|
|
const object = await bucket.get(`exports/${jobId}/${fileName}`);
|
|
if (!object) return null;
|
|
|
|
return new Response(object.body, {
|
|
headers: {
|
|
"content-type": object.httpMetadata?.contentType ?? contentType,
|
|
"content-disposition": `attachment; filename="${fileName}"`,
|
|
},
|
|
});
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run export-store tests and verify GREEN**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/workflow/__tests__/export-store.test.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
## Task 8: Wire Routes To Auth, Repository, And Export Store
|
|
|
|
**Files:**
|
|
- Modify: `src/app/api/jobs/route.ts`
|
|
- Modify: `src/app/api/jobs/[jobId]/confirm-fact-card/route.ts`
|
|
- Modify: `src/app/api/jobs/[jobId]/optimize/route.ts`
|
|
- Modify: `src/app/api/jobs/[jobId]/exports/[fileName]/route.ts`
|
|
- Test: `src/app/api/__tests__/jobs.test.ts`
|
|
|
|
- [ ] **Step 1: Update API tests for API key enforcement**
|
|
|
|
In `src/app/api/__tests__/jobs.test.ts`, add tests that call the route without `x-api-key` and expect `401`, then call with `x-api-key` and expect the existing behavior. In test setup set:
|
|
|
|
```typescript
|
|
const originalApiKey = process.env.API_ACCESS_KEY;
|
|
const originalAuthDisabled = process.env.API_AUTH_DISABLED;
|
|
|
|
beforeEach(() => {
|
|
process.env.API_ACCESS_KEY = "test-key";
|
|
process.env.API_AUTH_DISABLED = "false";
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env.API_ACCESS_KEY = originalApiKey;
|
|
process.env.API_AUTH_DISABLED = originalAuthDisabled;
|
|
});
|
|
```
|
|
|
|
For existing successful route calls, construct requests with:
|
|
|
|
```typescript
|
|
headers: { "x-api-key": "test-key" }
|
|
```
|
|
|
|
- [ ] **Step 2: Run API tests and verify RED**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/app/api/__tests__/jobs.test.ts
|
|
```
|
|
|
|
Expected: FAIL because routes do not call `requireApiAccess` yet.
|
|
|
|
- [ ] **Step 3: Update route handlers**
|
|
|
|
In every API route, add this pattern at the start of each handler:
|
|
|
|
```typescript
|
|
const access = requireApiAccess(request);
|
|
if (!access.ok) {
|
|
return access.response;
|
|
}
|
|
```
|
|
|
|
Use `_request` only in handlers that do not need auth; after this change every API route needs `request`.
|
|
|
|
Replace direct repository calls with:
|
|
|
|
```typescript
|
|
const repository = getRepositoryFromRuntime();
|
|
```
|
|
|
|
Then await calls:
|
|
|
|
```typescript
|
|
const job = await repository.getArticleJob(jobId);
|
|
```
|
|
|
|
In optimize route, replace `writeJobExports` with runtime export store:
|
|
|
|
```typescript
|
|
const exportStore = getExportStoreFromRuntime();
|
|
const exportPaths =
|
|
qaReport.overall_status === "fail"
|
|
? {}
|
|
: await exportStore.writeJobExports({
|
|
jobId,
|
|
article: optimizedArticle,
|
|
qaReport,
|
|
});
|
|
```
|
|
|
|
In export download route, replace filesystem reads with:
|
|
|
|
```typescript
|
|
const exportStore = getExportStoreFromRuntime();
|
|
const response = await exportStore.readJobExport(jobId, fileName);
|
|
if (!response) {
|
|
return NextResponse.json({ error: "Export file not found" }, { status: 404 });
|
|
}
|
|
return response;
|
|
```
|
|
|
|
- [ ] **Step 4: Add export-store runtime selector**
|
|
|
|
Append this function to `src/lib/workflow/export-store.ts`:
|
|
|
|
```typescript
|
|
import { getAppCloudflareEnv } from "../runtime/cloudflare";
|
|
|
|
export function getExportStoreFromRuntime(): ExportStore {
|
|
if (process.env.APP_RUNTIME === "cloudflare") {
|
|
const env = getAppCloudflareEnv();
|
|
if (!env?.EXPORT_BUCKET) {
|
|
throw new Error("Cloudflare R2 binding EXPORT_BUCKET is required");
|
|
}
|
|
return createR2ExportStore(env.EXPORT_BUCKET);
|
|
}
|
|
|
|
return createLocalExportStore();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run API tests and verify GREEN**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/app/api/__tests__/jobs.test.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
## Task 9: Document Manual Cloudflare Resource And Deployment Flow
|
|
|
|
**Files:**
|
|
- Modify: `README.md`
|
|
- Modify: `.env.example`
|
|
- Modify: `wrangler.jsonc`
|
|
|
|
- [ ] **Step 1: Update `.env.example`**
|
|
|
|
Replace the environment example with local-only values:
|
|
|
|
```text
|
|
LLM_PROVIDER=deepseek
|
|
DEEPSEEK_API_KEY=
|
|
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
|
DEEPSEEK_MODEL=deepseek-v4-pro
|
|
DEEPSEEK_THINKING=disabled
|
|
OPENAI_API_KEY=
|
|
OPENAI_MODEL=gpt-4.1-mini
|
|
APP_DATA_DIR=./data
|
|
API_ACCESS_KEY=local-dev-key
|
|
API_AUTH_DISABLED=false
|
|
```
|
|
|
|
- [ ] **Step 2: Update README deployment section**
|
|
|
|
Add a Cloudflare section with this content. Use four-space indentation for nested command examples so the README renders cleanly:
|
|
|
|
## Cloudflare Workers Deployment
|
|
|
|
Cloudflare deployment is manual. Pushing to Git does not deploy or hot-update
|
|
the production Worker.
|
|
|
|
Local development:
|
|
|
|
npm run dev
|
|
|
|
Cloudflare preview:
|
|
|
|
cp .dev.vars.example .dev.vars
|
|
npm run d1:migrate:local
|
|
npm run preview:worker
|
|
|
|
Create private staging resources:
|
|
|
|
npx wrangler d1 create geo-agent-article-optimizer-staging
|
|
npx wrangler r2 bucket create geo-agent-article-optimizer-staging
|
|
|
|
Create private production resources:
|
|
|
|
npx wrangler d1 create geo-agent-article-optimizer-production
|
|
npx wrangler r2 bucket create geo-agent-article-optimizer-production
|
|
|
|
After D1 creation, copy the returned database IDs into the matching
|
|
`wrangler.jsonc` environment entries. Keep R2 buckets private; do not add
|
|
public bucket domains.
|
|
|
|
Set secrets:
|
|
|
|
npx wrangler secret put API_ACCESS_KEY --env staging
|
|
npx wrangler secret put DEEPSEEK_API_KEY --env staging
|
|
npx wrangler secret put API_ACCESS_KEY --env production
|
|
npx wrangler secret put DEEPSEEK_API_KEY --env production
|
|
|
|
Migration order:
|
|
|
|
npm run d1:migrate:local
|
|
npm run d1:migrate:staging
|
|
npm run deploy:worker:staging
|
|
npm run d1:migrate:production
|
|
npm run deploy:worker:production
|
|
|
|
All API requests require:
|
|
|
|
x-api-key: <API_ACCESS_KEY>
|
|
|
|
- [ ] **Step 3: Verify docs mention no runtime schema mutation**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
rg -n "migrations|automatic|API_ACCESS_KEY|private|hot-update|hot update|x-api-key" README.md docs/superpowers/specs/2026-06-16-cloudflare-workers-deployment-design.md
|
|
```
|
|
|
|
Expected: output includes D1 migrations, private R2, manual deployment, and API key language.
|
|
|
|
- [ ] **Step 4: Leave remote env blocks out until real resource IDs exist**
|
|
|
|
If the executor cannot create Cloudflare resources in this session, keep `env` as `{}` in `wrangler.jsonc`. Do not commit fake staging or production `database_id` values. The README documents the exact commands for creating resources and adding remote env blocks after Wrangler returns real database IDs.
|
|
|
|
Expected: `wrangler.jsonc` contains no fake staging or production database IDs.
|
|
|
|
## Task 10: Full Verification
|
|
|
|
**Files:**
|
|
- All modified files
|
|
|
|
- [ ] **Step 1: Run focused tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/api/__tests__/auth.test.ts src/lib/db/__tests__/repository.test.ts src/lib/db/__tests__/repository-selection.test.ts src/lib/db/__tests__/d1-repository.test.ts src/lib/workflow/__tests__/export-store.test.ts src/app/api/__tests__/jobs.test.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 2: Run full test suite**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Run regular Next.js build**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run build
|
|
```
|
|
|
|
Expected: exit 0.
|
|
|
|
- [ ] **Step 4: Run Worker build**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run build:worker
|
|
```
|
|
|
|
Expected: exit 0 and `.open-next/worker.js` exists.
|
|
|
|
- [ ] **Step 5: Run Wrangler dry run**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npx wrangler deploy --dry-run
|
|
```
|
|
|
|
Expected: Wrangler validates the Worker bundle. If staging/production IDs are not configured yet, dry-run top-level local config still validates.
|
|
|
|
- [ ] **Step 6: Inspect for forbidden runtime schema changes**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
rg -n "CREATE TABLE|DROP TABLE|DELETE FROM|initializeSchema" src wrangler.jsonc migrations
|
|
```
|
|
|
|
Expected: `CREATE TABLE` only appears in `migrations/0001_initial_schema.sql` and local SQLite test/setup code. No Cloudflare runtime file creates, drops, or clears tables.
|
|
|
|
- [ ] **Step 7: Commit implementation**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
git add .gitignore .dev.vars.example README.md next.config.ts open-next.config.ts package.json package-lock.json public/_headers wrangler.jsonc migrations src docs/superpowers
|
|
git commit -m "feat: add cloudflare workers deployment path"
|
|
```
|
|
|
|
Expected: commit succeeds without staging unrelated `.DS_Store`.
|