fix(tasktree): serialize schema migrations

This commit is contained in:
root
2026-07-21 12:07:13 +02:00
parent 5dfea9b044
commit 90350d63e7
4 changed files with 255 additions and 32 deletions
+8 -1
View File
@@ -111,7 +111,14 @@ export function createTaskTreeDispatcher(options: TaskTreeDispatcherOptions) {
else throw codedError("unsupported_operation", `unsupported operation: ${String(operation)}`);
return { ok: true, operation, data };
} catch (error: any) {
return { ok: false, operation: command.operation, error: { code: error?.code ?? "tasktree_error", message: error?.message ?? String(error) } };
const migrationDetail = error?.tasktreeMigrationId
? { migrationId: String(error.tasktreeMigrationId), sqlState: error?.code ? String(error.code) : undefined }
: undefined;
return {
ok: false,
operation: command.operation,
error: { code: error?.code ?? "tasktree_error", message: error?.message ?? String(error), ...(migrationDetail ? { detail: migrationDetail } : {}) },
};
}
};
}
+95 -31
View File
@@ -11,58 +11,100 @@ import type { MdtodoImportPlan } from "./mdtodo-import.ts";
const { Pool } = pg;
const schema = [
`CREATE TABLE IF NOT EXISTS tasktree_schema_migrations (migration_id TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
`CREATE TABLE IF NOT EXISTS tasktree_groups (
id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
`CREATE TABLE IF NOT EXISTS tasktree_tasks (
id TEXT PRIMARY KEY, group_id TEXT NOT NULL REFERENCES tasktree_groups(id) ON DELETE CASCADE,
parent_id TEXT REFERENCES tasktree_tasks(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK (kind IN ('task','subtask','subsubtask')),
title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','in_progress','completed','blocked')),
start_at TIMESTAMPTZ, due_at TIMESTAMPTZ, sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (due_at IS NULL OR start_at IS NULL OR due_at >= start_at))`,
`CREATE TABLE IF NOT EXISTS tasktree_milestones (
id TEXT PRIMARY KEY, group_id TEXT NOT NULL REFERENCES tasktree_groups(id) ON DELETE CASCADE,
task_id TEXT REFERENCES tasktree_tasks(id) ON DELETE CASCADE, title TEXT NOT NULL,
occurs_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
`CREATE TABLE IF NOT EXISTS tasktree_execution_reports (
id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasktree_tasks(id) ON DELETE CASCADE,
title TEXT NOT NULL, body TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'succeeded',
created_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
`CREATE INDEX IF NOT EXISTS idx_tasktree_tasks_group_order ON tasktree_tasks(group_id, sort_order, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_tasktree_tasks_parent ON tasktree_tasks(parent_id)`,
`CREATE INDEX IF NOT EXISTS idx_tasktree_reports_task ON tasktree_execution_reports(task_id, created_at DESC)`
,`CREATE TABLE IF NOT EXISTS tasktree_backup_state (
type SchemaMigration = { id: string; statements: string[] };
const migrationLedgerStatement = `CREATE TABLE IF NOT EXISTS tasktree_schema_migrations (
migration_id TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
const schemaMigrations: SchemaMigration[] = [
{
id: "tasktree-20260716-v1",
statements: [
`CREATE TABLE IF NOT EXISTS tasktree_groups (
id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
`CREATE TABLE IF NOT EXISTS tasktree_tasks (
id TEXT PRIMARY KEY, group_id TEXT NOT NULL REFERENCES tasktree_groups(id) ON DELETE CASCADE,
parent_id TEXT REFERENCES tasktree_tasks(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK (kind IN ('task','subtask')),
title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','in_progress','completed','blocked')),
start_at TIMESTAMPTZ, due_at TIMESTAMPTZ, sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (due_at IS NULL OR start_at IS NULL OR due_at >= start_at))`,
`CREATE TABLE IF NOT EXISTS tasktree_milestones (
id TEXT PRIMARY KEY, group_id TEXT NOT NULL REFERENCES tasktree_groups(id) ON DELETE CASCADE,
task_id TEXT REFERENCES tasktree_tasks(id) ON DELETE CASCADE, title TEXT NOT NULL,
occurs_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
`CREATE TABLE IF NOT EXISTS tasktree_execution_reports (
id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasktree_tasks(id) ON DELETE CASCADE,
title TEXT NOT NULL, body TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'succeeded',
created_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
`CREATE INDEX IF NOT EXISTS idx_tasktree_tasks_group_order ON tasktree_tasks(group_id, sort_order, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_tasktree_tasks_parent ON tasktree_tasks(parent_id)`,
`CREATE INDEX IF NOT EXISTS idx_tasktree_reports_task ON tasktree_execution_reports(task_id, created_at DESC)`,
],
},
{ id: "tasktree-20260717-v2-subsubtask", statements: [
`ALTER TABLE tasktree_tasks DROP CONSTRAINT IF EXISTS tasktree_tasks_kind_check`,
`ALTER TABLE tasktree_tasks ADD CONSTRAINT tasktree_tasks_kind_check CHECK (kind IN ('task','subtask','subsubtask'))`,
] },
{ id: "tasktree-20260718-v3-github-backup", statements: [
`CREATE TABLE IF NOT EXISTS tasktree_backup_state (
target TEXT PRIMARY KEY, pending BOOLEAN NOT NULL DEFAULT false,
requested_at TIMESTAMPTZ, request_reason TEXT NOT NULL DEFAULT '',
last_attempt_at TIMESTAMPTZ, last_success_at TIMESTAMPTZ,
content_sha256 TEXT NOT NULL DEFAULT '', git_commit TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'never' CHECK (status IN ('never','pending','succeeded','failed')),
error_code TEXT NOT NULL DEFAULT '', error_message TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`
,`ALTER TABLE tasktree_tasks DROP CONSTRAINT IF EXISTS tasktree_tasks_kind_check`
,`ALTER TABLE tasktree_tasks ADD CONSTRAINT tasktree_tasks_kind_check CHECK (kind IN ('task','subtask','subsubtask'))`
updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
] },
];
const schemaMigrationIds = schemaMigrations.map((migration) => migration.id);
const schemaAdvisoryLockKeys = [0x48574c42, 0x54545245] as const;
export class TaskTreeStore {
private readonly pool: pg.Pool;
private ready = false;
private schemaPromise: Promise<void> | null = null;
constructor(databaseUrl: string) {
if (!databaseUrl) throw new Error("TASKTREE_DATABASE_URL or DATABASE_URL is required");
this.pool = new Pool({ connectionString: databaseUrl, max: 4, connectionTimeoutMillis: 5000, ssl: { rejectUnauthorized: false } });
this.pool = new Pool({ connectionString: databaseUrl, max: 4, connectionTimeoutMillis: 5000, ssl: taskTreePostgresSsl(databaseUrl) });
}
async ensureSchema() {
if (this.ready) return;
this.schemaPromise ??= this.migrateSchema();
try {
await this.schemaPromise;
} catch (error) {
this.schemaPromise = null;
throw error;
}
}
private async migrateSchema() {
if (await schemaIsCurrent(this.pool)) {
this.ready = true;
return;
}
const client = await this.pool.connect();
try {
await client.query("BEGIN");
for (const statement of schema) await client.query(statement);
await client.query("INSERT INTO tasktree_schema_migrations (migration_id) VALUES ($1),($2),($3) ON CONFLICT DO NOTHING", ["tasktree-20260716-v1", "tasktree-20260717-v2-subsubtask", "tasktree-20260718-v3-github-backup"]);
await client.query("SELECT pg_advisory_xact_lock($1, $2)", [...schemaAdvisoryLockKeys]);
const ledger = await client.query("SELECT to_regclass('tasktree_schema_migrations') AS relation");
if (ledger.rows[0]?.relation == null) await client.query(migrationLedgerStatement);
const appliedResult = await client.query("SELECT migration_id FROM tasktree_schema_migrations");
const applied = new Set(appliedResult.rows.map((row) => String(row.migration_id)));
for (const migration of schemaMigrations) {
if (applied.has(migration.id)) continue;
try {
for (const statement of migration.statements) await client.query(statement);
await client.query("INSERT INTO tasktree_schema_migrations (migration_id) VALUES ($1)", [migration.id]);
} catch (error) {
if (error && typeof error === "object") Object.assign(error, { tasktreeMigrationId: migration.id });
throw error;
}
}
await client.query("COMMIT");
this.ready = true;
} catch (error) {
@@ -581,6 +623,21 @@ export function projectTimelineTasks(tasks: TaskItem[], reports: ExecutionReport
});
}
async function schemaIsCurrent(pool: pg.Pool): Promise<boolean> {
const relation = await pool.query("SELECT to_regclass('tasktree_schema_migrations') AS relation");
if (relation.rows[0]?.relation == null) return false;
try {
const applied = await pool.query(
"SELECT COUNT(*)::int AS count FROM tasktree_schema_migrations WHERE migration_id = ANY($1::text[])",
[schemaMigrationIds],
);
return Number(applied.rows[0]?.count ?? 0) === schemaMigrationIds.length;
} catch (error: any) {
if (error?.code === "42P01") return false;
throw error;
}
}
function groupRow(row: any): TaskGroup { return { id: row.id, name: row.name, description: row.description, createdAt: iso(row.created_at), updatedAt: iso(row.updated_at) }; }
function overviewRow(row: any): TaskGroupOverview { return { group: groupRow(row), taskCount: Number(row.task_count), subtaskCount: Number(row.subtask_count), subsubtaskCount: Number(row.subsubtask_count), reportCount: Number(row.report_count), startAt: nullableIso(row.start_at), dueAt: nullableIso(row.due_at) }; }
function taskRow(row: any): TaskItem { return { id: row.id, groupId: row.group_id, parentId: row.parent_id, kind: row.kind, title: row.title, description: row.description, status: row.status, startAt: nullableIso(row.start_at), dueAt: nullableIso(row.due_at), sortOrder: row.sort_order, createdAt: iso(row.created_at), updatedAt: iso(row.updated_at) }; }
@@ -605,6 +662,13 @@ function backupStateRow(row: any): TaskTreeBackupState {
function iso(value: unknown): string { return new Date(value as any).toISOString(); }
function nullableIso(value: unknown): string | null { return value == null ? null : iso(value); }
function domainError(code: string, message: string) { return Object.assign(new Error(message), { code }); }
function taskTreePostgresSsl(databaseUrl: string) {
try {
return new URL(databaseUrl).searchParams.get("sslmode") === "disable" ? false : { rejectUnauthorized: false };
} catch {
return { rejectUnauthorized: false };
}
}
function ensureTimeRange(startAt: string | null, dueAt: string | null) {
if (startAt !== null && dueAt !== null && new Date(startAt).valueOf() > new Date(dueAt).valueOf()) {
throw domainError("invalid_time_range", "startAt must be before or equal to dueAt");