diff --git a/AGENTS.md b/AGENTS.md
index 938a2b02..c71b42b7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -17,7 +17,7 @@ A pnpm monorepo for PostgreSQL AST parsing, deparsing, and code generation. All
| `@pgsql/utils` | `packages/utils` | Type-safe AST node creation utilities |
| `@pgsql/traverse` | `packages/traverse` | Visitor-pattern AST traversal |
| `@pgsql/transform-ast` | `packages/transform-ast` | Multi-version AST transformer (PG 13-17) |
-| `@pgsql/transform` | `packages/transform` | SQL schema transformation, statement classification, qualification, dependency closure |
+| `@pgsql/transform` | `packages/transform` | SQL schema transformation, statement classification (AST facts), qualification, round-trip validation |
| `@pgsql/quotes` | `packages/quotes` | SQL identifier/string quoting and keyword classification |
| `@pgsql/cli` | `packages/pgsql-cli` | CLI tool for parse/deparse operations |
| `pg-proto-parser` | `packages/proto-parser` | Generate TypeScript from PostgreSQL protobuf definitions |
diff --git a/README.md b/README.md
index 64330fb1..21603b84 100644
--- a/README.md
+++ b/README.md
@@ -174,7 +174,7 @@ walk(ast, visitor);
| [**@pgsql/utils**](./packages/utils) | Type-safe AST node creation utilities | • Programmatic AST construction
• Runtime Schema
• Seamless integration with types |
| [**pg-proto-parser**](./packages/proto-parser) | PostgreSQL protobuf parser and code generator | • Generate TypeScript interfaces from protobuf
• Create enum mappings and utilities
• AST helper generation |
| [**@pgsql/transform-ast**](./packages/transform-ast) | Multi-version PostgreSQL AST transformer | • Transform ASTs between PostgreSQL versions (13→17)
• Single source of truth deparser pipeline
• Backward compatibility for legacy SQL |
-| [**@pgsql/transform**](./packages/transform) | SQL transformation & classification | • Schema-name rewriting (incl. PL/pgSQL bodies)
• Per-statement AST facts (`classifyStatements`)
• Transitive dependency closure & qualification |
+| [**@pgsql/transform**](./packages/transform) | SQL transformation & classification | • Schema-name rewriting (incl. PL/pgSQL bodies)
• Per-statement AST facts (`classifyStatements`)
• Qualification & round-trip validation |
| [**@pgsql/traverse**](./packages/traverse) | PostgreSQL AST traversal utilities | • Visitor pattern for traversing PostgreSQL AST nodes
• NodePath context with parent/path information
• Runtime schema-based precise traversal |
## 🛠️ Development
diff --git a/packages/transform/README.md b/packages/transform/README.md
index 67f270da..50deb332 100644
--- a/packages/transform/README.md
+++ b/packages/transform/README.md
@@ -54,14 +54,6 @@ const facts = classifyStatements(sql);
Qualify unqualified object references against an inventory of known objects, with multi-schema routing support.
-### `resolveFixtureClosure` — transitive dependency closure
-
-Given a set of changes (name + SQL + optional declared dependencies), compute the transitive closure of a selection: forward producers of every referenced object/schema/role, plus attached fixtures (policies/grants/RLS targeting closure members), with explicit unresolved-reference reporting.
-
-### `categorizeChange` / `buildCategoryOf` — change categorization
-
-Profile-driven categorization of migration changes (e.g. schema / functionality / security / fixtures) from their AST facts.
-
### Round-trip validation
`normalizeTree` / `cleanTree` / `validateRoundTrip` — dependency-free AST normalization and mutation-aware parse→deparse→re-parse validation.
diff --git a/packages/transform/__tests__/categorize.test.ts b/packages/transform/__tests__/categorize.test.ts
deleted file mode 100644
index e7dbeb35..00000000
--- a/packages/transform/__tests__/categorize.test.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { loadModule } from 'plpgsql-parser';
-
-import { buildCategoryOf, categorizeChange, CategoryProfile, TIER_PROFILE } from '../src/categorize';
-
-beforeAll(async () => {
- await loadModule();
-});
-
-describe('categorizeChange (tier profile)', () => {
- it('classifies base DDL as schema', () => {
- expect(categorizeChange('CREATE SCHEMA app_public;', 'schemas/app/schema')).toBe('schema');
- expect(
- categorizeChange('CREATE TABLE app_public.users (id uuid PRIMARY KEY);', 'schemas/app/tables/users')
- ).toBe('schema');
- expect(
- categorizeChange('CREATE INDEX users_org_idx ON app_public.users (org_id);', 'schemas/app/indexes/users_org')
- ).toBe('schema');
- });
-
- it('classifies functions and triggers as functionality', () => {
- expect(
- categorizeChange(
- 'CREATE FUNCTION app_public.touch() RETURNS trigger AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql;',
- 'schemas/app/procedures/touch'
- )
- ).toBe('functionality');
- expect(
- categorizeChange(
- 'CREATE TRIGGER touch_users BEFORE UPDATE ON app_public.users FOR EACH ROW EXECUTE FUNCTION app_public.touch();',
- 'schemas/app/triggers/touch_users'
- )
- ).toBe('functionality');
- });
-
- it('classifies policies, grants and RLS as security (wins over everything)', () => {
- expect(
- categorizeChange('GRANT SELECT ON app_public.users TO authenticated;', 'schemas/app/grants/users')
- ).toBe('security');
- expect(
- categorizeChange(
- 'CREATE POLICY users_select ON app_public.users FOR SELECT TO authenticated USING (true);',
- 'schemas/app/policies/users_select'
- )
- ).toBe('security');
- // A change that both creates a table AND enables RLS is pulled into security.
- expect(
- categorizeChange(
- 'CREATE TABLE app_public.secrets (id uuid PRIMARY KEY); ALTER TABLE app_public.secrets ENABLE ROW LEVEL SECURITY;',
- 'schemas/app/tables/secrets'
- )
- ).toBe('security');
- });
-
- it('classifies seed DML as fixtures', () => {
- expect(
- categorizeChange("INSERT INTO app_public.settings (key, value) VALUES ('theme', 'dark');", 'seeds/settings')
- ).toBe('fixtures');
- });
-
- it('defaults empty/unclassifiable SQL to schema', () => {
- expect(categorizeChange('-- just a comment\n', 'schemas/app/noop')).toBe('schema');
- });
-});
-
-describe('buildCategoryOf', () => {
- const changeSql: Record = {
- 'schemas/app/schema': 'CREATE SCHEMA app_public;',
- 'schemas/app/tables/users': 'CREATE TABLE app_public.users (id uuid PRIMARY KEY);',
- 'schemas/app/procedures/touch':
- 'CREATE FUNCTION app_public.touch() RETURNS trigger AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql;',
- 'schemas/app/grants/users': 'GRANT SELECT ON app_public.users TO authenticated;',
- };
-
- it('produces a categoryOf function keyed by change name', () => {
- const categoryOf = buildCategoryOf(changeSql);
- expect(categoryOf('schemas/app/schema')).toBe('schema');
- expect(categoryOf('schemas/app/tables/users')).toBe('schema');
- expect(categoryOf('schemas/app/procedures/touch')).toBe('functionality');
- expect(categoryOf('schemas/app/grants/users')).toBe('security');
- });
-
- it('returns undefined for changes it never saw (core falls back to folder key)', () => {
- const categoryOf = buildCategoryOf(changeSql);
- expect(categoryOf('schemas/other/unknown')).toBeUndefined();
- });
-
- it('honors a custom profile', () => {
- // A per-schema profile: the category is the top-level schema segment.
- const bySchema: CategoryProfile = {
- name: 'by-schema',
- categorize: (_facts, changeName) => changeName.split('/')[1] ?? 'root',
- };
- const categoryOf = buildCategoryOf(changeSql, bySchema);
- expect(categoryOf('schemas/app/schema')).toBe('app');
- });
-
- it('exposes the default profile name', () => {
- expect(TIER_PROFILE.name).toBe('tier');
- });
-});
diff --git a/packages/transform/__tests__/fixture-closure.test.ts b/packages/transform/__tests__/fixture-closure.test.ts
deleted file mode 100644
index 3a344f26..00000000
--- a/packages/transform/__tests__/fixture-closure.test.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-import { loadModule } from 'plpgsql-parser';
-
-import { ClosureInputChange, resolveFixtureClosure } from '../src/fixture-closure';
-
-beforeAll(async () => {
- await loadModule();
-});
-
-// A small module: two schemas, tables, a policy + grant + RLS (security),
-// a seed insert (fixtures), and a trigger function (functionality).
-const CHANGES: ClosureInputChange[] = [
- { name: 'schemas/app_public/schema', sql: 'CREATE SCHEMA app_public;' },
- { name: 'schemas/app_private/schema', sql: 'CREATE SCHEMA app_private;' },
- {
- name: 'schemas/app_public/tables/orgs',
- sql: 'CREATE TABLE app_public.orgs (id uuid PRIMARY KEY);',
- dependencies: ['schemas/app_public/schema']
- },
- {
- name: 'schemas/app_public/tables/users',
- sql: 'CREATE TABLE app_public.users (id uuid PRIMARY KEY, org_id uuid REFERENCES app_public.orgs (id));',
- dependencies: ['schemas/app_public/schema']
- },
- {
- name: 'schemas/app_private/tables/sprt_org_members',
- sql: 'CREATE TABLE app_private.sprt_org_members (user_id uuid, org_id uuid REFERENCES app_public.orgs (id));',
- dependencies: ['schemas/app_private/schema']
- },
- {
- name: 'schemas/app_public/policies/users_select',
- sql: `CREATE POLICY users_select ON app_public.users FOR SELECT TO authenticated
- USING (org_id IN (SELECT org_id FROM app_private.sprt_org_members));`
- },
- {
- name: 'schemas/app_public/grants/users_grant',
- sql: 'GRANT SELECT ON app_public.users TO authenticated;'
- },
- {
- name: 'schemas/app_public/rls/users_enable',
- sql: 'ALTER TABLE app_public.users ENABLE ROW LEVEL SECURITY;'
- },
- {
- name: 'schemas/app_public/seed/default_org',
- sql: "INSERT INTO app_public.orgs (id) VALUES ('00000000-0000-0000-0000-000000000000');"
- },
- // an entirely unrelated table (same schema) that must NOT be pulled in
- {
- name: 'schemas/other/tables/widgets',
- sql: 'CREATE TABLE app_public.widgets (id uuid PRIMARY KEY);',
- dependencies: ['schemas/app_public/schema']
- },
- // a grant on the unrelated table — shares the schema but must NOT attach to users
- {
- name: 'schemas/other/grants/widgets_grant',
- sql: 'GRANT SELECT ON app_public.widgets TO authenticated;'
- }
-];
-
-describe('resolveFixtureClosure', () => {
- it('pulls prerequisites and attached fixtures for a selected table', () => {
- const closure = resolveFixtureClosure(CHANGES, ['schemas/app_public/tables/users']);
-
- // prerequisites: schema + FK target table
- expect(closure.order).toContain('schemas/app_public/schema');
- expect(closure.order).toContain('schemas/app_public/tables/orgs');
-
- // attached fixtures: policy, grant, RLS enable, and the seed on orgs
- expect(closure.fixtures).toEqual(
- expect.arrayContaining([
- 'schemas/app_public/policies/users_select',
- 'schemas/app_public/grants/users_grant',
- 'schemas/app_public/rls/users_enable',
- 'schemas/app_public/seed/default_org'
- ])
- );
-
- // the policy references the SPRT table -> its prerequisite is pulled too
- expect(closure.order).toContain('schemas/app_private/tables/sprt_org_members');
- expect(closure.order).toContain('schemas/app_private/schema');
-
- // unrelated table and its grant stay out despite sharing the schema
- expect(closure.order).not.toContain('schemas/other/tables/widgets');
- expect(closure.order).not.toContain('schemas/other/grants/widgets_grant');
- });
-
- it('is deterministic and preserves plan order', () => {
- const a = resolveFixtureClosure(CHANGES, ['schemas/app_public/tables/users']);
- const b = resolveFixtureClosure(CHANGES, ['schemas/app_public/tables/users']);
- expect(a).toEqual(b);
-
- const planOrder = CHANGES.map(c => c.name);
- const positions = a.order.map(n => planOrder.indexOf(n));
- expect(positions).toEqual([...positions].sort((x, y) => x - y));
- });
-
- it('reports required roles and surfaces unresolved external roles', () => {
- const closure = resolveFixtureClosure(CHANGES, ['schemas/app_public/tables/users']);
- // `authenticated` is referenced by policy/grant but created by no change
- expect(closure.roles).toContain('authenticated');
- expect(closure.unresolved.roles).toContain('authenticated');
- });
-
- it('labels each closure member with a category and reason', () => {
- const closure = resolveFixtureClosure(CHANGES, ['schemas/app_public/tables/users']);
- const users = closure.changes.find(c => c.name === 'schemas/app_public/tables/users')!;
- const policy = closure.changes.find(c => c.name === 'schemas/app_public/policies/users_select')!;
- const orgs = closure.changes.find(c => c.name === 'schemas/app_public/tables/orgs')!;
-
- expect(users.reason).toBe('selected');
- expect(users.category).toBe('schema');
- expect(policy.reason).toBe('fixture');
- expect(policy.category).toBe('security');
- expect(orgs.reason).toBe('prerequisite');
- });
-
- it('can skip prerequisites while still pulling attached fixtures', () => {
- const closure = resolveFixtureClosure(CHANGES, ['schemas/app_public/tables/users'], {
- includePrerequisites: false
- });
- // still pulls the fixtures attached to users
- expect(closure.fixtures).toEqual(
- expect.arrayContaining(['schemas/app_public/grants/users_grant'])
- );
- // but does not walk references to the FK target as a prerequisite
- expect(closure.order).not.toContain('schemas/app_public/tables/orgs');
- });
-});
diff --git a/packages/transform/src/categorize.ts b/packages/transform/src/categorize.ts
deleted file mode 100644
index e2ddc810..00000000
--- a/packages/transform/src/categorize.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { classifyStatements, StatementFacts } from './facts';
-
-/**
- * A category is a chunk-seam key a change is assigned to. Free-form string; the
- * built-in {@link TIER_PROFILE} uses `'security' | 'functionality' | 'fixtures'
- * | 'schema'`.
- *
- * This module is the classifier-driven counterpart to `@pgpmjs/core`'s
- * `boundary: 'category'` rebundle mode: core stays classifier-agnostic and only
- * consumes a `categoryOf(changeName)` seam function, while the rules that decide
- * *what* each change is (the profile) live here, next to the AST facts.
- */
-export type ChangeCategory = string;
-
-/**
- * Decides a change's category from the AST facts of its (deploy) statements.
- * A profile is a pure function of the facts — swap it to carve a monolith along
- * different seams (e.g. per-role admin/users modules) without touching core.
- */
-export interface CategoryProfile {
- /** Identifier for the profile (for logging / provenance). */
- name: string;
- /** Map a change's statement facts to its category. */
- categorize: (facts: StatementFacts[], changeName: string) => ChangeCategory;
-}
-
-/**
- * Default tier profile: isolates the security surface and procedural code out
- * of the schema base. Precedence, highest first — a change is placed in the
- * most specialized tier any of its statements reaches:
- *
- * security any securityRelevant statement (policy/grant/RLS/role/owner)
- * functionality functions and triggers
- * fixtures seed DML (INSERT/UPDATE/DELETE)
- * schema schemas, tables, views, indexes, types, constraints (base)
- *
- * Ordering/dependencies across the resulting chunks are still resolved by
- * core's rebundle graph, so this only decides *naming/grouping*, never deploy
- * order.
- */
-export const TIER_PROFILE: CategoryProfile = {
- name: 'tier',
- categorize(facts): ChangeCategory {
- if (facts.length === 0) return 'schema';
- if (facts.some(f => f.securityRelevant)) return 'security';
- if (facts.some(f => f.kind === 'function' || f.kind === 'trigger')) return 'functionality';
- if (facts.some(f => f.kind === 'seed_dml')) return 'fixtures';
- return 'schema';
- },
-};
-
-/**
- * Classify a single change's deploy SQL into its category under `profile`
- * (defaults to {@link TIER_PROFILE}).
- */
-export function categorizeChange(
- sql: string,
- changeName: string,
- profile: CategoryProfile = TIER_PROFILE
-): ChangeCategory {
- return profile.categorize(classifyStatements(sql), changeName);
-}
-
-/**
- * Build a `categoryOf(changeName)` seam function from a map of change name to
- * its deploy SQL — ready to pass straight to `@pgpmjs/core`'s rebundle
- * `boundary: 'category'` / `categoryOf` options.
- *
- * Changes absent from `changeSql` return `undefined`, letting core fall back to
- * its folder key for anything the classifier did not see.
- */
-export function buildCategoryOf(
- changeSql: Record,
- profile: CategoryProfile = TIER_PROFILE
-): (changeName: string) => ChangeCategory | undefined {
- const categories = new Map();
- for (const [name, sql] of Object.entries(changeSql)) {
- categories.set(name, categorizeChange(sql, name, profile));
- }
- return (changeName: string): ChangeCategory | undefined => categories.get(changeName);
-}
diff --git a/packages/transform/src/fixture-closure.ts b/packages/transform/src/fixture-closure.ts
deleted file mode 100644
index d3679544..00000000
--- a/packages/transform/src/fixture-closure.ts
+++ /dev/null
@@ -1,286 +0,0 @@
-import {
- CategoryProfile,
- ChangeCategory,
- TIER_PROFILE
-} from './categorize';
-import { classifyStatements, QualifiedName, StatementFacts } from './facts';
-
-/**
- * A change plus its deploy SQL. Optional plan `dependencies` (from `pgpm.plan`)
- * are followed as authoritative prerequisites in addition to AST-derived edges.
- */
-export interface ClosureInputChange {
- name: string;
- sql: string;
- dependencies?: string[];
-}
-
-/** Why a change ended up in the closure. */
-export type ClosureReason = 'selected' | 'prerequisite' | 'fixture';
-
-/**
- * A change included in the closure, with its category and the reason it was pulled.
- */
-export interface ClosureChange {
- name: string;
- category: ChangeCategory;
- reason: ClosureReason;
-}
-
-/**
- * The fixture closure of a schema slice.
- *
- * Everything the selected changes need to actually *work*: schema/functionality
- * prerequisites they depend on, plus the fixtures that attach to them (policies,
- * grants, RLS enables, role bindings, seed rows / SPRT-style support changes).
- * References that nothing in the input produces are surfaced in `unresolved`
- * rather than silently dropped.
- */
-export interface FixtureClosure {
- /** Full closure (selection + prerequisites + fixtures) in input/plan order. */
- order: string[];
- /** Per-change detail in the same order as {@link order}. */
- changes: ClosureChange[];
- /** Closure members categorized as security/fixtures (attached fixtures). */
- fixtures: string[];
- /** Non-selected schema/functionality members pulled in as prerequisites. */
- prerequisites: string[];
- /** Roles required across the closure. */
- roles: string[];
- /** Requirements no input change produces — explicit, never dropped. */
- unresolved: {
- objects: QualifiedName[];
- schemas: string[];
- roles: string[];
- };
-}
-
-export interface ResolveFixtureClosureOptions {
- /** Category profile used to label changes (default {@link TIER_PROFILE}). */
- profile?: CategoryProfile;
- /**
- * When false, do not follow AST references from selected changes to their
- * schema/functionality producers (only pull attached fixtures + plan deps).
- * Default true.
- */
- includePrerequisites?: boolean;
-}
-
-function objectKey(schema: string | null, name: string): string {
- return `${schema ?? ''}\u0000${name}`;
-}
-
-interface ChangeAnalysis {
- name: string;
- category: ChangeCategory;
- facts: StatementFacts[];
- /** Declared plan dependencies (authoritative prerequisites). */
- dependencies: string[];
- /** Objects this change creates (for producer indexing). */
- creates: QualifiedName[];
- /** Schemas this change creates. */
- createsSchemas: string[];
- /** Roles this change creates (CreateRoleStmt). */
- createsRoles: string[];
- /** Schema-qualified objects this change requires. */
- needsObjects: QualifiedName[];
- /** Schemas this change requires. */
- needsSchemas: string[];
- /** Roles this change requires (excludes roles it creates itself). */
- needsRoles: string[];
- /** True when categorized as an attached fixture (security/fixtures). */
- isFixture: boolean;
-}
-
-function analyze(change: ClosureInputChange, profile: CategoryProfile): ChangeAnalysis {
- const facts = classifyStatements(change.sql);
- const category = profile.categorize(facts, change.name);
-
- const creates: QualifiedName[] = [];
- const createsSchemas: string[] = [];
- const createsRoles: string[] = [];
- const needsObjects: QualifiedName[] = [];
- const needsSchemas = new Set();
- const needsRoles = new Set();
-
- for (const f of facts) {
- for (const c of f.creates) {
- if (f.kind === 'schema') createsSchemas.push(c.name);
- else {
- creates.push(c);
- if (c.schema) needsSchemas.add(c.schema);
- }
- }
- if (f.nodeTag === 'CreateRoleStmt') createsRoles.push(...f.roles);
-
- for (const r of [...f.references, ...f.fkTargets]) {
- if (r.schema) needsObjects.push(r);
- }
- for (const s of f.referencedSchemas) needsSchemas.add(s);
- }
-
- const created = new Set(createsRoles);
- for (const f of facts) {
- if (f.nodeTag === 'CreateRoleStmt') continue;
- for (const role of f.roles) if (!created.has(role)) needsRoles.add(role);
- }
- for (const s of createsSchemas) needsSchemas.delete(s);
-
- return {
- name: change.name,
- category,
- facts,
- dependencies: change.dependencies ?? [],
- creates,
- createsSchemas,
- createsRoles,
- needsObjects,
- needsSchemas: [...needsSchemas],
- needsRoles: [...needsRoles],
- isFixture: category === 'security' || category === 'fixtures'
- };
-}
-
-/**
- * Resolve the fixture closure of a schema slice.
- *
- * Pure and deterministic — no I/O — and runs to a fixpoint in both directions:
- * - forward: a selected change pulls in the producers of the objects/schemas/
- * roles it references, plus its declared plan dependencies (prerequisites);
- * - reverse: any fixture (security/fixtures-tier change) that attaches to an
- * object already in the closure is pulled in (attached fixtures), and then
- * resolved for its own prerequisites.
- *
- * Output order follows the input (plan) order, so it stays deploy-safe.
- */
-export function resolveFixtureClosure(
- allChanges: ClosureInputChange[],
- selection: string[],
- options: ResolveFixtureClosureOptions = {}
-): FixtureClosure {
- const profile = options.profile ?? TIER_PROFILE;
- const includePrerequisites = options.includePrerequisites ?? true;
-
- const analyses = allChanges.map(c => analyze(c, profile));
- const byName = new Map(analyses.map(a => [a.name, a]));
-
- const producerByObject = new Map();
- const producerBySchema = new Map();
- const producerByRole = new Map();
- for (const a of analyses) {
- for (const c of a.creates) {
- const key = objectKey(c.schema, c.name);
- if (!producerByObject.has(key)) producerByObject.set(key, a.name);
- }
- for (const s of a.createsSchemas) {
- if (!producerBySchema.has(s)) producerBySchema.set(s, a.name);
- }
- for (const r of a.createsRoles) {
- if (!producerByRole.has(r)) producerByRole.set(r, a.name);
- }
- }
-
- const reason = new Map();
- for (const name of selection) {
- if (byName.has(name)) reason.set(name, 'selected');
- }
-
- const producedObjectKeys = new Set();
- const producedSchemas = new Set();
- const refreshProduced = (): void => {
- producedObjectKeys.clear();
- producedSchemas.clear();
- for (const name of reason.keys()) {
- const a = byName.get(name)!;
- for (const c of a.creates) producedObjectKeys.add(objectKey(c.schema, c.name));
- for (const s of a.createsSchemas) producedSchemas.add(s);
- }
- };
-
- let changed = true;
- while (changed) {
- changed = false;
-
- // forward: pull producers of what current members need
- for (const name of [...reason.keys()]) {
- const a = byName.get(name)!;
- const pull = (producer: string | undefined, r: ClosureReason): void => {
- if (!producer || reason.has(producer)) return;
- const pa = byName.get(producer)!;
- reason.set(producer, pa.isFixture ? 'fixture' : r);
- changed = true;
- };
- for (const dep of a.dependencies) {
- pull(byName.has(dep) ? dep : undefined, 'prerequisite');
- }
- if (!includePrerequisites) continue;
- for (const o of a.needsObjects) pull(producerByObject.get(objectKey(o.schema, o.name)), 'prerequisite');
- for (const s of a.needsSchemas) pull(producerBySchema.get(s), 'prerequisite');
- for (const role of a.needsRoles) pull(producerByRole.get(role), 'prerequisite');
- }
-
- // reverse: pull attached fixtures that target current members
- refreshProduced();
- for (const a of analyses) {
- if (reason.has(a.name) || !a.isFixture) continue;
- const objectAttaches =
- a.needsObjects.some(o => producedObjectKeys.has(objectKey(o.schema, o.name))) ||
- a.creates.some(c => producedObjectKeys.has(objectKey(c.schema, c.name)));
- // Schema-scoped fixtures (e.g. ALTER DEFAULT PRIVILEGES IN SCHEMA x) name no
- // specific object; attach them by schema. Object-scoped fixtures attach only
- // via their object refs, so a shared schema does not drag in every fixture.
- const schemaScoped = a.needsObjects.length === 0 && a.creates.length === 0;
- const schemaAttaches =
- schemaScoped &&
- (a.needsSchemas.some(s => producedSchemas.has(s)) ||
- a.createsSchemas.some(s => producedSchemas.has(s)));
- if (objectAttaches || schemaAttaches) {
- reason.set(a.name, 'fixture');
- changed = true;
- }
- }
- }
-
- const order = analyses.filter(a => reason.has(a.name)).map(a => a.name);
- const changes: ClosureChange[] = order.map(name => {
- const a = byName.get(name)!;
- return { name, category: a.category, reason: reason.get(name)! };
- });
-
- const roles = new Set();
- const unresolvedObjects: QualifiedName[] = [];
- const unresolvedObjectKeys = new Set();
- const unresolvedSchemas = new Set();
- const unresolvedRoles = new Set();
-
- for (const name of order) {
- const a = byName.get(name)!;
- for (const role of a.needsRoles) {
- roles.add(role);
- if (!producerByRole.has(role)) unresolvedRoles.add(role);
- }
- for (const o of a.needsObjects) {
- const key = objectKey(o.schema, o.name);
- if (!producerByObject.has(key) && !unresolvedObjectKeys.has(key)) {
- unresolvedObjectKeys.add(key);
- unresolvedObjects.push(o);
- }
- }
- for (const s of a.needsSchemas) {
- if (!producerBySchema.has(s)) unresolvedSchemas.add(s);
- }
- }
-
- return {
- order,
- changes,
- fixtures: changes.filter(c => c.reason === 'fixture').map(c => c.name),
- prerequisites: changes.filter(c => c.reason === 'prerequisite').map(c => c.name),
- roles: [...roles],
- unresolved: {
- objects: unresolvedObjects,
- schemas: [...unresolvedSchemas],
- roles: [...unresolvedRoles]
- }
- };
-}
diff --git a/packages/transform/src/index.ts b/packages/transform/src/index.ts
index cd447917..81b58f38 100644
--- a/packages/transform/src/index.ts
+++ b/packages/transform/src/index.ts
@@ -1,26 +1,9 @@
-export type {
- CategoryProfile,
- ChangeCategory,
-} from './categorize';
-export {
- buildCategoryOf,
- categorizeChange,
- TIER_PROFILE,
-} from './categorize';
export type {
QualifiedName,
StatementFacts,
StatementKind,
} from './facts';
export { classifyStatements } from './facts';
-export type {
- ClosureChange,
- ClosureInputChange,
- ClosureReason,
- FixtureClosure,
- ResolveFixtureClosureOptions,
-} from './fixture-closure';
-export { resolveFixtureClosure } from './fixture-closure';
export type {
ObjectInventory,
QualifyResult,