Add issue identifiers, activity run tracking, and migration inspection
Add issuePrefix/issueCounter to companies and issueNumber/identifier to issues for human-readable issue IDs (e.g. PAP-42). Add runId to activity_log for linking activity to heartbeat runs. Rework DB client to support migration state inspection and interactive pending migration prompts at startup. Add reopen option to issue comments validator. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,178 @@
|
||||
import { drizzle as drizzlePg } from "drizzle-orm/postgres-js";
|
||||
import { migrate as migratePg } from "drizzle-orm/postgres-js/migrator";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema/index.js";
|
||||
|
||||
const MIGRATIONS_FOLDER = new URL("./migrations", import.meta.url).pathname;
|
||||
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
|
||||
const MIGRATIONS_JOURNAL_JSON = new URL("./migrations/meta/_journal.json", import.meta.url).pathname;
|
||||
|
||||
function isSafeIdentifier(value: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
||||
}
|
||||
|
||||
function quoteIdentifier(value: string): string {
|
||||
if (!isSafeIdentifier(value)) throw new Error(`Unsafe SQL identifier: ${value}`);
|
||||
return `"${value.replaceAll("\"", "\"\"")}"`;
|
||||
}
|
||||
|
||||
export type MigrationState =
|
||||
| { status: "upToDate"; tableCount: number; availableMigrations: string[]; appliedMigrations: string[] }
|
||||
| {
|
||||
status: "needsMigrations";
|
||||
tableCount: number;
|
||||
availableMigrations: string[];
|
||||
appliedMigrations: string[];
|
||||
pendingMigrations: string[];
|
||||
reason: "no-migration-journal-empty-db" | "no-migration-journal-non-empty-db" | "pending-migrations";
|
||||
};
|
||||
|
||||
export function createDb(url: string) {
|
||||
const sql = postgres(url);
|
||||
return drizzlePg(sql, { schema });
|
||||
}
|
||||
|
||||
async function listMigrationFiles(): Promise<string[]> {
|
||||
const entries = await readdir(MIGRATIONS_FOLDER, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".sql"))
|
||||
.map((entry) => entry.name)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
type MigrationJournalFile = {
|
||||
entries?: Array<{ tag?: string }>;
|
||||
};
|
||||
|
||||
async function listJournalMigrationFiles(): Promise<string[]> {
|
||||
try {
|
||||
const raw = await readFile(MIGRATIONS_JOURNAL_JSON, "utf8");
|
||||
const parsed = JSON.parse(raw) as MigrationJournalFile;
|
||||
if (!Array.isArray(parsed.entries)) return [];
|
||||
return parsed.entries
|
||||
.map((entry) => (typeof entry?.tag === "string" ? `${entry.tag}.sql` : null))
|
||||
.filter((name): name is string => typeof name === "string");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAppliedMigrations(
|
||||
sql: ReturnType<typeof postgres>,
|
||||
migrationTableSchema: string,
|
||||
availableMigrations: string[],
|
||||
): Promise<string[]> {
|
||||
const qualifiedTable = `${quoteIdentifier(migrationTableSchema)}.${quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE)}`;
|
||||
try {
|
||||
const rows = await sql.unsafe<{ name: string }[]>(`SELECT name FROM ${qualifiedTable} ORDER BY id`);
|
||||
return rows.map((row) => row.name).filter((name): name is string => Boolean(name));
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !error.message.includes('column "name" does not exist')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await sql.unsafe<{ id: number }[]>(`SELECT id FROM ${qualifiedTable} ORDER BY id`);
|
||||
const journalMigrationFiles = await listJournalMigrationFiles();
|
||||
const appliedFromIds = rows
|
||||
.map((row) => journalMigrationFiles[row.id - 1])
|
||||
.filter((name): name is string => Boolean(name));
|
||||
if (appliedFromIds.length > 0) return appliedFromIds;
|
||||
|
||||
return availableMigrations.slice(0, Math.max(0, rows.length));
|
||||
}
|
||||
|
||||
async function discoverMigrationTableSchema(sql: ReturnType<typeof postgres>): Promise<string | null> {
|
||||
const rows = await sql<{ schemaName: string }[]>`
|
||||
SELECT n.nspname AS "schemaName"
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relname = ${DRIZZLE_MIGRATIONS_TABLE} AND c.relkind = 'r'
|
||||
`;
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const drizzleSchema = rows.find(({ schemaName }) => schemaName === "drizzle");
|
||||
if (drizzleSchema) return drizzleSchema.schemaName;
|
||||
|
||||
const publicSchema = rows.find(({ schemaName }) => schemaName === "public");
|
||||
if (publicSchema) return publicSchema.schemaName;
|
||||
|
||||
return rows[0]?.schemaName ?? null;
|
||||
}
|
||||
|
||||
export async function inspectMigrations(url: string): Promise<MigrationState> {
|
||||
const sql = postgres(url, { max: 1 });
|
||||
|
||||
try {
|
||||
const availableMigrations = await listMigrationFiles();
|
||||
const tableCountResult = await sql<{ count: number }[]>`
|
||||
select count(*)::int as count
|
||||
from information_schema.tables
|
||||
where table_schema = 'public'
|
||||
and table_type = 'BASE TABLE'
|
||||
`;
|
||||
const tableCount = tableCountResult[0]?.count ?? 0;
|
||||
|
||||
const migrationTableSchema = await discoverMigrationTableSchema(sql);
|
||||
if (!migrationTableSchema) {
|
||||
if (tableCount > 0) {
|
||||
return {
|
||||
status: "needsMigrations",
|
||||
tableCount,
|
||||
availableMigrations,
|
||||
appliedMigrations: [],
|
||||
pendingMigrations: availableMigrations,
|
||||
reason: "no-migration-journal-non-empty-db",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "needsMigrations",
|
||||
tableCount,
|
||||
availableMigrations,
|
||||
appliedMigrations: [],
|
||||
pendingMigrations: availableMigrations,
|
||||
reason: "no-migration-journal-empty-db",
|
||||
};
|
||||
}
|
||||
|
||||
const appliedMigrations = await loadAppliedMigrations(sql, migrationTableSchema, availableMigrations);
|
||||
const pendingMigrations = availableMigrations.filter((name) => !appliedMigrations.includes(name));
|
||||
if (pendingMigrations.length === 0) {
|
||||
return {
|
||||
status: "upToDate",
|
||||
tableCount,
|
||||
availableMigrations,
|
||||
appliedMigrations,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "needsMigrations",
|
||||
tableCount,
|
||||
availableMigrations,
|
||||
appliedMigrations,
|
||||
pendingMigrations,
|
||||
reason: "pending-migrations",
|
||||
};
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyPendingMigrations(url: string): Promise<void> {
|
||||
const sql = postgres(url, { max: 1 });
|
||||
|
||||
try {
|
||||
const db = drizzlePg(sql);
|
||||
await migratePg(db, { migrationsFolder: MIGRATIONS_FOLDER });
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
|
||||
export type MigrationBootstrapResult =
|
||||
| { migrated: true; reason: "migrated-empty-db"; tableCount: 0 }
|
||||
| { migrated: false; reason: "already-migrated"; tableCount: number }
|
||||
@@ -17,9 +182,7 @@ export async function migratePostgresIfEmpty(url: string): Promise<MigrationBoot
|
||||
const sql = postgres(url, { max: 1 });
|
||||
|
||||
try {
|
||||
const journal = await sql<{ regclass: string | null }[]>`
|
||||
select to_regclass('public.__drizzle_migrations') as regclass
|
||||
`;
|
||||
const migrationTableSchema = await discoverMigrationTableSchema(sql);
|
||||
|
||||
const tableCountResult = await sql<{ count: number }[]>`
|
||||
select count(*)::int as count
|
||||
@@ -30,7 +193,7 @@ export async function migratePostgresIfEmpty(url: string): Promise<MigrationBoot
|
||||
|
||||
const tableCount = tableCountResult[0]?.count ?? 0;
|
||||
|
||||
if (journal[0]?.regclass) {
|
||||
if (migrationTableSchema) {
|
||||
return { migrated: false, reason: "already-migrated", tableCount };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user