mirror of
https://github.com/tiennm99/agentic-inbox.git
synced 2026-05-20 20:23:24 +00:00
172 lines
5.4 KiB
TypeScript
172 lines
5.4 KiB
TypeScript
// Copyright (c) 2026 Cloudflare, Inc.
|
|
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
|
|
// https://opensource.org/licenses/Apache-2.0
|
|
|
|
export interface Migration {
|
|
name: string;
|
|
sql: string;
|
|
}
|
|
|
|
/**
|
|
* Minimal migration runner that replaces workers-qb's DOQB.migrations().apply().
|
|
*
|
|
* Uses the `d1_migrations` tracking table for backward compatibility with
|
|
* existing deployments that were managed by workers-qb. New deployments
|
|
* create the same table so the schema is consistent either way.
|
|
*/
|
|
export function applyMigrations(
|
|
sql: SqlStorage,
|
|
migrations: Migration[],
|
|
storage?: DurableObjectStorage,
|
|
): void {
|
|
sql.exec(`CREATE TABLE IF NOT EXISTS d1_migrations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)`);
|
|
|
|
for (const migration of migrations) {
|
|
const applied = [
|
|
...sql.exec(
|
|
`SELECT 1 FROM d1_migrations WHERE name = ?`,
|
|
migration.name,
|
|
),
|
|
];
|
|
if (applied.length > 0) continue;
|
|
|
|
// Strip any existing BEGIN/COMMIT wrapper from the migration SQL.
|
|
// Cloudflare's DO runtime forbids SQL-level transactions -- must use
|
|
// the JS storage.transactionSync() API instead.
|
|
let migrationSql = migration.sql.trim();
|
|
migrationSql = migrationSql.replace(/^\s*BEGIN\s+TRANSACTION\s*;?\s*/i, "");
|
|
migrationSql = migrationSql.replace(/\s*COMMIT\s*;?\s*$/i, "");
|
|
|
|
const escapedName = migration.name.replace(/'/g, "''");
|
|
const run = () => {
|
|
sql.exec(migrationSql);
|
|
sql.exec(
|
|
`INSERT INTO d1_migrations (name) VALUES ('${escapedName}')`,
|
|
);
|
|
};
|
|
|
|
if (storage) {
|
|
// Preferred: atomic transaction via the DO JS API
|
|
storage.transactionSync(run);
|
|
} else {
|
|
// Fallback: run without explicit transaction (each exec is auto-committed)
|
|
run();
|
|
}
|
|
}
|
|
}
|
|
|
|
interface DurableObjectStorage {
|
|
transactionSync: <T>(closure: () => T) => T;
|
|
}
|
|
|
|
/**
|
|
* Wrap SQL in a transaction so multi-statement migrations are atomic.
|
|
*
|
|
* Without this, a migration like `1_initial_setup` (CREATE + INSERT +
|
|
* CREATE + CREATE) could fail mid-way and leave the database in an
|
|
* inconsistent state that the runner considers "applied" but is
|
|
* actually broken. SQLite transactions guarantee all-or-nothing.
|
|
*
|
|
* Single-statement migrations don't strictly need it but wrapping
|
|
* uniformly costs nothing and avoids accidental omissions.
|
|
*/
|
|
function txn(sql: string): string {
|
|
const trimmed = sql.trim();
|
|
// Don't double-wrap if someone already added BEGIN/COMMIT
|
|
if (/^\s*BEGIN\b/i.test(trimmed)) return trimmed;
|
|
return `BEGIN TRANSACTION;\n${trimmed}\nCOMMIT;`;
|
|
}
|
|
|
|
export const mailboxMigrations: Migration[] = [
|
|
{
|
|
name: "1_initial_setup",
|
|
sql: txn(`
|
|
CREATE TABLE folders (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
is_deletable INTEGER NOT NULL DEFAULT 1
|
|
);
|
|
|
|
INSERT INTO folders (id, name, is_deletable) VALUES
|
|
('inbox', 'Inbox', 0),
|
|
('sent', 'Sent', 0),
|
|
('trash', 'Trash', 0),
|
|
('archive', 'Archive', 0),
|
|
('spam', 'Spam', 0);
|
|
|
|
CREATE TABLE emails (
|
|
id TEXT PRIMARY KEY,
|
|
folder_id TEXT NOT NULL,
|
|
subject TEXT,
|
|
sender TEXT,
|
|
recipient TEXT,
|
|
date TEXT,
|
|
read INTEGER DEFAULT 0,
|
|
starred INTEGER DEFAULT 0,
|
|
body TEXT,
|
|
FOREIGN KEY(folder_id) REFERENCES folders(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE attachments (
|
|
id TEXT PRIMARY KEY,
|
|
email_id TEXT NOT NULL,
|
|
filename TEXT NOT NULL,
|
|
mimetype TEXT NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
content_id TEXT,
|
|
disposition TEXT,
|
|
FOREIGN KEY(email_id) REFERENCES emails(id) ON DELETE CASCADE
|
|
);
|
|
`),
|
|
},
|
|
{
|
|
name: "2_add_email_threading",
|
|
sql: txn(`
|
|
ALTER TABLE emails ADD COLUMN in_reply_to TEXT;
|
|
ALTER TABLE emails ADD COLUMN email_references TEXT;
|
|
ALTER TABLE emails ADD COLUMN thread_id TEXT;
|
|
|
|
CREATE INDEX idx_emails_thread_id ON emails(thread_id);
|
|
CREATE INDEX idx_emails_in_reply_to ON emails(in_reply_to);
|
|
`),
|
|
},
|
|
{
|
|
name: "3_add_draft_folder",
|
|
sql: txn(`INSERT INTO folders (id, name, is_deletable) VALUES ('draft', 'Drafts', 0);`),
|
|
},
|
|
{
|
|
name: "4_add_message_id",
|
|
sql: txn(`ALTER TABLE emails ADD COLUMN message_id TEXT;`),
|
|
},
|
|
{
|
|
name: "5_add_raw_headers",
|
|
sql: txn(`ALTER TABLE emails ADD COLUMN raw_headers TEXT;`),
|
|
},
|
|
{
|
|
name: "6_mark_sent_emails_as_read",
|
|
sql: txn(`UPDATE emails SET read = 1 WHERE folder_id = 'sent' AND read = 0;`),
|
|
},
|
|
{
|
|
name: "7_add_cc_bcc",
|
|
sql: txn(`
|
|
ALTER TABLE emails ADD COLUMN cc TEXT;
|
|
ALTER TABLE emails ADD COLUMN bcc TEXT;
|
|
`),
|
|
},
|
|
{
|
|
// No txn() wrapper: Cloudflare's DO runtime requires state.storage.transactionSync()
|
|
// instead of SQL-level BEGIN TRANSACTION. These are idempotent CREATE INDEX IF NOT EXISTS
|
|
// statements so they're safe to run without a transaction.
|
|
name: "8_add_folder_date_indexes",
|
|
sql: `
|
|
CREATE INDEX IF NOT EXISTS idx_emails_folder_id ON emails(folder_id);
|
|
CREATE INDEX IF NOT EXISTS idx_emails_date ON emails(date);
|
|
CREATE INDEX IF NOT EXISTS idx_emails_folder_date ON emails(folder_id, date DESC);
|
|
`,
|
|
},
|
|
];
|