From 477276266b2c4665d81b4c27618831b936dfcaad Mon Sep 17 00:00:00 2001 From: Gefei Hou Date: Mon, 29 Jun 2026 01:23:19 -0700 Subject: [PATCH 1/2] Remove platform code from exported per tenant db --- pgpm/export/src/export-graphql.ts | 8 +- pgpm/export/src/export-migrations.ts | 17 +++- pgpm/export/src/export-utils.ts | 138 +++++++++++++++++++++++++++ pgpm/export/src/index.ts | 4 +- 4 files changed, 159 insertions(+), 8 deletions(-) diff --git a/pgpm/export/src/export-graphql.ts b/pgpm/export/src/export-graphql.ts index 5f5853ec05..7591e18988 100644 --- a/pgpm/export/src/export-graphql.ts +++ b/pgpm/export/src/export-graphql.ts @@ -25,7 +25,8 @@ import { installMissingModules, makeReplacer, preparePackage, - normalizeOutdir + normalizeOutdir, + filterPlatformLeakage } from './export-utils'; // ============================================================================= @@ -204,8 +205,9 @@ export const exportGraphQL = async ({ await installMissingModules(dbModuleDir, dbMissingResult.missingModules); } - writePgpmPlan(sqlActionRows as unknown as PgpmRow[], opts); - writePgpmFiles(sqlActionRows as unknown as PgpmRow[], opts); + const filteredRows = filterPlatformLeakage(sqlActionRows as unknown as any[], schema_names); + writePgpmPlan(filteredRows as unknown as PgpmRow[], opts); + writePgpmFiles(filteredRows as unknown as PgpmRow[], opts); } else { console.log('No sql_actions found. Skipping database module export.'); } diff --git a/pgpm/export/src/export-migrations.ts b/pgpm/export/src/export-migrations.ts index 9edf0fc62d..645459e37d 100644 --- a/pgpm/export/src/export-migrations.ts +++ b/pgpm/export/src/export-migrations.ts @@ -14,7 +14,8 @@ import { installMissingModules, makeReplacer, preparePackage, - normalizeOutdir + normalizeOutdir, + filterPlatformLeakage } from './export-utils'; interface ExportMigrationsToDiskOptions { @@ -148,6 +149,14 @@ const exportMigrationsToDisk = async ({ [databaseId] ); + // ========================================================================= + // Platform leakage filter — strip platform-integrated actions and + // cross-package requires from the exported package. See + // filterPlatformLeakage() in export-utils.ts for details. + // ========================================================================= + const filteredRows = filterPlatformLeakage(results?.rows ?? [], schema_names); + + const opts: SqlWriteOptions = { name, replacer, @@ -158,7 +167,7 @@ const exportMigrationsToDisk = async ({ // Build description for the database extension package const dbExtensionDesc = extensionDesc || `${name} database schema for ${databaseName}`; - if (results?.rows?.length > 0) { + if (filteredRows.length > 0) { // Detect missing modules at workspace level and prompt user const dbMissingResult = await detectMissingModules(project, [...DB_REQUIRED_EXTENSIONS], prompter, argv); @@ -180,8 +189,8 @@ const exportMigrationsToDisk = async ({ await installMissingModules(dbModuleDir, dbMissingResult.missingModules); } - writePgpmPlan(results.rows, opts); - writePgpmFiles(results.rows, opts); + writePgpmPlan(filteredRows, opts); + writePgpmFiles(filteredRows, opts); } else { console.log('No sql_actions found — skipping database module. Meta/service module will still be exported.'); } diff --git a/pgpm/export/src/export-utils.ts b/pgpm/export/src/export-utils.ts index 847a1421ac..2fe6c7d863 100644 --- a/pgpm/export/src/export-utils.ts +++ b/pgpm/export/src/export-utils.ts @@ -866,3 +866,141 @@ export const preparePackage = async ({ export const normalizeOutdir = (outdir: string): string => { return outdir.endsWith(path.sep) ? outdir : outdir + path.sep; }; + +// ============================================================================= +// Platform leakage filter +// ============================================================================= + +/** Mirror trigger function kinds stamped by the generator. */ +const MIRROR_KINDS = new Set([ + 'namespace_mirror_insert', + 'namespace_mirror_delete' +]); + +/** Deploy-path pattern for mirror trigger functions. + * payload.kind is unreliable (the migrate() path drops it), so we + * also seed from the deterministic deploy path. */ +const MIRROR_DEPLOY_PATH = /\/trigger_fns\/[^/]+_mirror_to_platform_(insert|delete)$/; + +/** Strips the `pkg:` prefix from a deploys/deps path. */ +const stripPkg = (p: string): string => + typeof p === 'string' && p.includes(':') ? p.slice(p.indexOf(':') + 1) : p; + +/** + * Filters platform-integrated actions and cross-package requires from + * sql_actions rows before writing them as a per-tenant package. + * + * Step A — exclude mirror trigger functions by `payload.kind`, then + * transitively exclude any row whose `deps` reference an + * excluded change (catches the trigger rows). + * Step B — strip cross-package `deps` so `requires` directives only + * reference schemas owned by this database. + */ +export const filterPlatformLeakage = (rows: any[], schema_names: string[]): any[] => { + const ownedPrefixes = schema_names.map(s => `schemas/${s}/`); + + // Seed: mirror trigger functions — by deploy path (reliable) OR + // stamped payload.kind (fallback for rows that preserve it). + const excluded = new Set(); + for (const row of rows) { + if (typeof row.deploy !== 'string') continue; + const path = stripPkg(row.deploy); + const payload = typeof row.payload === 'string' + ? JSON.parse(row.payload) + : row.payload; + if (MIRROR_DEPLOY_PATH.test(path) || (payload && MIRROR_KINDS.has(payload.kind))) { + excluded.add(path); + } + } + + // Closure: anything requiring an excluded change is also excluded. + let changed = true; + while (changed) { + changed = false; + for (const row of rows) { + const deployPath = typeof row.deploy === 'string' ? stripPkg(row.deploy) : ''; + if (!deployPath || excluded.has(deployPath)) continue; + if (Array.isArray(row.deps) && row.deps.some((d: string) => excluded.has(stripPkg(d)))) { + excluded.add(deployPath); + changed = true; + } + } + } + + const filteredRows = rows + .filter((row: any) => + !(typeof row.deploy === 'string' && excluded.has(stripPkg(row.deploy))) + ) + .map((row: any) => { + if (Array.isArray(row.deps)) { + row.deps = row.deps.filter((dep: string) => + ownedPrefixes.some(p => dep.includes(p)) + ); + } + return row; + }); + + return rewritePartmanMigrations(filteredRows); +}; + +/** + * Rewrites table/partman migrations to a self-contained + * partman.create_parent_with_retention() call instead of + * INSERT INTO metaschema_public.partition (hardcoded source-DB UUIDs). + * The control column is resolved at deploy time via pg_get_partkeydef(), + * so no DB lookups are needed at export time. Pure function. + */ +export const rewritePartmanMigrations = (rows: any[]): any[] => { + return rows.map((row: any) => { + if (typeof row.deploy !== 'string') return row; + const match = row.deploy.match(/schemas\/([^/]+)\/tables\/([^/]+)\/table\/partman$/); + if (!match) return row; + + const [, schema, table] = match; + const parentTable = `${schema}.${table}`; + + // Parse the VALUES (...) tuple positionally. Column order: + // id, database_id, table_id, strategy, partition_key_id, + // interval, retention, retention_keep_table, premake, naming_pattern + const content: string = row.content || ''; + const tuple = content.match(/VALUES\s*\(([\s\S]*?)\)\s*ON CONFLICT/i); + const cols = tuple + ? tuple[1].split(',').map((s: string) => s.trim().replace(/^'|'$/g, '')) + : []; + const strategy = cols[3] || 'range'; + const interval = cols[5] || '1 month'; + const retention = cols[6] || ''; + const keepTable = /^true$/i.test(cols[7] || 'true'); + const premake = parseInt(cols[8] || '2', 10); + const retentionSql = retention ? `'${retention}'` : 'NULL'; + + // SQL BODY ONLY — writeDeploy() adds the header + requires from row.deps. + row.content = `-- Rewritten at export time: replaces INSERT INTO +-- metaschema_public.partition (hardcoded source-DB UUIDs) with a +-- self-contained partman registration. Control column resolved at +-- deploy time via pg_get_partkeydef(); guarded for idempotency. +DO $$ +DECLARE + v_control text; +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM partman.part_config WHERE parent_table = '${parentTable}' + ) THEN + v_control := substring( + pg_get_partkeydef('${parentTable}'::regclass) FROM '\\(([^)]+)\\)' + ); + PERFORM partman.create_parent_with_retention( + v_parent_table := '${parentTable}', + v_control := v_control, + v_type := '${strategy}', + partition_interval := '${interval}', + v_premake := ${premake}, + v_retention := ${retentionSql}, + v_retention_keep_table := ${keepTable} + ); + END IF; +END $$; +`; + return row; + }); +}; diff --git a/pgpm/export/src/index.ts b/pgpm/export/src/index.ts index b357a3d2a9..147cf8b38d 100644 --- a/pgpm/export/src/index.ts +++ b/pgpm/export/src/index.ts @@ -16,7 +16,9 @@ export { preparePackage, normalizeOutdir, detectMissingModules, - installMissingModules + installMissingModules, + filterPlatformLeakage, + rewritePartmanMigrations } from './export-utils'; export type { FieldType, From 658e1831b985985749b6adc83581f30508ec9d16 Mon Sep 17 00:00:00 2001 From: Gefei Hou Date: Wed, 1 Jul 2026 09:40:35 -0700 Subject: [PATCH 2/2] Update fix to sync with new plan --- graphql/server/src/middleware/graphile.ts | 6 + pgpm/export/src/export-utils.ts | 139 +++------------------- pgpm/export/src/index.ts | 3 +- 3 files changed, 23 insertions(+), 125 deletions(-) diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index 477e61288b..21aa35e5c6 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -249,6 +249,12 @@ const buildPreset = ( context['jwt.claims.device_token'] = req.deviceToken; } + // Export category exclusion — set 'platform_mirror' as default so + // platform-integrated mirror triggers are always excluded from + // db_migrate.sql_actions queries via the export_category_filter RLS + // policy. The X-Exclude-Categories header can override if needed. + context['export.exclude_categories'] = req.get('X-Exclude-Categories') || 'platform_mirror'; + if (req.token?.user_id) { const pgSettings: Record = { role: roleName, diff --git a/pgpm/export/src/export-utils.ts b/pgpm/export/src/export-utils.ts index 2fe6c7d863..ba2fdc38ec 100644 --- a/pgpm/export/src/export-utils.ts +++ b/pgpm/export/src/export-utils.ts @@ -871,136 +871,29 @@ export const normalizeOutdir = (outdir: string): string => { // Platform leakage filter // ============================================================================= -/** Mirror trigger function kinds stamped by the generator. */ -const MIRROR_KINDS = new Set([ - 'namespace_mirror_insert', - 'namespace_mirror_delete' -]); - -/** Deploy-path pattern for mirror trigger functions. - * payload.kind is unreliable (the migrate() path drops it), so we - * also seed from the deterministic deploy path. */ -const MIRROR_DEPLOY_PATH = /\/trigger_fns\/[^/]+_mirror_to_platform_(insert|delete)$/; - -/** Strips the `pkg:` prefix from a deploys/deps path. */ -const stripPkg = (p: string): string => - typeof p === 'string' && p.includes(':') ? p.slice(p.indexOf(':') + 1) : p; - /** - * Filters platform-integrated actions and cross-package requires from - * sql_actions rows before writing them as a per-tenant package. + * Filters cross-package deps from sql_actions rows before writing them + * as a per-tenant package. Strips deps that reference schemas not owned + * by this database so requires directives only reference tenant-owned + * schemas. * - * Step A — exclude mirror trigger functions by `payload.kind`, then - * transitively exclude any row whose `deps` reference an - * excluded change (catches the trigger rows). - * Step B — strip cross-package `deps` so `requires` directives only - * reference schemas owned by this database. + * Mirror trigger exclusion is handled upstream via the category mechanism: + * create_trigger_function(v_category := 'platform_mirror') → insert_action + * → depase_ast trigger → db_migrate.sql_actions.category = 'platform_mirror'. + * The server sets export.exclude_categories = 'platform_mirror' (via the + * X-Exclude-Categories header → pgSettings GUC) which activates the + * export_category_filter RLS policy, making those rows invisible at the + * SQL layer before the export code sees them. */ export const filterPlatformLeakage = (rows: any[], schema_names: string[]): any[] => { const ownedPrefixes = schema_names.map(s => `schemas/${s}/`); - // Seed: mirror trigger functions — by deploy path (reliable) OR - // stamped payload.kind (fallback for rows that preserve it). - const excluded = new Set(); - for (const row of rows) { - if (typeof row.deploy !== 'string') continue; - const path = stripPkg(row.deploy); - const payload = typeof row.payload === 'string' - ? JSON.parse(row.payload) - : row.payload; - if (MIRROR_DEPLOY_PATH.test(path) || (payload && MIRROR_KINDS.has(payload.kind))) { - excluded.add(path); - } - } - - // Closure: anything requiring an excluded change is also excluded. - let changed = true; - while (changed) { - changed = false; - for (const row of rows) { - const deployPath = typeof row.deploy === 'string' ? stripPkg(row.deploy) : ''; - if (!deployPath || excluded.has(deployPath)) continue; - if (Array.isArray(row.deps) && row.deps.some((d: string) => excluded.has(stripPkg(d)))) { - excluded.add(deployPath); - changed = true; - } - } - } - - const filteredRows = rows - .filter((row: any) => - !(typeof row.deploy === 'string' && excluded.has(stripPkg(row.deploy))) - ) - .map((row: any) => { - if (Array.isArray(row.deps)) { - row.deps = row.deps.filter((dep: string) => - ownedPrefixes.some(p => dep.includes(p)) - ); - } - return row; - }); - - return rewritePartmanMigrations(filteredRows); -}; - -/** - * Rewrites table/partman migrations to a self-contained - * partman.create_parent_with_retention() call instead of - * INSERT INTO metaschema_public.partition (hardcoded source-DB UUIDs). - * The control column is resolved at deploy time via pg_get_partkeydef(), - * so no DB lookups are needed at export time. Pure function. - */ -export const rewritePartmanMigrations = (rows: any[]): any[] => { return rows.map((row: any) => { - if (typeof row.deploy !== 'string') return row; - const match = row.deploy.match(/schemas\/([^/]+)\/tables\/([^/]+)\/table\/partman$/); - if (!match) return row; - - const [, schema, table] = match; - const parentTable = `${schema}.${table}`; - - // Parse the VALUES (...) tuple positionally. Column order: - // id, database_id, table_id, strategy, partition_key_id, - // interval, retention, retention_keep_table, premake, naming_pattern - const content: string = row.content || ''; - const tuple = content.match(/VALUES\s*\(([\s\S]*?)\)\s*ON CONFLICT/i); - const cols = tuple - ? tuple[1].split(',').map((s: string) => s.trim().replace(/^'|'$/g, '')) - : []; - const strategy = cols[3] || 'range'; - const interval = cols[5] || '1 month'; - const retention = cols[6] || ''; - const keepTable = /^true$/i.test(cols[7] || 'true'); - const premake = parseInt(cols[8] || '2', 10); - const retentionSql = retention ? `'${retention}'` : 'NULL'; - - // SQL BODY ONLY — writeDeploy() adds the header + requires from row.deps. - row.content = `-- Rewritten at export time: replaces INSERT INTO --- metaschema_public.partition (hardcoded source-DB UUIDs) with a --- self-contained partman registration. Control column resolved at --- deploy time via pg_get_partkeydef(); guarded for idempotency. -DO $$ -DECLARE - v_control text; -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM partman.part_config WHERE parent_table = '${parentTable}' - ) THEN - v_control := substring( - pg_get_partkeydef('${parentTable}'::regclass) FROM '\\(([^)]+)\\)' - ); - PERFORM partman.create_parent_with_retention( - v_parent_table := '${parentTable}', - v_control := v_control, - v_type := '${strategy}', - partition_interval := '${interval}', - v_premake := ${premake}, - v_retention := ${retentionSql}, - v_retention_keep_table := ${keepTable} - ); - END IF; -END $$; -`; + if (Array.isArray(row.deps)) { + row.deps = row.deps.filter((dep: string) => + ownedPrefixes.some(p => dep.includes(p)) + ); + } return row; }); }; diff --git a/pgpm/export/src/index.ts b/pgpm/export/src/index.ts index 147cf8b38d..d703959729 100644 --- a/pgpm/export/src/index.ts +++ b/pgpm/export/src/index.ts @@ -17,8 +17,7 @@ export { normalizeOutdir, detectMissingModules, installMissingModules, - filterPlatformLeakage, - rewritePartmanMigrations + filterPlatformLeakage } from './export-utils'; export type { FieldType,