diff --git a/pgpm/export/__tests__/export-data.test.ts b/pgpm/export/__tests__/export-data.test.ts index 4cb7804aa..27db5f953 100644 --- a/pgpm/export/__tests__/export-data.test.ts +++ b/pgpm/export/__tests__/export-data.test.ts @@ -13,6 +13,7 @@ import { exportTableData, exportTablesData, getDataExportColumns, + getMetadataExportColumns, isVolatileTimestampColumn } from '../src/export-data'; @@ -114,6 +115,108 @@ describe('getDataExportColumns', () => { }); }); +describe('getMetadataExportColumns', () => { + it('classifies metadata-described columns identically to a physical table', async () => { + // Same columns as app_routing_public.apis, described purely as metadata + // (name/type/default expression) with no physical table present. + const classified = await getMetadataExportColumns(pg, [ + { name: 'id', type: 'uuid', columnDefault: null }, + { name: 'name', type: 'text', columnDefault: null }, + { name: 'dbname', type: 'text', columnDefault: 'current_database()' }, + { name: 'is_public', type: 'boolean', columnDefault: 'true' }, + { name: 'created_at', type: 'timestamptz', columnDefault: 'now()' }, + { name: 'updated_at', type: 'timestamptz', columnDefault: 'CURRENT_TIMESTAMP' }, + { name: 'observed_at', type: 'timestamptz', columnDefault: 'clock_timestamp()' }, + { name: 'expires_at', type: 'timestamptz', columnDefault: "now() + '2 days'::interval" }, + { name: 'fixed_at', type: 'timestamptz', columnDefault: "'2020-01-01T00:00:00Z'::timestamptz" } + ]); + const byName = Object.fromEntries(classified.map(c => [c.name, c])); + + // Wall-clock timestamp defaults — all volatile + expect(byName.created_at.volatileDefault).toBe(true); + expect(byName.updated_at.volatileDefault).toBe(true); + expect(byName.observed_at.volatileDefault).toBe(true); + expect(byName.expires_at.volatileDefault).toBe(true); + + // Constant timestamp default — immutable + expect(byName.fixed_at.volatileDefault).toBe(false); + + // Stable but non-timestamp default + expect(byName.dbname.volatileDefault).toBe(true); + + // No default / constant defaults + expect(byName.id.volatileDefault).toBe(false); + expect(byName.is_public.volatileDefault).toBe(false); + + // Types are canonicalised through the catalog, matching the physical path + expect(byName.created_at.type).toBe('timestamp with time zone'); + expect(byName.name.type).toBe('text'); + + // Matches getDataExportColumns over the equivalent physical table + const physical = await getDataExportColumns(pg, 'app_routing_public', 'apis'); + const physicalByName = Object.fromEntries(physical.map(c => [c.name, c])); + for (const name of Object.keys(byName)) { + expect(byName[name].volatileDefault).toBe(physicalByName[name].volatileDefault); + expect(byName[name].type).toBe(physicalByName[name].type); + } + }); + + it('feeds isVolatileTimestampColumn to select droppable columns', async () => { + const classified = await getMetadataExportColumns(pg, [ + { name: 'created_at', type: 'timestamptz', columnDefault: 'now()' }, + { name: 'updated_at', type: 'timestamptz', columnDefault: 'now()' }, + { name: 'expires_at', type: 'timestamptz', columnDefault: null }, + { name: 'dbname', type: 'text', columnDefault: 'current_database()' }, + { name: 'fixed_at', type: 'timestamptz', columnDefault: "'2020-01-01'::timestamptz" } + ]); + const dropped = classified.filter(isVolatileTimestampColumn).map(c => c.name).sort(); + expect(dropped).toEqual(['created_at', 'updated_at']); + }); + + it('preserves input order and returns [] for no columns, cleaning up temp tables', async () => { + expect(await getMetadataExportColumns(pg, [])).toEqual([]); + + const order = await getMetadataExportColumns(pg, [ + { name: 'z', type: 'text', columnDefault: null }, + { name: 'a', type: 'timestamptz', columnDefault: 'now()' }, + { name: 'm', type: 'integer', columnDefault: '0' } + ]); + expect(order.map(c => c.name)).toEqual(['z', 'a', 'm']); + + // No leftover temp relations from the ephemeral tables + const leftover = await pg.query( + `SELECT count(*)::int AS n FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = pg_my_temp_schema()::regnamespace::text + AND c.relname = 'pgpm_metadata_export_columns'` + ); + expect(leftover.rows[0].n).toBe(0); + }); + + it('rolls back cleanly on failure: no leftover temp table, session still usable', async () => { + await expect( + getMetadataExportColumns(pg, [ + { name: 'bad', type: 'timestamptz', columnDefault: 'not_a_function(' } + ]) + ).rejects.toThrow(); + + // The rollback erased the temp table even though classification failed... + const leftover = await pg.query( + `SELECT count(*)::int AS n FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = pg_my_temp_schema()::regnamespace::text + AND c.relname = 'pgpm_metadata_export_columns'` + ); + expect(leftover.rows[0].n).toBe(0); + + // ...and the session/transaction is not aborted: further work succeeds. + const ok = await getMetadataExportColumns(pg, [ + { name: 'created_at', type: 'timestamptz', columnDefault: 'now()' } + ]); + expect(ok[0].volatileDefault).toBe(true); + }); +}); + describe('exportTableData', () => { it('emits a deterministic INSERT ordered by id, excluding volatile timestamps and excludeColumns', async () => { const result = await exportTableData( diff --git a/pgpm/export/src/export-data.ts b/pgpm/export/src/export-data.ts index 1dfce7207..bd11c3218 100644 --- a/pgpm/export/src/export-data.ts +++ b/pgpm/export/src/export-data.ts @@ -92,6 +92,99 @@ export const getDataExportColumns = async ( export const isVolatileTimestampColumn = (column: DataExportColumn): boolean => column.volatileDefault && /^timestamp/.test(column.type); +/** + * A column described by metadata (its name, SQL type, and default expression + * text) rather than by a live physical table. Fed to getMetadataExportColumns + * so metadata-only planes (tables that exist as schema metadata but are not + * physically deployed at export time) get the same volatility classification + * as physical tables. + */ +export interface MetadataExportColumn { + /** Column name. */ + name: string; + /** SQL type, e.g. 'timestamptz', 'text', 'uuid' — must resolve in the DB. */ + type: string; + /** The column's default expression text, if any (e.g. 'now()'). */ + columnDefault: string | null; +} + +/** + * Classify columns described by metadata instead of a live physical table. + * + * Metadata-only planes describe their tables as schema metadata but are not + * physically deployed when the export runs, so pg_attrdef-based introspection + * cannot see them. This builds an ephemeral temp table from the supplied + * descriptions and runs the exact same provolatile-based classification that + * getDataExportColumns uses for physical tables — one shared code path — so + * volatile-default detection (isVolatileTimestampColumn, etc.) behaves + * identically for both. + * + * Results preserve the input order and names. Each column's type must be a + * type resolvable in the connected database; each non-null default must be a + * valid default expression for that type. `db` must be a single session + * (client), not a pool: the classification runs inside a transaction (or a + * savepoint when the caller is already in one) that is always rolled back, so + * the temp table never survives — not even on failure — and nothing is ever + * committed. + */ +export const getMetadataExportColumns = async ( + db: Queryable, + columns: MetadataExportColumn[] +): Promise => { + if (columns.length === 0) return []; + + const tempTable = 'pgpm_metadata_export_columns'; + // Synthetic column identifiers keep the DDL immune to reserved words and + // duplicate names; results map back to the caller's columns by ordinal. + const columnDefs = columns + .map((c, i) => { + const def = + c.columnDefault != null && c.columnDefault !== '' + ? ` DEFAULT ${c.columnDefault}` + : ''; + return `c${i} ${c.type}${def}`; + }) + .join(', '); + + // Savepoint when already inside a caller transaction, otherwise our own + // transaction. Either way the work is rolled back below, so the temp table + // name is deterministic and can never collide or leak. + let usedSavepoint = true; + try { + await db.query(`SAVEPOINT ${tempTable}`); + } catch { + usedSavepoint = false; + await db.query('BEGIN'); + } + try { + await db.query(`CREATE TEMP TABLE ${tempTable} (${columnDefs})`); + const tempSchemaRes = await db.query( + `SELECT nspname FROM pg_namespace WHERE oid = pg_my_temp_schema()` + ); + const tempSchema = tempSchemaRes.rows[0]?.nspname as string; + const introspected = await getDataExportColumns(db, tempSchema, tempTable); + + // getDataExportColumns orders by attnum, matching the c0..cN creation + // order, so results align with the input columns by index. + return columns.map((c, i) => { + const found = introspected[i]; + return { + name: c.name, + type: found ? found.type : c.type, + columnDefault: c.columnDefault ?? null, + volatileDefault: found ? found.volatileDefault : false + }; + }); + } finally { + if (usedSavepoint) { + await db.query(`ROLLBACK TO SAVEPOINT ${tempTable}`); + await db.query(`RELEASE SAVEPOINT ${tempTable}`); + } else { + await db.query('ROLLBACK'); + } + } +}; + export interface DataExportTableSpec { schema: string; table: string;