Files
PaperClipAI/packages/db/src/client.ts
T
DottaandPaperclip f6bc653f7f fix(server): schedule interrupted tool claims by their deadlines (#17458)
## Thinking Path

> - Paperclip governs tool execution and keeps durable approval records.
> - An interrupted execution may have produced an external effect
without a saved final request state.
> - The maintenance tick currently scans executing requests even when
none exist.
> - Every executing claim already has a ten-minute stale deadline.
> - This PR schedules that deadline from committed claim changes and
clears it after settlement.
> - Recovery preserves saved provider outcomes and quarantines unknown
outcomes without another provider call.

## Linked Issues or Issue Description

Refs #17454. This is the sixth recurring-server sleep slice, stacked on
`codex/sleep-05-approved-tool-recovery`.

**Problem**
The aggregate maintenance tick repeatedly searches for stale executing
tool requests. Its settlement can overwrite a saved successful
invocation with an unknown-outcome failure after a crash between
invocation and request writes.

**Proposed Solution**
Use the existing review notification boundary and shared scheduler. Read
the earliest executing claim deadline through a partial index. At that
deadline, lock invocation then request, recheck claim identity, and
atomically settle from the saved outcome or quarantine the unknown
outcome.

**Alternatives Considered**
A longer polling interval still queries empty work. A new queue would
duplicate existing records. Replaying an interrupted call can duplicate
an external effect. Treating every stale request as unknown discards
already saved outcomes.

## What Changed

- Coalesce sequential review notifications for up to 30 seconds while
preserving already loaded recovery deadlines. Do not schedule scans when
no notification or execution deadline remains.

- Extract interrupted-execution deadline lookup and settlement into the
owning tool-review service.
- Register its worker at startup. Use committed claim/result changes to
update the existing ten-minute deadline; retry failed settlement after
30 seconds.
- Preserve terminal invocation results, reasons, and completion times
when repairing request state.
- Settle uncertain request/invocation state atomically with the existing
unknown-outcome reason, without dispatching a provider call.
- Notify all gateway claim and settlement paths through the existing
review transaction helper. Keep framework wiring and the shared
scheduler free of provider-specific behavior.
- Add an idempotent concurrent partial index for executing deadlines.
Preserve the combined manual sweep and scheduler-disabled startup pass.
- Remove interrupted scanning from the aggregate tick. Interaction
projection repair remains the next separate duty.

## Verification

Current head: `648d8a9a64ded15deeeb331545b92a12d69e277d`. The final
commit changes no files and requests CI after the master retarget. A
preceding run appeared while that trigger was being published; it was
observational delay, not an established CI outage. No test rerun or
bypass was used to turn a failure green. Source remains identical to the
198-test passing integration, apart from the already recorded ledger
update. Fresh checks and both automated reviews must qualify this
current head before merge.


Current master-targeted head:
`d55e44b376555e4926bb2c2a0e4dcfb431324162`. Parent #17454 landed as
`77295faebc8`; a normal merge reconciles that squash. Every conflicted
master file was checked against the already-integrated parent, and all
matched byte-for-byte. Each interruption-only hunk was retained; the
complete integrated source tree matched the 198-test passing
`848f1b87b3` exactly before the sole final documentation update. All
master migration journal entries are unchanged, with only this PR's
executing-deadline index appended. Server types pass. Fresh CI and
current-head Greptile/Ponytail are required before the next authorized
merge.


Latest published integration:
`848f1b87b3a6646ae4e56e8ab142f64889d7a69c`, normally merging
approved-recovery parent `23868c1751`. All 198
gateway/interruption/startup/deadline cases and server production
typechecking pass. Both parent approval fixes and all interruption tests
are retained. The two merge conflicts preserved the interruption import
alongside the parent's claim-bound hash helper and preserved the
parameterized historical test helpers. This remains stacked on #17454;
fresh CI and both automated review gates are required after integration.


- Review documentation correction at
`c985eb9d1b48a481909de6a1c2d89d7af11bfc03`: the focused real-PostgreSQL
interruption-worker selection passes all 16 cases; 124 unrelated cases
were filtered out. The docs now correctly name 16 cases and six saved
terminal statuses. No production behavior changed in the correction.
Fresh remote CI/review is pending after integrating the verified parent.

Approved delegated Test-tab calls now evaluate caller and target
policies in the existing claim transaction. Real HTTP regressions first
showed a missing caller charge and shared-limit self-rejection. All 78
connection-management cases and 92 gateway cases now pass on the owning
branch, along with server production typechecking. All 78
connection-management cases also pass at combined tip
`d25f830db89c1623bff3547bfc68015c1c66e0cf`. The three new cases cover
shared charges, separate charges, rollback on denial, duplicate
approval, and rejection of a later authorized call. Intermediate
counter/identity fixture failures and a reused-database Slack collision
are recorded in [the owning
audit](https://github.com/paperclipai/paperclip/blob/f6a5949fb726f71c247950714adecb275655284f/doc/idle-approved-tool-recovery.md#delegated-test-tab-admission-after-policy-changes).
The earlier combined checks below remain evidence for their stated
revisions. Current-head CI and review remain required.

Current head: `f6a5949fb726f71c247950714adecb275655284f`. This remains a
single-duty PR stacked on #17454.

- Integrated pinned master `8548173fc9efdc0fa07644fb92dcc0b6851a95e5`
with normal merges. Every branch passed generated-schema checks and its
target-branch migration-order gate. Existing SQL is unchanged; the 25
stack migrations follow master’s new migration.
- Duty-specific producer coverage, lifecycle tests, and earlier failure
records:
[2026-10-24-recurring-server-sleep-stack.md](https://github.com/paperclipai/paperclip/blob/eaac8d343e5ef4d2c13cdf7cb7607cb5a498e9a2/doc/plans/2026-10-24-recurring-server-sleep-stack.md).
- Combined tip `51e6cbe67979b55b340ea9f0f434c5b5c682ccd6`: **355
startup/cancellation/recovery/dispatch/preservation tests**, **75
connection-management tests**, and production server typechecking pass.
These combined checks do not mean every intermediate PR independently
ran the full repository suite.
- Real master-to-stack database upgrade passes: **379 unchanged prior
migration receipts plus 25 new migrations**, with the gateway
responsibility fixture preserved.
- Owning-branch checks: 84 runner installation/settlement cases plus
runner typechecking and generated TypeScript/Rust profile agreement; 253
GitHub review workflow cases. Stop-attribution and held-worker readiness
regressions failed before their fixes.
- Failure accounting: the initial combined run passed 349 cases and
failed six idle assertions in a reused preservation database containing
old cancelled runs. Repeating the combined command with a fresh
preservation database passed. Earlier invalid project selection, merge
import/type errors, and the interrupted full local suite are not counted
as passing checks.
- **Latest-head CI and review remain required.** Prior CI findings
include GitHub idle observation, route teardown, attention cleanup,
touch-picker E2E, workspace health, and voice-cleanup timing; local
passes do not erase those failures. No full local repository-suite,
deployed sleep, fleet cost, or live Copilot qualification claim is made.

## Risks

- Signals cover the existing single-active-writer database owner.
Independent writers and replicas need an explicit wake contract.
- The ten-minute policy is unchanged. A provider call that outlives its
claim remains an uncertain outcome; this worker never retries the
external call.
- Interaction projection follows authoritative settlement outside its
transaction. Its separate existing repair duty still handles an
interrupted projection.
- Non-executing review writes can cause one empty reconciliation.
Without an executing claim or failed attempt, no recurring deadline
remains.
- Cloud eligibility and external-alarm coverage remain conservative.
Worker quiet does not prove actual host or database suspension.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning and code execution. The exact
serving model identifier and context window were not exposed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-27 16:33:30 -05:00

1250 lines
45 KiB
TypeScript

import { createHash } from "node:crypto";
import { drizzle as drizzlePg } from "drizzle-orm/postgres-js";
import { readFile, readdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import postgres from "postgres";
import { installDatabaseWorkSignals } from "./work-signals.js";
import { installDatabaseResponseCapture } from "./response-capture.js";
import { installPreparedQueries } from "./prepared-queries.js";
import * as schema from "./schema/index.js";
const MIGRATIONS_FOLDER = fileURLToPath(new URL("./migrations", import.meta.url));
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
const MIGRATIONS_JOURNAL_JSON = fileURLToPath(new URL("./migrations/meta/_journal.json", import.meta.url));
function createUtilitySql(url: string) {
return postgres(url, { max: 1, onnotice: () => {} });
}
type RegisteredPostgresClient = ReturnType<typeof postgres>;
/**
* Derives a registry key from a connection URL's host and port only. We must
* not retain or log the full URL, because it carries credentials.
*/
function hostPortKey(url: string): string {
const parsed = new URL(url);
return `${parsed.hostname}:${parsed.port || "5432"}`;
}
/**
* Same as `hostPortKey`, but returns `null` instead of throwing when the URL
* does not parse. `postgres(url)` tolerates a value `new URL()` rejects (an
* empty string falls back to the `PG*` environment variables), so `createDb`
* must tolerate it too: skip the registry entry and let the driver decide
* the outcome, instead of throwing an error the driver itself would not.
*/
function hostPortKeyOrNull(url: string): string | null {
try {
return hostPortKey(url);
} catch (error) {
if (error instanceof TypeError && (error as NodeJS.ErrnoException).code === "ERR_INVALID_URL") return null;
throw error;
}
}
// Tracks every client `createDb` hands out, keyed by host and port, so a test
// fixture can end them before it stops the Postgres cluster they point at. A
// `WeakRef` plus `FinalizationRegistry` means a long-lived process (a real
// server) retains nothing extra: an unreferenced client is pruned on its own.
const clientsByHostPort = new Map<string, Set<WeakRef<RegisteredPostgresClient>>>();
const clientFinalizer = new FinalizationRegistry<{ hostPortKey: string; ref: WeakRef<RegisteredPostgresClient> }>(
({ hostPortKey, ref }) => {
const refs = clientsByHostPort.get(hostPortKey);
if (!refs) return;
refs.delete(ref);
if (refs.size === 0) clientsByHostPort.delete(hostPortKey);
},
);
function registerClient(key: string, client: RegisteredPostgresClient): void {
const ref = new WeakRef(client);
let refs = clientsByHostPort.get(key);
if (!refs) {
refs = new Set();
clientsByHostPort.set(key, refs);
}
refs.add(ref);
clientFinalizer.register(client, { hostPortKey: key, ref }, ref);
}
/**
* Ends every live client `createDb` handed out for the given URL's host and
* port, then forgets them. Call this before stopping a Postgres cluster: a
* client that outlives the cluster it points at can crash the process (a
* reserved connection's deferred write firing after the socket is gone).
* Swallows individual `end()` errors so one bad client cannot block the rest.
*/
export async function closeRegisteredClients(url: string): Promise<void> {
const key = hostPortKey(url);
const refs = clientsByHostPort.get(key);
if (!refs) return;
clientsByHostPort.delete(key);
const clients: RegisteredPostgresClient[] = [];
for (const ref of refs) {
clientFinalizer.unregister(ref);
const client = ref.deref();
if (client) clients.push(client);
}
await Promise.all(clients.map((client) => client.end({ timeout: 1 }).catch(() => {})));
}
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("\"", "\"\"")}"`;
}
function quoteLiteral(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
function splitMigrationStatements(content: string): string[] {
return content
.split("--> statement-breakpoint")
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0);
}
export type MigrationState =
| {
status: "upToDate";
tableCount: number;
availableMigrations: string[];
appliedMigrations: string[];
journalEntryCount: number;
}
| {
status: "needsMigrations";
tableCount: number;
availableMigrations: string[];
appliedMigrations: string[];
pendingMigrations: string[];
journalEntryCount: number;
reason: "no-migration-journal-empty-db" | "no-migration-journal-non-empty-db" | "pending-migrations";
};
export interface DatabaseClientOptions {
prepare?: boolean;
/** postgres.js `max` — connection pool size (driver default: 10). */
maxConnections?: number;
/** postgres.js `idle_timeout` in seconds (driver default: disabled). */
idleTimeoutSeconds?: number;
/** postgres.js `connect_timeout` in seconds (driver default: 30). */
connectTimeoutSeconds?: number;
/**
* postgres.js `max_lifetime` in seconds. Bounds how long one pooled
* connection is reused before the client replaces it (driver default: a
* random value between 30 and 60 minutes).
*/
maxLifetimeSeconds?: number;
/**
* postgres.js `connection.application_name`, shown in
* `pg_stat_activity.application_name`. Lets an operator tell Paperclip's
* pool apart from other clients of the same database (driver default:
* `postgres.js`).
*/
applicationName?: string;
}
/**
* Idle pooled connections close after this many seconds unless
* `DATABASE_IDLE_TIMEOUT_SECONDS` says otherwise. The driver default keeps an
* idle connection open forever, so a process that stops issuing queries still
* holds every backend it ever opened. Set `DATABASE_IDLE_TIMEOUT_SECONDS=0`
* to restore the driver default.
*/
export const DEFAULT_DATABASE_IDLE_TIMEOUT_SECONDS = 60;
/** `application_name` reported to PostgreSQL unless `DATABASE_APPLICATION_NAME` overrides it. */
export const DEFAULT_DATABASE_APPLICATION_NAME = "paperclip";
function envBoolean(env: NodeJS.ProcessEnv, name: string): boolean | undefined {
const value = env[name]?.trim().toLowerCase();
if (value === undefined || value === "") return undefined;
if (value === "true" || value === "1") return true;
if (value === "false" || value === "0") return false;
throw new Error(`${name} must be "true" or "false", got: ${env[name]}`);
}
function envPositiveInteger(env: NodeJS.ProcessEnv, name: string): number | undefined {
const value = env[name]?.trim();
if (value === undefined || value === "") return undefined;
if (!/^[1-9]\d*$/.test(value)) {
throw new Error(`${name} must be a positive integer, got: ${env[name]}`);
}
return Number.parseInt(value, 10);
}
function envNonNegativeInteger(env: NodeJS.ProcessEnv, name: string): number | undefined {
const value = env[name]?.trim();
if (value === undefined || value === "") return undefined;
if (!/^(?:0|[1-9]\d*)$/.test(value)) {
throw new Error(`${name} must be a non-negative integer, got: ${env[name]}`);
}
return Number.parseInt(value, 10);
}
function envNonEmptyString(env: NodeJS.ProcessEnv, name: string): string | undefined {
const value = env[name]?.trim();
if (value === undefined || value === "") return undefined;
return value;
}
/**
* Database client tuning from the environment, so hosted deployments can
* adapt to their connection topology (pooled endpoints, network latency)
* without editing source. Every variable is optional. This function returns
* only the values the environment sets; `resolveDatabaseClientOptions` adds
* Paperclip's own defaults on top, and the driver defaults apply to the rest
* — self-hosted setups need none of these.
*/
export function databaseClientOptionsFromEnv(env: NodeJS.ProcessEnv = process.env): DatabaseClientOptions {
const options: DatabaseClientOptions = {};
const prepare = envBoolean(env, "DATABASE_PREPARED_STATEMENTS");
if (prepare !== undefined) options.prepare = prepare;
const maxConnections = envPositiveInteger(env, "DATABASE_POOL_MAX");
if (maxConnections !== undefined) options.maxConnections = maxConnections;
// `0` is allowed here: it disables idle reaping (the driver default).
const idleTimeoutSeconds = envNonNegativeInteger(env, "DATABASE_IDLE_TIMEOUT_SECONDS");
if (idleTimeoutSeconds !== undefined) options.idleTimeoutSeconds = idleTimeoutSeconds;
const connectTimeoutSeconds = envPositiveInteger(env, "DATABASE_CONNECT_TIMEOUT_SECONDS");
if (connectTimeoutSeconds !== undefined) options.connectTimeoutSeconds = connectTimeoutSeconds;
const maxLifetimeSeconds = envPositiveInteger(env, "DATABASE_MAX_LIFETIME_SECONDS");
if (maxLifetimeSeconds !== undefined) options.maxLifetimeSeconds = maxLifetimeSeconds;
const applicationName = envNonEmptyString(env, "DATABASE_APPLICATION_NAME");
if (applicationName !== undefined) options.applicationName = applicationName;
return options;
}
/**
* Fills in Paperclip's defaults for the options the caller left unset: idle
* connections are reaped after `DEFAULT_DATABASE_IDLE_TIMEOUT_SECONDS`, and the
* pool identifies itself as `DEFAULT_DATABASE_APPLICATION_NAME`. Everything
* else stays at the driver default. An explicit value (including
* `idleTimeoutSeconds: 0`) always wins over the default.
*/
export function resolveDatabaseClientOptions(options: DatabaseClientOptions): DatabaseClientOptions {
return {
...options,
idleTimeoutSeconds: options.idleTimeoutSeconds ?? DEFAULT_DATABASE_IDLE_TIMEOUT_SECONDS,
applicationName: options.applicationName ?? DEFAULT_DATABASE_APPLICATION_NAME,
};
}
export function postgresJsOptions(options: DatabaseClientOptions): Record<string, unknown> {
const driverOptions: Record<string, unknown> = {};
if (options.prepare !== undefined) driverOptions.prepare = options.prepare;
if (options.maxConnections !== undefined) driverOptions.max = options.maxConnections;
if (options.idleTimeoutSeconds !== undefined) driverOptions.idle_timeout = options.idleTimeoutSeconds;
if (options.connectTimeoutSeconds !== undefined) driverOptions.connect_timeout = options.connectTimeoutSeconds;
if (options.maxLifetimeSeconds !== undefined) driverOptions.max_lifetime = options.maxLifetimeSeconds;
if (options.applicationName !== undefined) {
driverOptions.connection = { application_name: options.applicationName };
}
return driverOptions;
}
// A long advisory-lock transaction must not borrow the normal pool that its
// work needs for progress writes. Keep connection configuration private to the
// originating Db lifetime; callers receive neither URLs nor credentials.
const dedicatedDbFactories = new WeakMap<object, () => Db>();
export async function withDedicatedDbConnection<T>(db: Db, action: (dedicated: Db) => Promise<T>): Promise<T> {
const factory = dedicatedDbFactories.get(db);
if (!factory) throw new Error("dedicated_connection_requires_create_db");
const dedicated = factory();
try { return await action(dedicated); }
finally { await dedicated.$client.end({ timeout: 1 }); }
}
// postgres.js pipelines a query onto a busy connection when none is idle, so
// a short liveness write can wait behind an unrelated slow statement with no
// client-side bound. Liveness writes (controller-lease renewal) get a small
// private pool, created on first use and shared for the owner's lifetime.
const livenessDbFactories = new WeakMap<object, () => Db>();
const livenessDbs = new WeakMap<object, Db>();
const LIVENESS_POOL_MAX_CONNECTIONS = 2;
/** The private liveness pool for a `createDb` client; other clients (test
* doubles, transactions) are returned unchanged. */
export function livenessDb(db: Db): Db {
const existing = livenessDbs.get(db);
if (existing) return existing;
const factory = livenessDbFactories.get(db);
if (!factory) return db;
const created = factory();
livenessDbs.set(db, created);
return created;
}
/** Ends the liveness pool if one was created. The ended pool stays cached, so
* a late renewal fails like a query on the ended primary pool. */
export async function closeLivenessDb(db: Db, timeoutSeconds: number): Promise<void> {
await livenessDbs.get(db)?.$client.end({ timeout: timeoutSeconds });
}
export function createDb(url: string, options?: DatabaseClientOptions) {
const resolved = resolveDatabaseClientOptions(options ?? databaseClientOptionsFromEnv());
return createDbClient(url, resolved);
}
function createDbClient(url: string, resolved: DatabaseClientOptions, owner?: object) {
const sql = postgres(url, postgresJsOptions(resolved));
if (resolved.prepare === true) installPreparedQueries(sql);
const key = hostPortKeyOrNull(url);
if (key) registerClient(key, sql);
// A disconnect can lose the response after a statement has committed.
// postgres.js calls that error "write CONNECTION_CLOSED" too, so the
// message cannot establish that replay is safe. Leave retries to callers
// that know the complete operation is idempotent.
const db = installDatabaseWorkSignals(drizzlePg(sql, { schema }), owner);
installDatabaseResponseCapture(db, schema);
dedicatedDbFactories.set(db, () => createDbClient(url, {
...resolved, maxConnections: 1, applicationName: "paperclip-workspace-finalization-lock",
}, db));
livenessDbFactories.set(db, () => createDbClient(url, {
...resolved, maxConnections: LIVENESS_POOL_MAX_CONNECTIONS, applicationName: "paperclip-controller-lease",
}, db));
return db;
}
export async function getPostgresDataDirectory(url: string): Promise<string | null> {
const sql = createUtilitySql(url);
try {
const rows = await sql<{ data_directory: string | null }[]>`
SELECT current_setting('data_directory', true) AS data_directory
`;
const actual = rows[0]?.data_directory;
return typeof actual === "string" && actual.length > 0 ? actual : null;
} catch {
return null;
} finally {
await sql.end();
}
}
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<{ idx?: number; tag?: string; when?: number }>;
};
type JournalMigrationEntry = {
fileName: string;
folderMillis: number;
order: number;
};
async function listJournalMigrationEntries(): Promise<JournalMigrationEntry[]> {
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, entryIndex) => {
if (typeof entry?.tag !== "string") return null;
if (typeof entry?.when !== "number" || !Number.isFinite(entry.when)) return null;
const order = Number.isInteger(entry.idx) ? Number(entry.idx) : entryIndex;
return { fileName: `${entry.tag}.sql`, folderMillis: entry.when, order };
})
.filter((entry): entry is JournalMigrationEntry => entry !== null);
} catch {
return [];
}
}
async function listJournalMigrationFiles(): Promise<string[]> {
const entries = await listJournalMigrationEntries();
return entries.map((entry) => entry.fileName);
}
async function readMigrationFileContent(migrationFile: string): Promise<string> {
return readFile(new URL(`./migrations/${migrationFile}`, import.meta.url), "utf8");
}
async function orderMigrationsByJournal(migrationFiles: string[]): Promise<string[]> {
const journalEntries = await listJournalMigrationEntries();
const orderByFileName = new Map(journalEntries.map((entry) => [entry.fileName, entry.order]));
return [...migrationFiles].sort((left, right) => {
const leftOrder = orderByFileName.get(left);
const rightOrder = orderByFileName.get(right);
if (leftOrder === undefined && rightOrder === undefined) return left.localeCompare(right);
if (leftOrder === undefined) return 1;
if (rightOrder === undefined) return -1;
if (leftOrder === rightOrder) return left.localeCompare(right);
return leftOrder - rightOrder;
});
}
type SqlExecutor = Pick<ReturnType<typeof postgres>, "unsafe">;
async function runInTransaction(sql: SqlExecutor, action: () => Promise<void>): Promise<void> {
await sql.unsafe("BEGIN");
try {
await action();
await sql.unsafe("COMMIT");
} catch (error) {
try {
await sql.unsafe("ROLLBACK");
} catch {
// Ignore rollback failures and surface the original error.
}
throw error;
}
}
async function latestMigrationCreatedAt(
sql: SqlExecutor,
qualifiedTable: string,
): Promise<number | null> {
const rows = await sql.unsafe<{ created_at: string | number | null }[]>(
`SELECT created_at FROM ${qualifiedTable} ORDER BY created_at DESC NULLS LAST LIMIT 1`,
);
const value = Number(rows[0]?.created_at ?? Number.NaN);
return Number.isFinite(value) ? value : null;
}
function normalizeFolderMillis(value: number | null | undefined): number {
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
return Math.trunc(value);
}
return Date.now();
}
async function ensureMigrationJournalTable(
sql: ReturnType<typeof postgres>,
): Promise<{ migrationTableSchema: string; columnNames: Set<string> }> {
let migrationTableSchema = await discoverMigrationTableSchema(sql);
if (!migrationTableSchema) {
const drizzleSchema = quoteIdentifier("drizzle");
const migrationTable = quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE);
await sql.unsafe(`CREATE SCHEMA IF NOT EXISTS ${drizzleSchema}`);
await sql.unsafe(
`CREATE TABLE IF NOT EXISTS ${drizzleSchema}.${migrationTable} (id SERIAL PRIMARY KEY, hash text NOT NULL, created_at bigint)`,
);
migrationTableSchema = (await discoverMigrationTableSchema(sql)) ?? "drizzle";
}
const columnNames = await getMigrationTableColumnNames(sql, migrationTableSchema);
return { migrationTableSchema, columnNames };
}
async function migrationHistoryEntryExists(
sql: SqlExecutor,
qualifiedTable: string,
columnNames: Set<string>,
migrationFile: string,
hash: string,
): Promise<boolean> {
const predicates: string[] = [];
if (columnNames.has("hash")) predicates.push(`hash = ${quoteLiteral(hash)}`);
if (columnNames.has("name")) predicates.push(`name = ${quoteLiteral(migrationFile)}`);
if (predicates.length === 0) return false;
const rows = await sql.unsafe<{ one: number }[]>(
`SELECT 1 AS one FROM ${qualifiedTable} WHERE ${predicates.join(" OR ")} LIMIT 1`,
);
return rows.length > 0;
}
async function recordMigrationHistoryEntry(
sql: SqlExecutor,
qualifiedTable: string,
columnNames: Set<string>,
migrationFile: string,
hash: string,
folderMillis: number,
): Promise<void> {
const insertColumns: string[] = [];
const insertValues: string[] = [];
if (columnNames.has("hash")) {
insertColumns.push(quoteIdentifier("hash"));
insertValues.push(quoteLiteral(hash));
}
if (columnNames.has("name")) {
insertColumns.push(quoteIdentifier("name"));
insertValues.push(quoteLiteral(migrationFile));
}
if (columnNames.has("created_at")) {
const latestCreatedAt = await latestMigrationCreatedAt(sql, qualifiedTable);
const createdAt = latestCreatedAt === null
? normalizeFolderMillis(folderMillis)
: Math.max(latestCreatedAt + 1, normalizeFolderMillis(folderMillis));
insertColumns.push(quoteIdentifier("created_at"));
insertValues.push(quoteLiteral(String(createdAt)));
}
if (insertColumns.length === 0) return;
await sql.unsafe(
`INSERT INTO ${qualifiedTable} (${insertColumns.join(", ")}) VALUES (${insertValues.join(", ")})`,
);
}
// These idempotent migrations require top-level statements for batch commits
// or concurrent index builds. History is recorded only after completion.
const MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS = new Set([
"0313_private_task_access.sql",
"0314_private_task_draft_assets.sql",
"0332_little_night_nurse.sql",
"0345_luxuriant_carmella_unuscione.sql",
"0369_late_logan.sql",
"0381_mean_scream.sql",
"0382_narrow_the_executioner.sql",
"0385_open_madame_web.sql",
"0384_funny_adam_warlock.sql",
"0383_tool_review_company_expiry.sql",
]);
async function applyPendingMigrationsManually(
url: string,
pendingMigrations: string[],
): Promise<void> {
if (pendingMigrations.length === 0) return;
const orderedPendingMigrations = await orderMigrationsByJournal(pendingMigrations);
const journalEntries = await listJournalMigrationEntries();
const folderMillisByFileName = new Map(
journalEntries.map((entry) => [entry.fileName, normalizeFolderMillis(entry.folderMillis)]),
);
const pool = createUtilitySql(url);
try {
const sql = await pool.reserve();
try {
// A session lock survives each batch COMMIT. Reserve the connection so
// pool rotation cannot release it while another migrator is waiting.
// A blocking lock SELECT retains its statement snapshot while waiting.
// A partial concurrent index build can wait for that snapshot, forming a
// deadlock with this lock. Each failed try ends its snapshot before sleep.
while (!(await sql<{ locked: boolean }[]>`
SELECT pg_try_advisory_lock(hashtextextended('paperclip:migrations', 0)) AS locked`)[0]?.locked) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
const { migrationTableSchema, columnNames } = await ensureMigrationJournalTable(sql);
const qualifiedTable = `${quoteIdentifier(migrationTableSchema)}.${quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE)}`;
for (const migrationFile of orderedPendingMigrations) {
const migrationContent = await readMigrationFileContent(migrationFile);
const hash = createHash("sha256").update(migrationContent).digest("hex");
const existingEntry = await migrationHistoryEntryExists(
sql,
qualifiedTable,
columnNames,
migrationFile,
hash,
);
if (existingEntry) continue;
const applyMigration = async () => {
for (const statement of splitMigrationStatements(migrationContent)) {
if (MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS.has(migrationFile)) {
// A cancelled concurrent build leaves an invalid index. IF NOT
// EXISTS alone would skip it on retry and journal an incomplete index.
const index = statement.replace(/^\s*--.*$/gm, "").trim()
.match(/^CREATE (?:UNIQUE )?INDEX CONCURRENTLY IF NOT EXISTS "([A-Za-z_][A-Za-z0-9_]*)"/i);
if (index) {
const invalid = await sql<{ invalid: boolean }[]>`
SELECT NOT i.indisvalid AS invalid FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relname = ${index[1]}`;
if (invalid[0]?.invalid) {
await sql.unsafe(`DROP INDEX CONCURRENTLY ${quoteIdentifier("public")}.${quoteIdentifier(index[1])}`);
}
}
}
// Older dev schemas can lack a replaced constraint. Preserve the
// published SQL/hash while allowing the harmless drop to proceed.
const executable = statement.replace(
/^(\s*ALTER TABLE "[^"]+" DROP CONSTRAINT) (?!IF EXISTS\b)/i,
"$1 IF EXISTS ",
);
await sql.unsafe(executable);
}
await recordMigrationHistoryEntry(
sql,
qualifiedTable,
columnNames,
migrationFile,
hash,
folderMillisByFileName.get(migrationFile) ?? Date.now(),
);
};
if (MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS.has(migrationFile)) {
await applyMigration();
} else {
await runInTransaction(sql, applyMigration);
}
}
} finally {
try {
await sql`SELECT pg_advisory_unlock(hashtextextended('paperclip:migrations', 0))`;
} catch {
// Preserve the migration error if the session died; PostgreSQL releases
// its lock on disconnect, and pool.end() closes any remaining connection.
}
sql.release();
}
} finally {
await pool.end();
}
}
async function mapHashesToMigrationFiles(migrationFiles: string[]): Promise<Map<string, string>> {
const mapped = new Map<string, string>();
await Promise.all(
migrationFiles.map(async (migrationFile) => {
const content = await readMigrationFileContent(migrationFile);
const hash = createHash("sha256").update(content).digest("hex");
mapped.set(hash, migrationFile);
if (migrationFile === "0355_agent_tool_definition_hooks.sql") {
mapped.set("7ac76adac0220da522c78e51da20490aeb0f90512b4370a05af9f8dd0f75eda6", migrationFile);
}
}),
);
return mapped;
}
async function getMigrationTableColumnNames(
sql: ReturnType<typeof postgres>,
migrationTableSchema: string,
): Promise<Set<string>> {
const columns = await sql.unsafe<{ column_name: string }[]>(
`
SELECT column_name
FROM information_schema.columns
WHERE table_schema = ${quoteLiteral(migrationTableSchema)}
AND table_name = ${quoteLiteral(DRIZZLE_MIGRATIONS_TABLE)}
`,
);
return new Set(columns.map((column) => column.column_name));
}
async function tableExists(
sql: ReturnType<typeof postgres>,
tableName: string,
): Promise<boolean> {
const rows = await sql<{ exists: boolean }[]>`
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = ${tableName}
) AS exists
`;
return rows[0]?.exists ?? false;
}
async function columnExists(
sql: ReturnType<typeof postgres>,
tableName: string,
columnName: string,
): Promise<boolean> {
const rows = await sql<{ exists: boolean }[]>`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = ${tableName}
AND column_name = ${columnName}
) AS exists
`;
return rows[0]?.exists ?? false;
}
async function columnHasDataType(
sql: ReturnType<typeof postgres>,
tableName: string,
columnName: string,
dataType: string,
): Promise<boolean> {
const rows = await sql<{ dataType: string; udtName: string }[]>`
SELECT data_type AS "dataType", udt_name AS "udtName"
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = ${tableName}
AND column_name = ${columnName}
`;
const expected = dataType.toLowerCase();
return rows.some((row) => (
row.dataType.toLowerCase() === expected || row.udtName.toLowerCase() === expected
));
}
async function indexExists(
sql: ReturnType<typeof postgres>,
indexName: string,
): Promise<boolean> {
const rows = await sql<{ exists: boolean }[]>`
SELECT EXISTS (
SELECT 1
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND c.relkind = 'i'
AND c.relname = ${indexName}
) AS exists
`;
return rows[0]?.exists ?? false;
}
async function constraintExists(
sql: ReturnType<typeof postgres>,
constraintName: string,
): Promise<boolean> {
const rows = await sql<{ exists: boolean }[]>`
SELECT EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_namespace n ON n.oid = c.connamespace
WHERE n.nspname = 'public'
AND c.conname = ${constraintName}
) AS exists
`;
return rows[0]?.exists ?? false;
}
async function functionExists(
sql: ReturnType<typeof postgres>,
functionName: string,
): Promise<boolean> {
const rows = await sql<{ exists: boolean }[]>`
SELECT EXISTS (
SELECT 1
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public'
AND p.proname = ${functionName}
) AS exists
`;
return rows[0]?.exists ?? false;
}
async function triggerExists(
sql: ReturnType<typeof postgres>,
triggerName: string,
): Promise<boolean> {
const rows = await sql<{ exists: boolean }[]>`
SELECT EXISTS (
SELECT 1
FROM pg_trigger t
JOIN pg_class c ON c.oid = t.tgrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND t.tgname = ${triggerName}
AND NOT t.tgisinternal
) AS exists
`;
return rows[0]?.exists ?? false;
}
async function heartbeatEventSequencesAreUnique(
sql: ReturnType<typeof postgres>,
): Promise<boolean> {
const rows = await sql<{ unique: boolean }[]>`
SELECT NOT EXISTS (
SELECT 1
FROM heartbeat_run_events
GROUP BY run_id, seq
HAVING count(*) > 1
) AS unique
`;
return rows[0]?.unique ?? false;
}
async function heartbeatNextEventSequencesAreCurrent(
sql: ReturnType<typeof postgres>,
): Promise<boolean> {
const rows = await sql<{ current: boolean }[]>`
SELECT NOT EXISTS (
SELECT 1
FROM heartbeat_runs run
WHERE run.next_event_seq IS DISTINCT FROM COALESCE((
SELECT max(event.seq) + 1
FROM heartbeat_run_events event
WHERE event.run_id = run.id
), 1)
) AS current
`;
return rows[0]?.current ?? false;
}
async function migrationStatementAlreadyApplied(
sql: ReturnType<typeof postgres>,
statement: string,
): Promise<boolean> {
const normalized = statement
.replace(/^\s*--.*$/gm, "")
.replace(/\s+/g, " ")
.trim();
const createTableMatch = normalized.match(/^CREATE TABLE(?: IF NOT EXISTS)? "([^"]+)"/i);
if (createTableMatch) {
return tableExists(sql, createTableMatch[1]);
}
const addColumnMatch = normalized.match(
/^ALTER TABLE "([^"]+)" ADD COLUMN(?: IF NOT EXISTS)? "([^"]+)"/i,
);
if (addColumnMatch) {
return columnExists(sql, addColumnMatch[1], addColumnMatch[2]);
}
const alterColumnTypeMatch = normalized.match(
/^ALTER TABLE "([^"]+)" ALTER COLUMN "([^"]+)" SET DATA TYPE ([A-Za-z0-9_]+)/i,
);
if (alterColumnTypeMatch) {
return columnHasDataType(
sql,
alterColumnTypeMatch[1],
alterColumnTypeMatch[2],
alterColumnTypeMatch[3],
);
}
const createIndexMatch = normalized.match(/^CREATE (?:UNIQUE )?INDEX(?: IF NOT EXISTS)? "([^"]+)"/i);
if (createIndexMatch) {
return indexExists(sql, createIndexMatch[1]);
}
const addConstraintMatch = normalized.match(/^ALTER TABLE "([^"]+)" ADD CONSTRAINT "([^"]+)"/i);
if (addConstraintMatch) {
return constraintExists(sql, addConstraintMatch[2]);
}
const createFunctionMatch = normalized.match(
/^CREATE OR REPLACE FUNCTION "?([A-Za-z_][A-Za-z0-9_]*)"?\s*\(/i,
);
if (createFunctionMatch) {
return functionExists(sql, createFunctionMatch[1]);
}
const createTriggerMatch = normalized.match(
/^CREATE TRIGGER "?([A-Za-z_][A-Za-z0-9_]*)"?/i,
);
if (createTriggerMatch) {
return triggerExists(sql, createTriggerMatch[1]);
}
// These native-runner repairs have persistent postconditions. Verify them
// instead of replaying them when a restored database is missing only the
// migration-history row.
if (
normalized.startsWith("WITH ranked AS (")
&& normalized.includes('UPDATE "heartbeat_run_events" AS event')
) {
return heartbeatEventSequencesAreUnique(sql);
}
if (
normalized.startsWith('UPDATE "heartbeat_runs" AS run')
&& normalized.includes('SET "next_event_seq" = COALESCE')
) {
return heartbeatNextEventSequencesAreCurrent(sql);
}
// If we cannot reason about a statement safely, require manual migration.
return false;
}
async function migrationContentAlreadyApplied(
sql: ReturnType<typeof postgres>,
migrationContent: string,
): Promise<boolean> {
const statements = splitMigrationStatements(migrationContent);
if (statements.length === 0) return false;
for (const statement of statements) {
const applied = await migrationStatementAlreadyApplied(sql, statement);
if (!applied) return false;
}
return true;
}
async function loadAppliedMigrations(
sql: ReturnType<typeof postgres>,
migrationTableSchema: string,
availableMigrations: string[],
): Promise<string[]> {
const quotedSchema = quoteIdentifier(migrationTableSchema);
const qualifiedTable = `${quotedSchema}.${quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE)}`;
const columnNames = await getMigrationTableColumnNames(sql, migrationTableSchema);
if (columnNames.has("name")) {
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));
}
if (columnNames.has("hash")) {
const rows = await sql.unsafe<{ hash: string }[]>(`SELECT hash FROM ${qualifiedTable} ORDER BY id`);
const hashesToMigrationFiles = await mapHashesToMigrationFiles(availableMigrations);
const appliedFromHashes = rows
.map((row) => hashesToMigrationFiles.get(row.hash))
.filter((name): name is string => Boolean(name));
if (appliedFromHashes.length > 0) {
// Best-effort: when all hashes resolve, this is authoritative.
if (appliedFromHashes.length === rows.length) return appliedFromHashes;
// Partial hash resolution can happen when files have changed; return what we can trust.
return appliedFromHashes;
}
// Fallback only when hashes are unavailable/unresolved.
if (columnNames.has("created_at")) {
const journalEntries = await listJournalMigrationEntries();
if (journalEntries.length > 0) {
const lastDbRows = await sql.unsafe<{ created_at: string | number | null }[]>(
`SELECT created_at FROM ${qualifiedTable} ORDER BY created_at DESC LIMIT 1`,
);
const lastCreatedAt = Number(lastDbRows[0]?.created_at ?? -1);
if (Number.isFinite(lastCreatedAt) && lastCreatedAt >= 0) {
return journalEntries
.filter((entry) => availableMigrations.includes(entry.fileName))
.filter((entry) => entry.folderMillis <= lastCreatedAt)
.map((entry) => entry.fileName)
.slice(0, rows.length);
}
}
}
}
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));
}
export type MigrationHistoryReconcileResult = {
repairedMigrations: string[];
remainingMigrations: string[];
};
export async function reconcilePendingMigrationHistory(
url: string,
): Promise<MigrationHistoryReconcileResult> {
const state = await inspectMigrations(url);
if (state.status !== "needsMigrations" || state.reason !== "pending-migrations") {
return { repairedMigrations: [], remainingMigrations: [] };
}
const sql = createUtilitySql(url);
const repairedMigrations: string[] = [];
try {
const journalEntries = await listJournalMigrationEntries();
const folderMillisByFile = new Map(journalEntries.map((entry) => [entry.fileName, entry.folderMillis]));
const migrationTableSchema = await discoverMigrationTableSchema(sql);
if (!migrationTableSchema) {
return { repairedMigrations, remainingMigrations: state.pendingMigrations };
}
const columnNames = await getMigrationTableColumnNames(sql, migrationTableSchema);
const qualifiedTable = `${quoteIdentifier(migrationTableSchema)}.${quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE)}`;
for (const migrationFile of state.pendingMigrations) {
const migrationContent = await readMigrationFileContent(migrationFile);
const alreadyApplied = await migrationContentAlreadyApplied(sql, migrationContent);
if (!alreadyApplied) break;
const hash = createHash("sha256").update(migrationContent).digest("hex");
const folderMillis = folderMillisByFile.get(migrationFile) ?? Date.now();
const existingByHash = columnNames.has("hash")
? await sql.unsafe<{ created_at: string | number | null }[]>(
`SELECT created_at FROM ${qualifiedTable} WHERE hash = ${quoteLiteral(hash)} ORDER BY created_at DESC LIMIT 1`,
)
: [];
const existingByName = columnNames.has("name")
? await sql.unsafe<{ created_at: string | number | null }[]>(
`SELECT created_at FROM ${qualifiedTable} WHERE name = ${quoteLiteral(migrationFile)} ORDER BY created_at DESC LIMIT 1`,
)
: [];
if (existingByHash.length > 0 || existingByName.length > 0) {
if (columnNames.has("created_at")) {
const existingHashCreatedAt = Number(existingByHash[0]?.created_at ?? -1);
if (existingByHash.length > 0 && Number.isFinite(existingHashCreatedAt) && existingHashCreatedAt < folderMillis) {
await sql.unsafe(
`UPDATE ${qualifiedTable} SET created_at = ${quoteLiteral(String(folderMillis))} WHERE hash = ${quoteLiteral(hash)} AND created_at < ${quoteLiteral(String(folderMillis))}`,
);
}
const existingNameCreatedAt = Number(existingByName[0]?.created_at ?? -1);
if (existingByName.length > 0 && Number.isFinite(existingNameCreatedAt) && existingNameCreatedAt < folderMillis) {
await sql.unsafe(
`UPDATE ${qualifiedTable} SET created_at = ${quoteLiteral(String(folderMillis))} WHERE name = ${quoteLiteral(migrationFile)} AND created_at < ${quoteLiteral(String(folderMillis))}`,
);
}
}
repairedMigrations.push(migrationFile);
continue;
}
const insertColumns: string[] = [];
const insertValues: string[] = [];
if (columnNames.has("hash")) {
insertColumns.push(quoteIdentifier("hash"));
insertValues.push(quoteLiteral(hash));
}
if (columnNames.has("name")) {
insertColumns.push(quoteIdentifier("name"));
insertValues.push(quoteLiteral(migrationFile));
}
if (columnNames.has("created_at")) {
insertColumns.push(quoteIdentifier("created_at"));
insertValues.push(quoteLiteral(String(folderMillis)));
}
if (insertColumns.length === 0) break;
await sql.unsafe(
`INSERT INTO ${qualifiedTable} (${insertColumns.join(", ")}) VALUES (${insertValues.join(", ")})`,
);
repairedMigrations.push(migrationFile);
}
} finally {
await sql.end();
}
const refreshed = await inspectMigrations(url);
return {
repairedMigrations,
remainingMigrations:
refreshed.status === "needsMigrations" ? refreshed.pendingMigrations : [],
};
}
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 = createUtilitySql(url);
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,
journalEntryCount: 0,
reason: "no-migration-journal-non-empty-db",
};
}
return {
status: "needsMigrations",
tableCount,
availableMigrations,
appliedMigrations: [],
pendingMigrations: availableMigrations,
journalEntryCount: 0,
reason: "no-migration-journal-empty-db",
};
}
const qualifiedMigrationTable = `${quoteIdentifier(migrationTableSchema)}.${quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE)}`;
const journalCountRows = await sql.unsafe<{ count: number }[]>(
`SELECT count(*)::int AS count FROM ${qualifiedMigrationTable}`,
);
const journalEntryCount = journalCountRows[0]?.count ?? 0;
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,
journalEntryCount,
};
}
return {
status: "needsMigrations",
tableCount,
availableMigrations,
appliedMigrations,
pendingMigrations,
journalEntryCount,
reason: "pending-migrations",
};
} finally {
await sql.end();
}
}
export async function applyPendingMigrations(url: string): Promise<void> {
const initialState = await inspectMigrations(url);
if (initialState.status === "upToDate") return;
if (initialState.reason === "no-migration-journal-empty-db") {
// Use the same per-file executor for bootstrap and upgrades: Drizzle wraps
// the whole journal in one transaction and cannot run committing backfills.
await applyPendingMigrationsManually(url, initialState.pendingMigrations);
const bootstrappedState = await inspectMigrations(url);
if (bootstrappedState.status === "upToDate") return;
throw new Error(
`Failed to bootstrap migrations: ${bootstrappedState.pendingMigrations.join(", ")}`,
);
}
if (initialState.reason === "no-migration-journal-non-empty-db") {
throw new Error(
"Database has tables but no migration journal; automatic migration is unsafe. Initialize migration history manually.",
);
}
let state = await inspectMigrations(url);
if (state.status === "upToDate") return;
const repair = await reconcilePendingMigrationHistory(url);
if (repair.repairedMigrations.length > 0) {
state = await inspectMigrations(url);
if (state.status === "upToDate") return;
}
if (state.status !== "needsMigrations" || state.reason !== "pending-migrations") {
throw new Error("Migrations are still pending after migration-history reconciliation; run inspectMigrations for details.");
}
await applyPendingMigrationsManually(url, state.pendingMigrations);
const finalState = await inspectMigrations(url);
if (finalState.status !== "upToDate") {
throw new Error(
`Failed to apply pending migrations: ${finalState.pendingMigrations.join(", ")}`,
);
}
}
export type MigrationBootstrapResult =
| { migrated: true; reason: "migrated-empty-db"; tableCount: 0 }
| { migrated: false; reason: "already-migrated"; tableCount: number }
| { migrated: false; reason: "not-empty-no-migration-journal"; tableCount: number };
export async function migratePostgresIfEmpty(url: string): Promise<MigrationBootstrapResult> {
const sql = createUtilitySql(url);
try {
const migrationTableSchema = await discoverMigrationTableSchema(sql);
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;
if (migrationTableSchema) {
return { migrated: false, reason: "already-migrated", tableCount };
}
if (tableCount > 0) {
return { migrated: false, reason: "not-empty-no-migration-journal", tableCount };
}
await applyPendingMigrationsManually(url, await listMigrationFiles());
return { migrated: true, reason: "migrated-empty-db", tableCount: 0 };
} finally {
await sql.end();
}
}
export async function ensurePostgresDatabase(
url: string,
databaseName: string,
): Promise<"created" | "exists"> {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(databaseName)) {
throw new Error(`Unsafe database name: ${databaseName}`);
}
const sql = createUtilitySql(url);
try {
const existing = await sql<{ one: number }[]>`
select 1 as one from pg_database where datname = ${databaseName} limit 1
`;
if (existing.length > 0) return "exists";
await sql.unsafe(`create database "${databaseName}" encoding 'UTF8' lc_collate 'C' lc_ctype 'C' template template0`);
return "created";
} finally {
await sql.end();
}
}
export async function resetPostgresDatabase(
url: string,
databaseName: string,
): Promise<"reset"> {
const quotedDatabaseName = quoteIdentifier(databaseName);
const sql = createUtilitySql(url);
try {
await sql`
select pg_terminate_backend(pid)
from pg_stat_activity
where datname = ${databaseName}
and pid <> pg_backend_pid()
`;
await sql.unsafe(`drop database if exists ${quotedDatabaseName}`);
await sql.unsafe(`create database ${quotedDatabaseName} encoding 'UTF8' lc_collate 'C' lc_ctype 'C' template template0`);
return "reset";
} finally {
await sql.end();
}
}
export type Db = ReturnType<typeof createDb>;