Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

1339 lines
40 KiB
TypeScript

/**
* Idempotent Postgres + Redis migration to a new cloud.
*
* Reads from `DATABASE_URL` (the existing Neon in `.env`) and writes to
* `NEW_POSTGRES_URL`. Re-runs are safe: rows already copied are skipped via
* `INSERT ... ON CONFLICT DO NOTHING`, and per-table cursors live in a
* `_migration_state` table on the destination so an interrupted run resumes
* from the last committed batch.
*
* Heavy text/jsonb columns flagged by migration 0081 are uploaded to R2 during
* the copy and replaced with a `*_storage='r2'` + `*_key=<object-key>` pointer.
* If R2 isn't configured the script just copies inline.
*
* Tables that exist purely as ephemeral state (idempotency keys, anonymous
* sessions, webhook dedup, daily aggregates) are skipped.
*
* Redis: nothing to copy. The script PINGs `NEW_REDIS_URL` if set, then exits.
* The cache repopulates on first request.
*
* Usage:
* bun run packages/scripts/migrate-database.ts
* bun run packages/scripts/migrate-database.ts --dry-run
* bun run packages/scripts/migrate-database.ts --only=users,organizations
* bun run packages/scripts/migrate-database.ts --reset
*/
import { spawnSync } from "node:child_process";
import path from "node:path";
import { enforceTlsForRemote } from "@elizaos/cloud-shared/db/client";
import pg from "pg";
import { loadEnvFiles } from "./local-dev-helpers";
// Existing shell env wins so a one-off `NEW_POSTGRES_URL=… bun run …` works.
// Then .env.local fills gaps; then .env fills the rest.
loadEnvFiles([".env.local", ".env"]);
// Imports below depend on env being loaded first because they read process.env at module init.
const { putObjectText } = await import("../lib/storage/object-store");
const { ObjectNamespaces } = await import("../lib/storage/object-namespace");
const { putTrajectoryPayload } = await import(
"../lib/services/trajectory-object-storage"
);
const { objectStorageConfigured } = await import(
"../lib/storage/s3-compatible-client"
);
const { Client } = pg;
type PgClient = pg.Client;
// ───────────────────────────────────────────────────────────── CLI parsing ──
interface CliArgs {
dryRun: boolean;
noR2: boolean;
reset: boolean;
skipMigrate: boolean;
skipRedis: boolean;
batchSize: number;
r2MinBytes: number;
only: Set<string> | null;
skip: Set<string>;
}
function parseArgs(argv: string[]): CliArgs {
const out: CliArgs = {
dryRun: false,
noR2: false,
reset: false,
skipMigrate: false,
skipRedis: false,
batchSize: 1000,
r2MinBytes: 1024,
only: null,
skip: new Set(),
};
for (const raw of argv) {
const [key, val] = raw.startsWith("--")
? raw.slice(2).split("=", 2)
: [raw, undefined];
switch (key) {
case "dry-run":
out.dryRun = true;
break;
case "no-r2":
out.noR2 = true;
break;
case "reset":
out.reset = true;
break;
case "skip-migrate":
out.skipMigrate = true;
break;
case "skip-redis":
out.skipRedis = true;
break;
case "batch-size":
out.batchSize = Math.max(1, Number(val ?? 1000));
break;
case "r2-min-bytes":
out.r2MinBytes = Math.max(0, Number(val ?? 1024));
break;
case "only":
out.only = new Set(
(val ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
);
break;
case "skip":
for (const t of (val ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)) {
out.skip.add(t);
}
break;
default:
if (raw !== "") throw new Error(`Unknown argument: ${raw}`);
}
}
return out;
}
// ───────────────────────────────────────────────────────── skip / R2 config ──
/**
* Tables we explicitly do not copy. All of these either expire on their own,
* dedupe state we'd rather rebuild fresh, or aggregate views we can recompute.
* `_migration_state` is the destination-only progress table.
*/
const ALWAYS_SKIP = new Set([
"webhook_events",
"idempotency_keys",
"cli_auth_sessions",
"agent_pairing_tokens",
"anonymous_sessions",
"daily_metrics",
"provider_health",
"_migration_state",
"__drizzle_migrations",
]);
interface R2TextField {
column: string;
storageColumn: string;
keyColumn: string;
namespace: string;
field: string;
inlineValueWhenOffloaded?: string;
}
interface R2JsonField extends R2TextField {
inlineValueWhenOffloaded?: (value: unknown) => unknown;
}
interface R2OffloadConfig {
/** Special-cased trajectory bundle (system_prompt + user_prompt + response_text). */
trajectoryBundle?: boolean;
text: R2TextField[];
json: R2JsonField[];
}
const R2_TABLES: Record<string, R2OffloadConfig> = {
llm_trajectories: {
trajectoryBundle: true,
text: [],
json: [],
},
conversation_messages: {
text: [
{
column: "content",
storageColumn: "content_storage",
keyColumn: "content_key",
namespace: ObjectNamespaces.ConversationMessageBodies,
field: "content",
},
],
json: [
{
column: "api_request",
storageColumn: "api_request_storage",
keyColumn: "api_request_key",
namespace: ObjectNamespaces.ConversationMessageApiPayloads,
field: "api_request",
},
{
column: "api_response",
storageColumn: "api_response_storage",
keyColumn: "api_response_key",
namespace: ObjectNamespaces.ConversationMessageApiPayloads,
field: "api_response",
},
],
},
generations: {
text: [
{
column: "prompt",
storageColumn: "prompt_storage",
keyColumn: "prompt_key",
namespace: ObjectNamespaces.GenerationArtifacts,
field: "prompt",
},
{
column: "negative_prompt",
storageColumn: "negative_prompt_storage",
keyColumn: "negative_prompt_key",
namespace: ObjectNamespaces.GenerationArtifacts,
field: "negative_prompt",
},
{
column: "content",
storageColumn: "content_storage",
keyColumn: "content_key",
namespace: ObjectNamespaces.GenerationArtifacts,
field: "content",
},
],
json: [
{
column: "result",
storageColumn: "result_storage",
keyColumn: "result_key",
namespace: ObjectNamespaces.GenerationArtifacts,
field: "result",
},
],
},
jobs: {
text: [
{
column: "error",
storageColumn: "error_storage",
keyColumn: "error_key",
namespace: ObjectNamespaces.JobPayloads,
field: "error",
},
],
json: [
{
column: "data",
storageColumn: "data_storage",
keyColumn: "data_key",
namespace: ObjectNamespaces.JobPayloads,
field: "data",
inlineValueWhenOffloaded: inlineJobDataForMigration,
},
{
column: "result",
storageColumn: "result_storage",
keyColumn: "result_key",
namespace: ObjectNamespaces.JobPayloads,
field: "result",
},
],
},
containers: {
text: [
{
column: "deployment_log",
storageColumn: "deployment_log_storage",
keyColumn: "deployment_log_key",
namespace: ObjectNamespaces.ContainerDeployLogs,
field: "deployment_log",
},
],
json: [],
},
agent_events: {
text: [
{
column: "message",
storageColumn: "message_storage",
keyColumn: "message_key",
namespace: ObjectNamespaces.AgentEventBodies,
field: "message",
},
],
json: [
{
column: "metadata",
storageColumn: "metadata_storage",
keyColumn: "metadata_key",
namespace: ObjectNamespaces.AgentEventBodies,
field: "metadata",
},
],
},
phone_message_log: {
text: [
{
column: "message_body",
storageColumn: "message_body_storage",
keyColumn: "message_body_key",
namespace: ObjectNamespaces.PhoneMessagePayloads,
field: "message_body",
},
{
column: "media_urls",
storageColumn: "media_urls_storage",
keyColumn: "media_urls_key",
namespace: ObjectNamespaces.PhoneMessagePayloads,
field: "media_urls",
inlineValueWhenOffloaded: "[]",
},
{
column: "agent_response",
storageColumn: "agent_response_storage",
keyColumn: "agent_response_key",
namespace: ObjectNamespaces.PhoneMessagePayloads,
field: "agent_response",
},
{
column: "metadata",
storageColumn: "metadata_storage",
keyColumn: "metadata_key",
namespace: ObjectNamespaces.PhoneMessagePayloads,
field: "metadata",
inlineValueWhenOffloaded: "{}",
},
],
json: [],
},
twilio_inbound_calls: {
text: [],
json: [
{
column: "raw_payload",
storageColumn: "raw_payload_storage",
keyColumn: "raw_payload_key",
namespace: ObjectNamespaces.TwilioInboundPayloads,
field: "raw_payload",
inlineValueWhenOffloaded: emptyRecordWhenOffloaded,
},
],
},
seo_requests: {
text: [
{
column: "prompt_context",
storageColumn: "prompt_context_storage",
keyColumn: "prompt_context_key",
namespace: ObjectNamespaces.SeoPayloads,
field: "prompt_context",
},
],
json: [],
},
seo_artifacts: {
text: [],
json: [
{
column: "data",
storageColumn: "data_storage",
keyColumn: "data_key",
namespace: ObjectNamespaces.SeoPayloads,
field: "artifact_data",
inlineValueWhenOffloaded: emptyRecordWhenOffloaded,
},
],
},
seo_provider_calls: {
text: [],
json: [
{
column: "request_payload",
storageColumn: "request_payload_storage",
keyColumn: "request_payload_key",
namespace: ObjectNamespaces.SeoPayloads,
field: "request_payload",
},
{
column: "response_payload",
storageColumn: "response_payload_storage",
keyColumn: "response_payload_key",
namespace: ObjectNamespaces.SeoPayloads,
field: "response_payload",
},
],
},
vertex_tuning_jobs: {
text: [],
json: [
{
column: "last_remote_payload",
storageColumn: "last_remote_payload_storage",
keyColumn: "last_remote_payload_key",
namespace: ObjectNamespaces.VertexTuningPayloads,
field: "last_remote_payload",
inlineValueWhenOffloaded: emptyRecordWhenOffloaded,
},
],
},
agent_sandbox_backups: {
text: [],
json: [
{
column: "state_data",
storageColumn: "state_data_storage",
keyColumn: "state_data_key",
namespace: ObjectNamespaces.AgentSandboxBackups,
field: "state_data",
inlineValueWhenOffloaded: emptyAgentBackupStateWhenOffloaded,
},
],
},
};
// ─────────────────────────────────────────────── schema introspection types ──
interface ColumnInfo {
name: string;
dataType: string;
isGenerated: boolean;
}
interface TableInfo {
name: string;
columns: ColumnInfo[];
primaryKey: string[];
/** Inserted-cursor column for paginated copy (id by default; created_at fallback). */
cursorColumn: string;
cursorIsText: boolean;
}
interface CopyStats {
source: number;
copied: number;
skipped: number;
r2Uploads: number;
r2BytesOffloaded: number;
}
// ─────────────────────────────────────────────── helpers: schema discovery ──
async function listTables(client: PgClient): Promise<string[]> {
const r = await client.query<{ table_name: string }>(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name
`);
return r.rows.map((row) => row.table_name);
}
async function describeTable(
client: PgClient,
table: string,
): Promise<TableInfo | null> {
const cols = await client.query<{
column_name: string;
data_type: string;
is_generated: string;
}>(
`
SELECT column_name, data_type, is_generated
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1
ORDER BY ordinal_position
`,
[table],
);
if (cols.rowCount === 0) return null;
const pk = await client.query<{ column_name: string }>(
`
SELECT a.attname AS column_name
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = $1::regclass AND i.indisprimary
ORDER BY array_position(i.indkey, a.attnum)
`,
[`public."${table}"`],
);
const columns: ColumnInfo[] = cols.rows.map((c) => ({
name: c.column_name,
dataType: c.data_type,
isGenerated: c.is_generated === "ALWAYS",
}));
const colByName = new Map(columns.map((c) => [c.name, c]));
const primaryKey = pk.rows.map((r) => r.column_name);
let cursorColumn = "id";
if (!colByName.has(cursorColumn)) {
if (colByName.has("created_at")) cursorColumn = "created_at";
else if (primaryKey.length === 1) cursorColumn = primaryKey[0];
else cursorColumn = columns[0].name; // last resort, full-scan friendly
}
const cursorMeta = colByName.get(cursorColumn);
const cursorIsText = !cursorMeta
? true
: cursorMeta.dataType === "uuid" ||
cursorMeta.dataType.includes("char") ||
cursorMeta.dataType === "text" ||
cursorMeta.dataType.startsWith("timestamp") ||
cursorMeta.dataType === "date";
return { name: table, columns, primaryKey, cursorColumn, cursorIsText };
}
function intersectColumns(src: TableInfo, dst: TableInfo): ColumnInfo[] {
const dstByName = new Map(dst.columns.map((c) => [c.name, c]));
return src.columns.filter((c) => {
const d = dstByName.get(c.name);
if (!d) return false;
if (d.isGenerated) return false; // generated columns can't be inserted
return true;
});
}
// ───────────────────────────────────── helpers: progress state on dest DB ──
async function ensureProgressTable(client: PgClient): Promise<void> {
await client.query(`
CREATE TABLE IF NOT EXISTS "_migration_state" (
table_name text PRIMARY KEY,
last_cursor text,
rows_copied bigint NOT NULL DEFAULT 0,
r2_uploads bigint NOT NULL DEFAULT 0,
r2_bytes bigint NOT NULL DEFAULT 0,
completed boolean NOT NULL DEFAULT false,
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
}
async function loadProgress(
client: PgClient,
table: string,
): Promise<{
cursor: string | null;
copied: number;
r2Uploads: number;
r2Bytes: number;
completed: boolean;
}> {
const r = await client.query<{
last_cursor: string | null;
rows_copied: string;
r2_uploads: string;
r2_bytes: string;
completed: boolean;
}>(
`SELECT last_cursor, rows_copied, r2_uploads, r2_bytes, completed
FROM "_migration_state" WHERE table_name = $1`,
[table],
);
if (r.rowCount === 0) {
return {
cursor: null,
copied: 0,
r2Uploads: 0,
r2Bytes: 0,
completed: false,
};
}
const row = r.rows[0];
return {
cursor: row.last_cursor,
copied: Number(row.rows_copied),
r2Uploads: Number(row.r2_uploads),
r2Bytes: Number(row.r2_bytes),
completed: row.completed,
};
}
async function saveProgress(
client: PgClient,
table: string,
cursor: string | null,
copied: number,
r2Uploads: number,
r2Bytes: number,
completed: boolean,
): Promise<void> {
await client.query(
`
INSERT INTO "_migration_state" (table_name, last_cursor, rows_copied, r2_uploads, r2_bytes, completed, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, now())
ON CONFLICT (table_name) DO UPDATE SET
last_cursor = EXCLUDED.last_cursor,
rows_copied = EXCLUDED.rows_copied,
r2_uploads = EXCLUDED.r2_uploads,
r2_bytes = EXCLUDED.r2_bytes,
completed = EXCLUDED.completed,
updated_at = now()
`,
[table, cursor, copied, r2Uploads, r2Bytes, completed],
);
}
async function resetProgress(client: PgClient): Promise<void> {
await client.query(`DROP TABLE IF EXISTS "_migration_state"`);
await ensureProgressTable(client);
}
// ────────────────────────────────────────────────────────── R2 offload helpers ──
function byteLength(value: string): number {
return new TextEncoder().encode(value).byteLength;
}
function stringRecordField(
value: unknown,
field: "agentId" | "characterId",
): string | null {
if (!value || typeof value !== "object") return null;
const raw = (value as Record<string, unknown>)[field];
return typeof raw === "string" && raw.length > 0 ? raw : null;
}
function inlineJobDataForMigration(value: unknown): Record<string, unknown> {
const inline: Record<string, unknown> = {};
const agentId = stringRecordField(value, "agentId");
const characterId = stringRecordField(value, "characterId");
if (agentId) inline.agentId = agentId;
if (characterId) inline.characterId = characterId;
return inline;
}
function emptyRecordWhenOffloaded(): Record<string, unknown> {
return {};
}
function emptyAgentBackupStateWhenOffloaded(): {
memories: unknown[];
config: Record<string, unknown>;
workspaceFiles: Record<string, string>;
} {
return { memories: [], config: {}, workspaceFiles: {} };
}
interface OffloadResult {
inlineValue: unknown;
storage: "inline" | "r2";
key: string | null;
bytes: number;
}
async function offloadText(
field: R2TextField,
value: string | null,
organizationId: string,
rowId: string,
createdAt: Date,
minBytes: number,
): Promise<OffloadResult> {
if (value == null)
return { inlineValue: null, storage: "inline", key: null, bytes: 0 };
const bytes = byteLength(value);
if (bytes < minBytes)
return { inlineValue: value, storage: "inline", key: null, bytes: 0 };
const key = await putObjectText({
namespace: field.namespace as never,
organizationId,
objectId: rowId,
field: field.field,
createdAt,
body: value,
contentType: "text/plain; charset=utf-8",
});
return {
inlineValue: field.inlineValueWhenOffloaded ?? "",
storage: "r2",
key,
bytes,
};
}
async function offloadJson(
field: R2JsonField,
value: unknown,
organizationId: string,
rowId: string,
createdAt: Date,
minBytes: number,
): Promise<OffloadResult> {
if (value == null)
return { inlineValue: null, storage: "inline", key: null, bytes: 0 };
const body = JSON.stringify(value);
const bytes = byteLength(body);
if (bytes < minBytes)
return { inlineValue: value, storage: "inline", key: null, bytes: 0 };
const key = await putObjectText({
namespace: field.namespace as never,
organizationId,
objectId: rowId,
field: field.field,
createdAt,
body,
contentType: "application/json; charset=utf-8",
});
return {
inlineValue: field.inlineValueWhenOffloaded
? field.inlineValueWhenOffloaded(value)
: null,
storage: "r2",
key,
bytes,
};
}
async function offloadTrajectoryBundle(
row: Record<string, unknown>,
minBytes: number,
): Promise<{
storage: "inline" | "r2";
key: string | null;
blankPrompts: boolean;
bytes: number;
}> {
const sys = (row.system_prompt as string | null) ?? null;
const usr = (row.user_prompt as string | null) ?? null;
const resp = (row.response_text as string | null) ?? null;
if (sys == null && usr == null && resp == null) {
return { storage: "inline", key: null, blankPrompts: false, bytes: 0 };
}
const totalBytes =
byteLength(sys ?? "") + byteLength(usr ?? "") + byteLength(resp ?? "");
if (totalBytes < minBytes) {
return { storage: "inline", key: null, blankPrompts: false, bytes: 0 };
}
const orgId = String(row.organization_id ?? "no-org");
const id = String(row.id);
const createdAt = (row.created_at as Date | null) ?? new Date();
const key = await putTrajectoryPayload({
organizationId: orgId,
trajectoryId: id,
createdAt,
body: { system_prompt: sys, user_prompt: usr, response_text: resp },
});
return { storage: "r2", key, blankPrompts: true, bytes: totalBytes };
}
// ─────────────────────────────────────────────────── value coercion for INSERT ──
function isJsonish(dataType: string): boolean {
return dataType === "jsonb" || dataType === "json";
}
/**
* Normalize a value coming back from `pg` into something the destination
* `INSERT` accepts. The big one: pg auto-parses jsonb on read into JS values
* (objects, arrays, primitives), and the destination needs the JSON text back
* to satisfy the `::jsonb` cast — `JSON.stringify` always, even for string
* scalars (a stored `"awdaw"` returns as the JS string `awdaw` and must go back
* as the JSON literal `"awdaw"`).
*/
function coerceForInsert(value: unknown, destDataType: string): unknown {
if (value === null || value === undefined) return null;
if (isJsonish(destDataType)) return JSON.stringify(value);
return value;
}
function placeholder(idx: number, destDataType: string): string {
const base = `$${idx}`;
if (isJsonish(destDataType)) return `${base}::jsonb`;
return base;
}
// ───────────────────────────────────────────────────────── per-table copy ──
async function copyTable(
source: PgClient,
dest: PgClient,
tableName: string,
args: CliArgs,
): Promise<CopyStats> {
const srcInfo = await describeTable(source, tableName);
if (!srcInfo) {
console.log(` - table missing on source, skipping`);
return {
source: 0,
copied: 0,
skipped: 0,
r2Uploads: 0,
r2BytesOffloaded: 0,
};
}
const dstInfo = await describeTable(dest, tableName);
if (!dstInfo) {
console.log(
` - table missing on destination (run migrations first), skipping`,
);
return {
source: 0,
copied: 0,
skipped: 0,
r2Uploads: 0,
r2BytesOffloaded: 0,
};
}
const sharedCols = intersectColumns(srcInfo, dstInfo);
if (sharedCols.length === 0) {
console.log(` - no shared columns, skipping`);
return {
source: 0,
copied: 0,
skipped: 0,
r2Uploads: 0,
r2BytesOffloaded: 0,
};
}
const srcCount = await source.query<{ c: string }>(
`SELECT count(*)::text AS c FROM public."${tableName}"`,
);
const sourceTotal = Number(srcCount.rows[0].c);
const prog = await loadProgress(dest, tableName);
if (prog.completed) {
console.log(
` - already completed (${prog.copied} rows). Use --reset to redo.`,
);
return {
source: sourceTotal,
copied: prog.copied,
skipped: 0,
r2Uploads: prog.r2Uploads,
r2BytesOffloaded: prog.r2Bytes,
};
}
const r2Cfg =
!args.noR2 && objectStorageConfigured() ? R2_TABLES[tableName] : undefined;
if (r2Cfg) {
console.log(
` - R2 offload enabled for this table (min ${args.r2MinBytes} bytes)`,
);
} else if (R2_TABLES[tableName] && args.noR2) {
console.log(` - R2 offload skipped (--no-r2)`);
} else if (R2_TABLES[tableName]) {
console.log(` - R2 offload skipped (object storage not configured)`);
}
// SELECT list = all source columns the destination also has, in source order.
const selectCols = sharedCols.map((c) => `"${c.name}"`).join(", ");
const cursorCol = `"${srcInfo.cursorColumn}"`;
const cursorTypeCast = srcInfo.cursorIsText ? "::text" : "::text";
let cursor = prog.cursor;
let copied = prog.copied;
let r2Uploads = prog.r2Uploads;
let r2Bytes = prog.r2Bytes;
let totalSkippedConflicts = 0;
let batchNum = 0;
while (true) {
batchNum += 1;
const params: unknown[] = [args.batchSize];
let where = "";
if (cursor !== null) {
params.push(cursor);
where = `WHERE ${cursorCol}${cursorTypeCast} > $2`;
}
const result = await source.query<Record<string, unknown>>(
`SELECT ${selectCols} FROM public."${tableName}" ${where} ORDER BY ${cursorCol} LIMIT $1`,
params,
);
if (result.rowCount === 0) break;
if (args.dryRun) {
const last = result.rows[result.rowCount - 1];
cursor = String(last[srcInfo.cursorColumn]);
copied += result.rowCount;
console.log(
` batch ${batchNum}: would copy ${result.rowCount} rows (cursor=${cursor.slice(0, 12)}...)`,
);
continue;
}
// Apply R2 offload + collect insert columns/values per row.
const insertCols = sharedCols.map((c) => c.name);
// Destination types drive coercion + casting (jsonb on dest needs stringified JSON).
const dstTypes = new Map(dstInfo.columns.map((c) => [c.name, c.dataType]));
// Pre-flight: each row may add storage/key columns the destination has but the source
// didn't include. We always include them when the destination has them.
const dstColSet = new Set(
dstInfo.columns.filter((c) => !c.isGenerated).map((c) => c.name),
);
await dest.query("BEGIN");
try {
for (const row of result.rows) {
const rowOut: Record<string, unknown> = { ...row };
if (r2Cfg) {
const orgId = String(rowOut.organization_id ?? "no-org");
const rowId = String(
rowOut.id ?? rowOut[srcInfo.primaryKey[0] ?? "id"],
);
const createdAt = (rowOut.created_at as Date | null) ?? new Date();
if (r2Cfg.trajectoryBundle) {
// Source row already lives in R2: copy the pointer through, don't re-upload.
const alreadyOffloaded = rowOut.trajectory_payload_storage === "r2";
if (!alreadyOffloaded) {
const r = await offloadTrajectoryBundle(rowOut, args.r2MinBytes);
if (r.storage === "r2") {
rowOut.trajectory_payload_storage = "r2";
rowOut.trajectory_payload_key = r.key;
if (r.blankPrompts) {
rowOut.system_prompt = null;
rowOut.user_prompt = null;
rowOut.response_text = null;
}
r2Uploads += 1;
r2Bytes += r.bytes;
}
}
}
for (const f of r2Cfg.text) {
if (!(f.column in rowOut)) continue;
// Source row already lives in R2: copy the pointer through, don't re-upload.
if (rowOut[f.storageColumn] === "r2") continue;
const r = await offloadText(
f,
(rowOut[f.column] as string | null) ?? null,
orgId,
rowId,
createdAt,
args.r2MinBytes,
);
if (r.storage === "r2") {
rowOut[f.column] = r.inlineValue;
rowOut[f.storageColumn] = "r2";
rowOut[f.keyColumn] = r.key;
r2Uploads += 1;
r2Bytes += r.bytes;
}
}
for (const f of r2Cfg.json) {
if (!(f.column in rowOut)) continue;
if (rowOut[f.storageColumn] === "r2") continue;
const r = await offloadJson(
f,
rowOut[f.column],
orgId,
rowId,
createdAt,
args.r2MinBytes,
);
if (r.storage === "r2") {
rowOut[f.column] = r.inlineValue;
rowOut[f.storageColumn] = "r2";
rowOut[f.keyColumn] = r.key;
r2Uploads += 1;
r2Bytes += r.bytes;
}
}
}
if (tableName === "jobs") {
rowOut.agent_id ??= stringRecordField(row.data, "agentId");
rowOut.character_id ??= stringRecordField(row.data, "characterId");
}
// Build the actual insert column list for this row (the offload may have introduced
// storage/key columns that aren't in `insertCols`).
const finalCols = new Set<string>(insertCols);
if (r2Cfg) {
if (r2Cfg.trajectoryBundle) {
for (const c of [
"trajectory_payload_storage",
"trajectory_payload_key",
"system_prompt",
"user_prompt",
"response_text",
]) {
if (dstColSet.has(c)) finalCols.add(c);
}
}
for (const f of [...r2Cfg.text, ...r2Cfg.json]) {
if (dstColSet.has(f.storageColumn)) finalCols.add(f.storageColumn);
if (dstColSet.has(f.keyColumn)) finalCols.add(f.keyColumn);
}
}
if (tableName === "jobs") {
if (dstColSet.has("agent_id")) finalCols.add("agent_id");
if (dstColSet.has("character_id")) finalCols.add("character_id");
}
const orderedCols = [...finalCols];
const values: unknown[] = [];
const placeholders: string[] = [];
orderedCols.forEach((c, i) => {
const dt = dstTypes.get(c) ?? "text";
values.push(coerceForInsert(rowOut[c], dt));
placeholders.push(placeholder(i + 1, dt));
});
// Targetless ON CONFLICT DO NOTHING catches the PK *and* every other unique
// constraint/index, which matters for tables like service_pricing that the
// schema migrations themselves seed before we ever copy.
const sql = `INSERT INTO public."${tableName}" (${orderedCols
.map((c) => `"${c}"`)
.join(
", ",
)}) VALUES (${placeholders.join(", ")}) ON CONFLICT DO NOTHING`;
const ins = await dest.query(sql, values);
if (ins.rowCount === 0) totalSkippedConflicts += 1;
else copied += 1;
}
const last = result.rows[result.rowCount - 1];
cursor = String(last[srcInfo.cursorColumn]);
await saveProgress(
dest,
tableName,
cursor,
copied,
r2Uploads,
r2Bytes,
false,
);
await dest.query("COMMIT");
} catch (err) {
await dest.query("ROLLBACK");
throw err;
}
process.stdout.write(
` batch ${batchNum}: ${result.rowCount} processed (copied=${copied}, conflicts=${totalSkippedConflicts}, r2=${r2Uploads})\r`,
);
if (result.rowCount < args.batchSize) break;
}
if (!args.dryRun) {
await saveProgress(
dest,
tableName,
cursor,
copied,
r2Uploads,
r2Bytes,
true,
);
}
process.stdout.write("\n");
return {
source: sourceTotal,
copied,
skipped: totalSkippedConflicts,
r2Uploads,
r2BytesOffloaded: r2Bytes,
};
}
// ─────────────────────────────────────────── apply schema migrations to dest ──
function runDestMigrations(newUrl: string): void {
console.log("→ Applying drizzle migrations to destination…");
const child = spawnSync(
"bun",
[
"run",
path.join(
"packages",
"scripts",
"cloud",
"admin",
"migrate-with-diagnostics.ts",
),
],
{
stdio: "inherit",
env: {
...process.env,
DATABASE_URL: newUrl,
},
},
);
if (child.status !== 0) {
throw new Error(
`Destination migrations failed (exit code ${child.status})`,
);
}
}
// ───────────────────────────────────────────────────────────── Redis check ──
async function checkRedis(newRedisUrl: string): Promise<void> {
console.log(
`→ Verifying new Redis at ${newRedisUrl.replace(/\/\/[^@]+@/, "//***@")}`,
);
const { createClient } = await import("redis");
const client = createClient({ url: newRedisUrl });
client.on("error", () => {
/* surfaced via the await below */
});
await client.connect();
const pong = await client.ping();
if (pong !== "PONG") throw new Error(`Unexpected PING response: ${pong}`);
await client.quit();
console.log(" ✓ Redis reachable. Cache will repopulate on first request.");
}
// ───────────────────────────────────────────────────── topological FK order ──
/**
* Order tables so that FK referents are inserted before dependents. We tolerate
* cycles (users<->organizations is the canonical one) by inserting the cycle
* tables last and relying on `ON CONFLICT DO NOTHING` + post-pass updates if
* needed. Most managed Postgres providers permit `SET session_replication_role`
* for the table owner; if so, we skip the topo sort entirely.
*/
async function topologicalOrder(
client: PgClient,
tables: string[],
): Promise<string[]> {
const setT = new Set(tables);
const fks = await client.query<{
table_name: string;
foreign_table_name: string;
}>(`
SELECT
tc.table_name AS table_name,
ccu.table_name AS foreign_table_name
FROM information_schema.table_constraints tc
JOIN information_schema.constraint_column_usage ccu
ON tc.constraint_name = ccu.constraint_name
AND tc.table_schema = ccu.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'public'
`);
const deps = new Map<string, Set<string>>();
for (const t of tables) deps.set(t, new Set());
for (const r of fks.rows) {
if (!setT.has(r.table_name) || !setT.has(r.foreign_table_name)) continue;
if (r.table_name === r.foreign_table_name) continue;
deps.get(r.table_name)!.add(r.foreign_table_name);
}
const ordered: string[] = [];
const visiting = new Set<string>();
const visited = new Set<string>();
const visit = (n: string): void => {
if (visited.has(n)) return;
if (visiting.has(n)) return; // cycle: break the edge, will be retried
visiting.add(n);
for (const d of deps.get(n) ?? []) visit(d);
visiting.delete(n);
visited.add(n);
ordered.push(n);
};
for (const t of tables) visit(t);
return ordered;
}
async function trySessionReplicationReplica(
client: PgClient,
): Promise<boolean> {
try {
await client.query("SET session_replication_role = 'replica'");
const r = await client.query<{ s: string }>(
"SHOW session_replication_role",
);
return r.rows[0]?.s === "replica";
} catch {
return false;
}
}
// ───────────────────────────────────────────────────────────────── main ──
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const sourceUrl = process.env.DATABASE_URL;
const destUrl = process.env.NEW_POSTGRES_URL ?? process.env.NEW_DATABASE_URL;
if (!sourceUrl) throw new Error("DATABASE_URL (source) is required.");
if (!destUrl) {
throw new Error(
"NEW_POSTGRES_URL (destination) is required. Add it to .env or export it before running.",
);
}
if (sourceUrl === destUrl) {
throw new Error(
"Source and destination DATABASE_URL are identical — refusing to run.",
);
}
console.log(`source: ${sourceUrl.replace(/\/\/[^@]+@/, "//***@")}`);
console.log(`destination: ${destUrl.replace(/\/\/[^@]+@/, "//***@")}`);
console.log(`mode: ${args.dryRun ? "DRY-RUN" : "WRITE"}`);
console.log(
`r2: ${
args.noR2
? "disabled (--no-r2)"
: objectStorageConfigured()
? "enabled"
: "not configured"
}`,
);
if (args.only) console.log(`--only: ${[...args.only].join(", ")}`);
if (args.skip.size > 0)
console.log(`--skip: ${[...args.skip].join(", ")}`);
// 1. Apply destination schema unless skipped.
if (!args.skipMigrate && !args.dryRun) {
runDestMigrations(destUrl);
} else {
console.log(
"→ Skipping destination schema migrations (--skip-migrate or --dry-run)",
);
}
// 2. Connect.
const { url: sourceConnUrl, ssl: sourceSsl } = enforceTlsForRemote(sourceUrl);
const { url: destConnUrl, ssl: destSsl } = enforceTlsForRemote(destUrl);
const source = new Client({
connectionString: sourceConnUrl,
...(sourceSsl ? { ssl: sourceSsl } : {}),
});
const dest = new Client({
connectionString: destConnUrl,
...(destSsl ? { ssl: destSsl } : {}),
});
await source.connect();
await dest.connect();
try {
// 3. Reset progress if asked.
await ensureProgressTable(dest);
if (args.reset && !args.dryRun) {
console.log("→ --reset: dropping _migration_state");
await resetProgress(dest);
}
// 4. Try to disable triggers on dest so we don't have to topo-sort.
let useTopoOrder = true;
if (!args.dryRun) {
const ok = await trySessionReplicationReplica(dest);
if (ok) {
useTopoOrder = false;
console.log(
"→ Disabled triggers on destination (session_replication_role=replica)",
);
} else {
console.log(
"→ Could not disable destination triggers; falling back to topological order",
);
}
}
// 5. Pick tables.
const allTables = await listTables(source);
let tables = allTables.filter(
(t) => !ALWAYS_SKIP.has(t) && !args.skip.has(t),
);
if (args.only) tables = tables.filter((t) => args.only!.has(t));
if (useTopoOrder) {
tables = await topologicalOrder(source, tables);
}
console.log(
`\n→ Will process ${tables.length} tables:\n ${tables.join(", ")}\n`,
);
const skipped = allTables.filter((t) => !tables.includes(t));
if (skipped.length > 0) {
console.log(` (skipping: ${skipped.join(", ")})\n`);
}
// 6. Copy each.
const summary: Array<{ table: string; stats: CopyStats }> = [];
for (const table of tables) {
console.log(`\n[${table}]`);
const stats = await copyTable(source, dest, table, args);
summary.push({ table, stats });
}
// 7. Optional Redis check.
const newRedisUrl = process.env.NEW_REDIS_URL;
if (newRedisUrl && !args.skipRedis) {
console.log("");
try {
await checkRedis(newRedisUrl);
} catch (err) {
console.error(` ✗ Redis check failed: ${(err as Error).message}`);
}
} else if (!newRedisUrl) {
console.log(
"\n→ NEW_REDIS_URL not set — skipping Redis check (cache will rebuild on demand)",
);
}
// 8. Print summary.
console.log(
"\n─── Summary ────────────────────────────────────────────────",
);
let totalSrc = 0;
let totalCopied = 0;
let totalR2Uploads = 0;
let totalR2Bytes = 0;
for (const { table, stats } of summary) {
totalSrc += stats.source;
totalCopied += stats.copied;
totalR2Uploads += stats.r2Uploads;
totalR2Bytes += stats.r2BytesOffloaded;
const r2Suffix =
stats.r2Uploads > 0
? ` r2:${stats.r2Uploads} (${(stats.r2BytesOffloaded / 1024).toFixed(1)} KiB)`
: "";
console.log(
` ${table.padEnd(40)} src=${String(stats.source).padStart(8)} copied=${String(
stats.copied,
).padStart(8)} skip=${String(stats.skipped).padStart(6)}${r2Suffix}`,
);
}
console.log(
` ${"TOTAL".padEnd(40)} src=${String(totalSrc).padStart(8)} copied=${String(
totalCopied,
).padStart(
8,
)} r2:${totalR2Uploads} (${(totalR2Bytes / 1024 / 1024).toFixed(2)} MiB)`,
);
console.log("────────────────────────────────────────────────────────────");
} finally {
await source.end();
await dest.end();
}
}
main().catch((err) => {
console.error("\n[migrate-database] fatal:", err);
process.exit(1);
});