Fix worktree minimal clone startup
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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})`);
|
||||||
|
|||||||
Reference in New Issue
Block a user