426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
267 lines
8.0 KiB
TypeScript
267 lines
8.0 KiB
TypeScript
// Drives cloud admin cloud admin migrate with diagnostics automation with explicit environment and CI invariants.
|
|
import { createHash } from "node:crypto";
|
|
import { existsSync } from "node:fs";
|
|
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { enforceTlsForRemote } from "@elizaos/cloud-shared/db/client";
|
|
import pg from "pg";
|
|
|
|
const { Client } = pg;
|
|
|
|
const MIGRATIONS_SCHEMA = "drizzle";
|
|
const MIGRATIONS_TABLE = "__drizzle_migrations";
|
|
const MIGRATIONS_DIR =
|
|
[
|
|
path.join(process.cwd(), "packages/cloud/shared/src/db/migrations"),
|
|
path.join(process.cwd(), "src/db/migrations"),
|
|
].find((candidate) =>
|
|
existsSync(path.join(candidate, "meta/_journal.json")),
|
|
) ?? path.join(process.cwd(), "packages/cloud/shared/src/db/migrations");
|
|
const JOURNAL_PATH = path.join(MIGRATIONS_DIR, "meta/_journal.json");
|
|
|
|
interface JournalEntry {
|
|
idx: number;
|
|
version: string;
|
|
when: number;
|
|
tag: string;
|
|
breakpoints: boolean;
|
|
}
|
|
|
|
interface Journal {
|
|
version: string;
|
|
dialect: string;
|
|
entries: JournalEntry[];
|
|
}
|
|
|
|
interface Migration {
|
|
entry: JournalEntry;
|
|
hash: string;
|
|
statements: string[];
|
|
}
|
|
|
|
interface AppliedMigration {
|
|
id: number;
|
|
hash: string;
|
|
created_at: string | number | bigint | null;
|
|
}
|
|
|
|
interface DatabaseError extends Error {
|
|
code?: string;
|
|
detail?: string;
|
|
hint?: string;
|
|
position?: string;
|
|
schema?: string;
|
|
table?: string;
|
|
column?: string;
|
|
constraint?: string;
|
|
}
|
|
|
|
interface MigrationClient {
|
|
query<T = unknown>(text: string, params?: unknown[]): Promise<{ rows: T[] }>;
|
|
end(): Promise<void>;
|
|
}
|
|
|
|
async function readJournal(): Promise<Journal> {
|
|
return JSON.parse(await readFile(JOURNAL_PATH, "utf8")) as Journal;
|
|
}
|
|
|
|
async function readMigration(entry: JournalEntry): Promise<Migration> {
|
|
const migrationPath = path.join(MIGRATIONS_DIR, `${entry.tag}.sql`);
|
|
const sql = await readFile(migrationPath, "utf8");
|
|
|
|
return {
|
|
entry,
|
|
hash: createHash("sha256").update(sql).digest("hex"),
|
|
statements: sql
|
|
.split("--> statement-breakpoint")
|
|
.map((statement) => statement.trim())
|
|
.filter(Boolean),
|
|
};
|
|
}
|
|
|
|
function createdAtValue(
|
|
migration: AppliedMigration | undefined,
|
|
): number | null {
|
|
if (!migration?.created_at) return null;
|
|
|
|
const value = Number(migration.created_at);
|
|
return Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
function summarizeStatement(statement: string): string {
|
|
return statement.replace(/\s+/g, " ").slice(0, 500);
|
|
}
|
|
|
|
function formatDatabaseError(error: unknown): string {
|
|
if (!(error instanceof Error)) {
|
|
return String(error);
|
|
}
|
|
|
|
const databaseError = error as DatabaseError;
|
|
const details = [
|
|
`message=${databaseError.message}`,
|
|
databaseError.code ? `code=${databaseError.code}` : null,
|
|
databaseError.detail ? `detail=${databaseError.detail}` : null,
|
|
databaseError.hint ? `hint=${databaseError.hint}` : null,
|
|
databaseError.position ? `position=${databaseError.position}` : null,
|
|
databaseError.schema ? `schema=${databaseError.schema}` : null,
|
|
databaseError.table ? `table=${databaseError.table}` : null,
|
|
databaseError.column ? `column=${databaseError.column}` : null,
|
|
databaseError.constraint ? `constraint=${databaseError.constraint}` : null,
|
|
].filter(Boolean);
|
|
|
|
return details.join(" ");
|
|
}
|
|
|
|
async function ensureMigrationsTable(client: MigrationClient): Promise<void> {
|
|
await client.query(`CREATE SCHEMA IF NOT EXISTS "${MIGRATIONS_SCHEMA}"`);
|
|
await client.query(`
|
|
CREATE TABLE IF NOT EXISTS "${MIGRATIONS_SCHEMA}"."${MIGRATIONS_TABLE}" (
|
|
id SERIAL PRIMARY KEY,
|
|
hash text NOT NULL,
|
|
created_at bigint
|
|
)
|
|
`);
|
|
}
|
|
|
|
async function getLastAppliedMigration(
|
|
client: MigrationClient,
|
|
): Promise<AppliedMigration | undefined> {
|
|
const result = await client.query<AppliedMigration>(`
|
|
SELECT id, hash, created_at
|
|
FROM "${MIGRATIONS_SCHEMA}"."${MIGRATIONS_TABLE}"
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
`);
|
|
|
|
return result.rows[0];
|
|
}
|
|
|
|
async function applyMigration(
|
|
client: MigrationClient,
|
|
migration: Migration,
|
|
): Promise<void> {
|
|
const { entry, statements, hash } = migration;
|
|
|
|
console.log(
|
|
`[db:migrate] applying ${entry.tag} (${statements.length} statements)`,
|
|
);
|
|
await client.query("BEGIN");
|
|
|
|
try {
|
|
for (const [index, statement] of statements.entries()) {
|
|
try {
|
|
await client.query(statement);
|
|
} catch (error) {
|
|
console.error(
|
|
`[db:migrate] failed ${entry.tag} statement ${index + 1}/${statements.length}`,
|
|
);
|
|
console.error(`[db:migrate] sql: ${summarizeStatement(statement)}`);
|
|
console.error(`[db:migrate] error: ${formatDatabaseError(error)}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
await client.query(
|
|
`INSERT INTO "${MIGRATIONS_SCHEMA}"."${MIGRATIONS_TABLE}" (hash, created_at) VALUES ($1, $2)`,
|
|
[hash, entry.when],
|
|
);
|
|
await client.query("COMMIT");
|
|
} catch (error) {
|
|
await client.query("ROLLBACK");
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function createPGliteClient(url: string): Promise<MigrationClient> {
|
|
const stripped = url.slice("pglite://".length);
|
|
const dataDir = !stripped || stripped === "memory" ? undefined : stripped;
|
|
const { PGlite } = await import("@electric-sql/pglite");
|
|
const { vector } = await import("@electric-sql/pglite/vector");
|
|
const db = await PGlite.create({ dataDir, extensions: { vector } });
|
|
|
|
return {
|
|
// Migrations contain multi-statement chunks (drizzle does not split on `;`
|
|
// for non-breakpoint segments). PGlite's prepared `query()` rejects those,
|
|
// so route parameter-less SQL through `exec()` and bound queries through
|
|
// `query()`. Result rows from `exec()` come back as an array per statement;
|
|
// the migrate harness only reads rows from the bound queries it issues.
|
|
query: async <T>(text: string, params?: unknown[]) => {
|
|
if (params && params.length > 0) {
|
|
const result = await db.query<T>(text, params as unknown[]);
|
|
return { rows: result.rows };
|
|
}
|
|
const results = await db.exec(text);
|
|
const last = results[results.length - 1];
|
|
return { rows: (last?.rows as T[] | undefined) ?? [] };
|
|
},
|
|
end: () => db.close(),
|
|
};
|
|
}
|
|
|
|
async function createPgClient(url: string): Promise<MigrationClient> {
|
|
const { url: clientUrl, ssl: clientSsl } = enforceTlsForRemote(url);
|
|
const client = new Client({
|
|
connectionString: clientUrl,
|
|
...(clientSsl ? { ssl: clientSsl } : {}),
|
|
});
|
|
await client.connect();
|
|
return {
|
|
query: async <T>(text: string, params?: unknown[]) => {
|
|
const result = await client.query<Record<string, unknown>>(text, params);
|
|
return { rows: result.rows as T[] };
|
|
},
|
|
end: () => client.end(),
|
|
};
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const databaseUrl = process.env.DATABASE_URL;
|
|
if (!databaseUrl) {
|
|
throw new Error("DATABASE_URL is required to run database migrations.");
|
|
}
|
|
|
|
const journal = await readJournal();
|
|
const migrations = await Promise.all(
|
|
journal.entries.map((entry) => readMigration(entry)),
|
|
);
|
|
|
|
const client: MigrationClient = databaseUrl.startsWith("pglite://")
|
|
? await createPGliteClient(databaseUrl)
|
|
: await createPgClient(databaseUrl);
|
|
|
|
try {
|
|
await ensureMigrationsTable(client);
|
|
|
|
const lastApplied = await getLastAppliedMigration(client);
|
|
const lastAppliedCreatedAt = createdAtValue(lastApplied);
|
|
console.log(
|
|
`[db:migrate] last applied migration: ${
|
|
lastAppliedCreatedAt === null
|
|
? "none"
|
|
: `${lastAppliedCreatedAt} (${lastApplied?.hash.slice(0, 12)})`
|
|
}`,
|
|
);
|
|
|
|
const pending = migrations.filter(
|
|
(migration) =>
|
|
lastAppliedCreatedAt === null ||
|
|
migration.entry.when > lastAppliedCreatedAt,
|
|
);
|
|
console.log(`[db:migrate] pending migrations: ${pending.length}`);
|
|
|
|
for (const migration of pending) {
|
|
await applyMigration(client, migration);
|
|
}
|
|
|
|
console.log("[db:migrate] migrations complete");
|
|
} finally {
|
|
await client.end();
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(`[db:migrate] fatal: ${formatDatabaseError(error)}`);
|
|
process.exit(1);
|
|
});
|