Fix worktree minimal clone startup

This commit is contained in:
Dotta
2026-03-10 10:08:58 -05:00
parent 4a67db6a4d
commit 83738b45cd
6 changed files with 140 additions and 64 deletions

View File

@@ -6,6 +6,7 @@ import { createServer } from "node:net";
import * as p from "@clack/prompts"; import * as p from "@clack/prompts";
import pc from "picocolors"; import pc from "picocolors";
import { import {
applyPendingMigrations,
ensurePostgresDatabase, ensurePostgresDatabase,
formatDatabaseBackupResult, formatDatabaseBackupResult,
runDatabaseBackup, runDatabaseBackup,
@@ -251,6 +252,7 @@ async function seedWorktreeDatabase(input: {
connectionString: targetConnectionString, connectionString: targetConnectionString,
backupFile: backup.backupFile, backupFile: backup.backupFile,
}); });
await applyPendingMigrations(targetConnectionString);
return formatDatabaseBackupResult(backup); return formatDatabaseBackupResult(backup);
} finally { } finally {

View File

@@ -143,7 +143,7 @@ This command:
Seed modes: Seed modes:
- `minimal` keeps core app state like companies, projects, issues, comments, approvals, and auth state, but drops heavy operational history such as heartbeat runs, wake requests, activity logs, runtime services, and agent session state - `minimal` keeps core app state like companies, projects, issues, comments, approvals, and auth state, preserves schema for all tables, but omits row data from heavy operational history such as heartbeat runs, wake requests, activity logs, runtime services, and agent session state
- `full` makes a full logical clone of the source instance - `full` makes a full logical clone of the source instance
- `--no-seed` creates an empty isolated instance - `--no-seed` creates an empty isolated instance

View File

@@ -27,6 +27,7 @@ export type RunDatabaseRestoreOptions = {
}; };
type SequenceDefinition = { type SequenceDefinition = {
sequence_schema: string;
sequence_name: string; sequence_name: string;
data_type: string; data_type: string;
start_value: string; start_value: string;
@@ -34,10 +35,19 @@ type SequenceDefinition = {
maximum_value: string; maximum_value: string;
increment: string; increment: string;
cycle_option: "YES" | "NO"; cycle_option: "YES" | "NO";
owner_schema: string | null;
owner_table: string | null; owner_table: string | null;
owner_column: string | null; owner_column: string | null;
}; };
type TableDefinition = {
schema_name: string;
tablename: string;
};
const DRIZZLE_SCHEMA = "drizzle";
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
const STATEMENT_BREAKPOINT = "-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900"; const STATEMENT_BREAKPOINT = "-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900";
function sanitizeRestoreErrorMessage(error: unknown): string { function sanitizeRestoreErrorMessage(error: unknown): string {
@@ -119,6 +129,18 @@ function normalizeNullifyColumnMap(values: Record<string, string[]> | undefined)
return out; return out;
} }
function quoteIdentifier(value: string): string {
return `"${value.replaceAll("\"", "\"\"")}"`;
}
function quoteQualifiedName(schemaName: string, objectName: string): string {
return `${quoteIdentifier(schemaName)}.${quoteIdentifier(objectName)}`;
}
function tableKey(schemaName: string, tableName: string): string {
return `${schemaName}.${tableName}`;
}
export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise<RunDatabaseBackupResult> { export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise<RunDatabaseBackupResult> {
const filenamePrefix = opts.filenamePrefix ?? "paperclip"; const filenamePrefix = opts.filenamePrefix ?? "paperclip";
const retentionDays = Math.max(1, Math.trunc(opts.retentionDays)); const retentionDays = Math.max(1, Math.trunc(opts.retentionDays));
@@ -149,19 +171,18 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
emitStatement("SET LOCAL client_min_messages = warning;"); emitStatement("SET LOCAL client_min_messages = warning;");
emit(""); emit("");
const allTables = await sql<{ tablename: string }[]>` const allTables = await sql<TableDefinition[]>`
SELECT c.relname AS tablename SELECT table_schema AS schema_name, table_name AS tablename
FROM pg_class c FROM information_schema.tables
JOIN pg_namespace n ON n.oid = c.relnamespace WHERE table_type = 'BASE TABLE'
WHERE n.nspname = 'public' AND (
AND c.relkind = 'r' table_schema = 'public'
ORDER BY c.relname OR (${includeMigrationJournal}::boolean AND table_schema = ${DRIZZLE_SCHEMA} AND table_name = ${DRIZZLE_MIGRATIONS_TABLE})
)
ORDER BY table_schema, table_name
`; `;
const tables = allTables.filter(({ tablename }) => { const tables = allTables;
if (!includeMigrationJournal && tablename === "__drizzle_migrations") return false; const includedTableNames = new Set(tables.map(({ schema_name, tablename }) => tableKey(schema_name, tablename)));
return !excludedTableNames.has(tablename);
});
const includedTableNames = new Set(tables.map(({ tablename }) => tablename));
// Get all enums // Get all enums
const enums = await sql<{ typname: string; labels: string[] }[]>` const enums = await sql<{ typname: string; labels: string[] }[]>`
@@ -182,6 +203,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
const allSequences = await sql<SequenceDefinition[]>` const allSequences = await sql<SequenceDefinition[]>`
SELECT SELECT
s.sequence_schema,
s.sequence_name, s.sequence_name,
s.data_type, s.data_type,
s.start_value, s.start_value,
@@ -189,6 +211,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
s.maximum_value, s.maximum_value,
s.increment, s.increment,
s.cycle_option, s.cycle_option,
tblns.nspname AS owner_schema,
tbl.relname AS owner_table, tbl.relname AS owner_table,
attr.attname AS owner_column attr.attname AS owner_column
FROM information_schema.sequences s FROM information_schema.sequences s
@@ -196,25 +219,43 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
JOIN pg_namespace n ON n.oid = seq.relnamespace AND n.nspname = s.sequence_schema JOIN pg_namespace n ON n.oid = seq.relnamespace AND n.nspname = s.sequence_schema
LEFT JOIN pg_depend dep ON dep.objid = seq.oid AND dep.deptype = 'a' LEFT JOIN pg_depend dep ON dep.objid = seq.oid AND dep.deptype = 'a'
LEFT JOIN pg_class tbl ON tbl.oid = dep.refobjid LEFT JOIN pg_class tbl ON tbl.oid = dep.refobjid
LEFT JOIN pg_namespace tblns ON tblns.oid = tbl.relnamespace
LEFT JOIN pg_attribute attr ON attr.attrelid = tbl.oid AND attr.attnum = dep.refobjsubid LEFT JOIN pg_attribute attr ON attr.attrelid = tbl.oid AND attr.attnum = dep.refobjsubid
WHERE s.sequence_schema = 'public' WHERE s.sequence_schema = 'public'
ORDER BY s.sequence_name OR (${includeMigrationJournal}::boolean AND s.sequence_schema = ${DRIZZLE_SCHEMA})
ORDER BY s.sequence_schema, s.sequence_name
`; `;
const sequences = allSequences.filter((seq) => !seq.owner_table || includedTableNames.has(seq.owner_table)); const sequences = allSequences.filter(
(seq) => !seq.owner_table || includedTableNames.has(tableKey(seq.owner_schema ?? "public", seq.owner_table)),
);
const schemas = new Set<string>();
for (const table of tables) schemas.add(table.schema_name);
for (const seq of sequences) schemas.add(seq.sequence_schema);
const extraSchemas = [...schemas].filter((schemaName) => schemaName !== "public");
if (extraSchemas.length > 0) {
emit("-- Schemas");
for (const schemaName of extraSchemas) {
emitStatement(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(schemaName)};`);
}
emit("");
}
if (sequences.length > 0) { if (sequences.length > 0) {
emit("-- Sequences"); emit("-- Sequences");
for (const seq of sequences) { for (const seq of sequences) {
emitStatement(`DROP SEQUENCE IF EXISTS "${seq.sequence_name}" CASCADE;`); const qualifiedSequenceName = quoteQualifiedName(seq.sequence_schema, seq.sequence_name);
emitStatement(`DROP SEQUENCE IF EXISTS ${qualifiedSequenceName} CASCADE;`);
emitStatement( emitStatement(
`CREATE SEQUENCE "${seq.sequence_name}" AS ${seq.data_type} INCREMENT BY ${seq.increment} MINVALUE ${seq.minimum_value} MAXVALUE ${seq.maximum_value} START WITH ${seq.start_value}${seq.cycle_option === "YES" ? " CYCLE" : " NO CYCLE"};`, `CREATE SEQUENCE ${qualifiedSequenceName} AS ${seq.data_type} INCREMENT BY ${seq.increment} MINVALUE ${seq.minimum_value} MAXVALUE ${seq.maximum_value} START WITH ${seq.start_value}${seq.cycle_option === "YES" ? " CYCLE" : " NO CYCLE"};`,
); );
} }
emit(""); emit("");
} }
// Get full CREATE TABLE DDL via column info // Get full CREATE TABLE DDL via column info
for (const { tablename } of tables) { for (const { schema_name, tablename } of tables) {
const qualifiedTableName = quoteQualifiedName(schema_name, tablename);
const columns = await sql<{ const columns = await sql<{
column_name: string; column_name: string;
data_type: string; data_type: string;
@@ -228,12 +269,12 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
SELECT column_name, data_type, udt_name, is_nullable, column_default, SELECT column_name, data_type, udt_name, is_nullable, column_default,
character_maximum_length, numeric_precision, numeric_scale character_maximum_length, numeric_precision, numeric_scale
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = ${tablename} WHERE table_schema = ${schema_name} AND table_name = ${tablename}
ORDER BY ordinal_position ORDER BY ordinal_position
`; `;
emit(`-- Table: ${tablename}`); emit(`-- Table: ${schema_name}.${tablename}`);
emitStatement(`DROP TABLE IF EXISTS "${tablename}" CASCADE;`); emitStatement(`DROP TABLE IF EXISTS ${qualifiedTableName} CASCADE;`);
const colDefs: string[] = []; const colDefs: string[] = [];
for (const col of columns) { for (const col of columns) {
@@ -269,7 +310,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
JOIN pg_class t ON t.oid = c.conrelid JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey) JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey)
WHERE n.nspname = 'public' AND t.relname = ${tablename} AND c.contype = 'p' WHERE n.nspname = ${schema_name} AND t.relname = ${tablename} AND c.contype = 'p'
GROUP BY c.conname GROUP BY c.conname
`; `;
for (const p of pk) { for (const p of pk) {
@@ -277,7 +318,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
colDefs.push(` CONSTRAINT "${p.constraint_name}" PRIMARY KEY (${cols})`); colDefs.push(` CONSTRAINT "${p.constraint_name}" PRIMARY KEY (${cols})`);
} }
emit(`CREATE TABLE "${tablename}" (`); emit(`CREATE TABLE ${qualifiedTableName} (`);
emit(colDefs.join(",\n")); emit(colDefs.join(",\n"));
emit(");"); emit(");");
emitStatementBoundary(); emitStatementBoundary();
@@ -289,7 +330,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
emit("-- Sequence ownership"); emit("-- Sequence ownership");
for (const seq of ownedSequences) { for (const seq of ownedSequences) {
emitStatement( emitStatement(
`ALTER SEQUENCE "${seq.sequence_name}" OWNED BY "${seq.owner_table!}"."${seq.owner_column!}";`, `ALTER SEQUENCE ${quoteQualifiedName(seq.sequence_schema, seq.sequence_name)} OWNED BY ${quoteQualifiedName(seq.owner_schema ?? "public", seq.owner_table!)}.${quoteIdentifier(seq.owner_column!)};`,
); );
} }
emit(""); emit("");
@@ -298,8 +339,10 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
// Foreign keys (after all tables created) // Foreign keys (after all tables created)
const allForeignKeys = await sql<{ const allForeignKeys = await sql<{
constraint_name: string; constraint_name: string;
source_schema: string;
source_table: string; source_table: string;
source_columns: string[]; source_columns: string[];
target_schema: string;
target_table: string; target_table: string;
target_columns: string[]; target_columns: string[];
update_rule: string; update_rule: string;
@@ -307,24 +350,31 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
}[]>` }[]>`
SELECT SELECT
c.conname AS constraint_name, c.conname AS constraint_name,
srcn.nspname AS source_schema,
src.relname AS source_table, src.relname AS source_table,
array_agg(sa.attname ORDER BY array_position(c.conkey, sa.attnum)) AS source_columns, array_agg(sa.attname ORDER BY array_position(c.conkey, sa.attnum)) AS source_columns,
tgtn.nspname AS target_schema,
tgt.relname AS target_table, tgt.relname AS target_table,
array_agg(ta.attname ORDER BY array_position(c.confkey, ta.attnum)) AS target_columns, array_agg(ta.attname ORDER BY array_position(c.confkey, ta.attnum)) AS target_columns,
CASE c.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS update_rule, CASE c.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS update_rule,
CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS delete_rule CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS delete_rule
FROM pg_constraint c FROM pg_constraint c
JOIN pg_class src ON src.oid = c.conrelid JOIN pg_class src ON src.oid = c.conrelid
JOIN pg_namespace srcn ON srcn.oid = src.relnamespace
JOIN pg_class tgt ON tgt.oid = c.confrelid JOIN pg_class tgt ON tgt.oid = c.confrelid
JOIN pg_namespace n ON n.oid = src.relnamespace JOIN pg_namespace tgtn ON tgtn.oid = tgt.relnamespace
JOIN pg_attribute sa ON sa.attrelid = src.oid AND sa.attnum = ANY(c.conkey) JOIN pg_attribute sa ON sa.attrelid = src.oid AND sa.attnum = ANY(c.conkey)
JOIN pg_attribute ta ON ta.attrelid = tgt.oid AND ta.attnum = ANY(c.confkey) JOIN pg_attribute ta ON ta.attrelid = tgt.oid AND ta.attnum = ANY(c.confkey)
WHERE c.contype = 'f' AND n.nspname = 'public' WHERE c.contype = 'f' AND (
GROUP BY c.conname, src.relname, tgt.relname, c.confupdtype, c.confdeltype srcn.nspname = 'public'
ORDER BY src.relname, c.conname OR (${includeMigrationJournal}::boolean AND srcn.nspname = ${DRIZZLE_SCHEMA})
)
GROUP BY c.conname, srcn.nspname, src.relname, tgtn.nspname, tgt.relname, c.confupdtype, c.confdeltype
ORDER BY srcn.nspname, src.relname, c.conname
`; `;
const fks = allForeignKeys.filter( const fks = allForeignKeys.filter(
(fk) => includedTableNames.has(fk.source_table) && includedTableNames.has(fk.target_table), (fk) => includedTableNames.has(tableKey(fk.source_schema, fk.source_table))
&& includedTableNames.has(tableKey(fk.target_schema, fk.target_table)),
); );
if (fks.length > 0) { if (fks.length > 0) {
@@ -333,7 +383,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", "); const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", ");
const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", "); const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", ");
emitStatement( emitStatement(
`ALTER TABLE "${fk.source_table}" ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES "${fk.target_table}" (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`, `ALTER TABLE ${quoteQualifiedName(fk.source_schema, fk.source_table)} ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES ${quoteQualifiedName(fk.target_schema, fk.target_table)} (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`,
); );
} }
emit(""); emit("");
@@ -342,43 +392,52 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
// Unique constraints // Unique constraints
const allUniqueConstraints = await sql<{ const allUniqueConstraints = await sql<{
constraint_name: string; constraint_name: string;
schema_name: string;
tablename: string; tablename: string;
column_names: string[]; column_names: string[];
}[]>` }[]>`
SELECT c.conname AS constraint_name, SELECT c.conname AS constraint_name,
n.nspname AS schema_name,
t.relname AS tablename, t.relname AS tablename,
array_agg(a.attname ORDER BY array_position(c.conkey, a.attnum)) AS column_names array_agg(a.attname ORDER BY array_position(c.conkey, a.attnum)) AS column_names
FROM pg_constraint c FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey) JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey)
WHERE n.nspname = 'public' AND c.contype = 'u' WHERE c.contype = 'u' AND (
GROUP BY c.conname, t.relname n.nspname = 'public'
ORDER BY t.relname, c.conname OR (${includeMigrationJournal}::boolean AND n.nspname = ${DRIZZLE_SCHEMA})
)
GROUP BY c.conname, n.nspname, t.relname
ORDER BY n.nspname, t.relname, c.conname
`; `;
const uniques = allUniqueConstraints.filter((entry) => includedTableNames.has(entry.tablename)); const uniques = allUniqueConstraints.filter((entry) => includedTableNames.has(tableKey(entry.schema_name, entry.tablename)));
if (uniques.length > 0) { if (uniques.length > 0) {
emit("-- Unique constraints"); emit("-- Unique constraints");
for (const u of uniques) { for (const u of uniques) {
const cols = u.column_names.map((c) => `"${c}"`).join(", "); const cols = u.column_names.map((c) => `"${c}"`).join(", ");
emitStatement(`ALTER TABLE "${u.tablename}" ADD CONSTRAINT "${u.constraint_name}" UNIQUE (${cols});`); emitStatement(`ALTER TABLE ${quoteQualifiedName(u.schema_name, u.tablename)} ADD CONSTRAINT "${u.constraint_name}" UNIQUE (${cols});`);
} }
emit(""); emit("");
} }
// Indexes (non-primary, non-unique-constraint) // Indexes (non-primary, non-unique-constraint)
const allIndexes = await sql<{ tablename: string; indexdef: string }[]>` const allIndexes = await sql<{ schema_name: string; tablename: string; indexdef: string }[]>`
SELECT tablename, indexdef SELECT schemaname AS schema_name, tablename, indexdef
FROM pg_indexes FROM pg_indexes
WHERE schemaname = 'public' WHERE (
AND indexname NOT IN ( schemaname = 'public'
SELECT conname FROM pg_constraint OR (${includeMigrationJournal}::boolean AND schemaname = ${DRIZZLE_SCHEMA})
WHERE connamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
) )
ORDER BY tablename, indexname AND indexname NOT IN (
SELECT conname FROM pg_constraint c
JOIN pg_namespace n ON n.oid = c.connamespace
WHERE n.nspname = pg_indexes.schemaname
)
ORDER BY schemaname, tablename, indexname
`; `;
const indexes = allIndexes.filter((entry) => includedTableNames.has(entry.tablename)); const indexes = allIndexes.filter((entry) => includedTableNames.has(tableKey(entry.schema_name, entry.tablename)));
if (indexes.length > 0) { if (indexes.length > 0) {
emit("-- Indexes"); emit("-- Indexes");
@@ -389,24 +448,23 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
} }
// Dump data for each table // Dump data for each table
for (const { tablename } of tables) { for (const { schema_name, tablename } of tables) {
const count = await sql<{ n: number }[]>` const qualifiedTableName = quoteQualifiedName(schema_name, tablename);
SELECT count(*)::int AS n FROM ${sql(tablename)} const count = await sql.unsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM ${qualifiedTableName}`);
`; if (excludedTableNames.has(tablename) || (count[0]?.n ?? 0) === 0) continue;
if ((count[0]?.n ?? 0) === 0) continue;
// Get column info for this table // Get column info for this table
const cols = await sql<{ column_name: string; data_type: string }[]>` const cols = await sql<{ column_name: string; data_type: string }[]>`
SELECT column_name, data_type SELECT column_name, data_type
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = ${tablename} WHERE table_schema = ${schema_name} AND table_name = ${tablename}
ORDER BY ordinal_position ORDER BY ordinal_position
`; `;
const colNames = cols.map((c) => `"${c.column_name}"`).join(", "); const colNames = cols.map((c) => `"${c.column_name}"`).join(", ");
emit(`-- Data for: ${tablename} (${count[0]!.n} rows)`); emit(`-- Data for: ${schema_name}.${tablename} (${count[0]!.n} rows)`);
const rows = await sql`SELECT * FROM ${sql(tablename)}`.values(); const rows = await sql.unsafe(`SELECT * FROM ${qualifiedTableName}`).values();
const nullifiedColumns = nullifiedColumnsByTable.get(tablename) ?? new Set<string>(); const nullifiedColumns = nullifiedColumnsByTable.get(tablename) ?? new Set<string>();
for (const row of rows) { for (const row of rows) {
const values = row.map((rawValue: unknown, index) => { const values = row.map((rawValue: unknown, index) => {
@@ -419,7 +477,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
if (typeof val === "object") return formatSqlLiteral(JSON.stringify(val)); if (typeof val === "object") return formatSqlLiteral(JSON.stringify(val));
return formatSqlLiteral(String(val)); return formatSqlLiteral(String(val));
}); });
emitStatement(`INSERT INTO "${tablename}" (${colNames}) VALUES (${values.join(", ")});`); emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`);
} }
emit(""); emit("");
} }
@@ -428,11 +486,15 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
if (sequences.length > 0) { if (sequences.length > 0) {
emit("-- Sequence values"); emit("-- Sequence values");
for (const seq of sequences) { for (const seq of sequences) {
const val = await sql<{ last_value: string; is_called: boolean }[]>` const qualifiedSequenceName = quoteQualifiedName(seq.sequence_schema, seq.sequence_name);
SELECT last_value::text, is_called FROM ${sql(seq.sequence_name)} const val = await sql.unsafe<{ last_value: string; is_called: boolean }[]>(
`; `SELECT last_value::text, is_called FROM ${qualifiedSequenceName}`,
if (val[0]) { );
emitStatement(`SELECT setval('"${seq.sequence_name}"', ${val[0].last_value}, ${val[0].is_called ? "true" : "false"});`); const skipSequenceValue =
seq.owner_table !== null
&& excludedTableNames.has(seq.owner_table);
if (val[0] && !skipSequenceValue) {
emitStatement(`SELECT setval('${qualifiedSequenceName.replaceAll("'", "''")}', ${val[0].last_value}, ${val[0].is_called ? "true" : "false"});`);
} }
} }
emit(""); emit("");

View File

@@ -10,6 +10,10 @@ const MIGRATIONS_FOLDER = fileURLToPath(new URL("./migrations", import.meta.url)
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations"; const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
const MIGRATIONS_JOURNAL_JSON = fileURLToPath(new URL("./migrations/meta/_journal.json", import.meta.url)); const MIGRATIONS_JOURNAL_JSON = fileURLToPath(new URL("./migrations/meta/_journal.json", import.meta.url));
function createUtilitySql(url: string) {
return postgres(url, { max: 1, onnotice: () => {} });
}
function isSafeIdentifier(value: string): boolean { function isSafeIdentifier(value: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value); return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
} }
@@ -223,7 +227,7 @@ async function applyPendingMigrationsManually(
journalEntries.map((entry) => [entry.fileName, normalizeFolderMillis(entry.folderMillis)]), journalEntries.map((entry) => [entry.fileName, normalizeFolderMillis(entry.folderMillis)]),
); );
const sql = postgres(url, { max: 1 }); const sql = createUtilitySql(url);
try { try {
const { migrationTableSchema, columnNames } = await ensureMigrationJournalTable(sql); const { migrationTableSchema, columnNames } = await ensureMigrationJournalTable(sql);
const qualifiedTable = `${quoteIdentifier(migrationTableSchema)}.${quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE)}`; const qualifiedTable = `${quoteIdentifier(migrationTableSchema)}.${quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE)}`;
@@ -472,7 +476,7 @@ export async function reconcilePendingMigrationHistory(
return { repairedMigrations: [], remainingMigrations: [] }; return { repairedMigrations: [], remainingMigrations: [] };
} }
const sql = postgres(url, { max: 1 }); const sql = createUtilitySql(url);
const repairedMigrations: string[] = []; const repairedMigrations: string[] = [];
try { try {
@@ -579,7 +583,7 @@ async function discoverMigrationTableSchema(sql: ReturnType<typeof postgres>): P
} }
export async function inspectMigrations(url: string): Promise<MigrationState> { export async function inspectMigrations(url: string): Promise<MigrationState> {
const sql = postgres(url, { max: 1 }); const sql = createUtilitySql(url);
try { try {
const availableMigrations = await listMigrationFiles(); const availableMigrations = await listMigrationFiles();
@@ -642,7 +646,7 @@ export async function applyPendingMigrations(url: string): Promise<void> {
const initialState = await inspectMigrations(url); const initialState = await inspectMigrations(url);
if (initialState.status === "upToDate") return; if (initialState.status === "upToDate") return;
const sql = postgres(url, { max: 1 }); const sql = createUtilitySql(url);
try { try {
const db = drizzlePg(sql); const db = drizzlePg(sql);
@@ -680,7 +684,7 @@ export type MigrationBootstrapResult =
| { migrated: false; reason: "not-empty-no-migration-journal"; tableCount: number }; | { migrated: false; reason: "not-empty-no-migration-journal"; tableCount: number };
export async function migratePostgresIfEmpty(url: string): Promise<MigrationBootstrapResult> { export async function migratePostgresIfEmpty(url: string): Promise<MigrationBootstrapResult> {
const sql = postgres(url, { max: 1 }); const sql = createUtilitySql(url);
try { try {
const migrationTableSchema = await discoverMigrationTableSchema(sql); const migrationTableSchema = await discoverMigrationTableSchema(sql);
@@ -719,7 +723,7 @@ export async function ensurePostgresDatabase(
throw new Error(`Unsafe database name: ${databaseName}`); throw new Error(`Unsafe database name: ${databaseName}`);
} }
const sql = postgres(url, { max: 1 }); const sql = createUtilitySql(url);
try { try {
const existing = await sql<{ one: number }[]>` const existing = await sql<{ one: number }[]>`
select 1 as one from pg_database where datname = ${databaseName} limit 1 select 1 as one from pg_database where datname = ${databaseName} limit 1

View File

@@ -32,6 +32,7 @@ export async function createApp(
db: Db, db: Db,
opts: { opts: {
uiMode: UiMode; uiMode: UiMode;
serverPort: number;
storageService: StorageService; storageService: StorageService;
deploymentMode: DeploymentMode; deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure; deploymentExposure: DeploymentExposure;
@@ -146,12 +147,18 @@ export async function createApp(
if (opts.uiMode === "vite-dev") { if (opts.uiMode === "vite-dev") {
const uiRoot = path.resolve(__dirname, "../../ui"); const uiRoot = path.resolve(__dirname, "../../ui");
const hmrPort = opts.serverPort + 10000;
const { createServer: createViteServer } = await import("vite"); const { createServer: createViteServer } = await import("vite");
const vite = await createViteServer({ const vite = await createViteServer({
root: uiRoot, root: uiRoot,
appType: "spa", appType: "spa",
server: { server: {
middlewareMode: true, middlewareMode: true,
hmr: {
host: opts.bindHost,
port: hmrPort,
clientPort: hmrPort,
},
allowedHosts: privateHostnameGateEnabled ? Array.from(privateHostnameAllowSet) : undefined, allowedHosts: privateHostnameGateEnabled ? Array.from(privateHostnameAllowSet) : undefined,
}, },
}); });

View File

@@ -460,10 +460,12 @@ export async function startServer(): Promise<StartedServer> {
authReady = true; authReady = true;
} }
const listenPort = await detectPort(config.port);
const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none"; const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none";
const storageService = createStorageServiceFromConfig(config); const storageService = createStorageServiceFromConfig(config);
const app = await createApp(db as any, { const app = await createApp(db as any, {
uiMode, uiMode,
serverPort: listenPort,
storageService, storageService,
deploymentMode: config.deploymentMode, deploymentMode: config.deploymentMode,
deploymentExposure: config.deploymentExposure, deploymentExposure: config.deploymentExposure,
@@ -475,7 +477,6 @@ export async function startServer(): Promise<StartedServer> {
resolveSession, resolveSession,
}); });
const server = createServer(app as unknown as Parameters<typeof createServer>[0]); const server = createServer(app as unknown as Parameters<typeof createServer>[0]);
const listenPort = await detectPort(config.port);
if (listenPort !== config.port) { if (listenPort !== config.port) {
logger.warn(`Requested port is busy; using next free port (requestedPort=${config.port}, selectedPort=${listenPort})`); logger.warn(`Requested port is busy; using next free port (requestedPort=${config.port}, selectedPort=${listenPort})`);