From cc6da09dfd76b69f0fba3553c08eeb09b7d1d16c Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 31 Jul 2026 04:32:07 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(transform):=20PGPM=20naming=20spec=20v?= =?UTF-8?q?1=20=E2=80=94=20identityOf=20+=20pathFor=20(canonical=20derived?= =?UTF-8?q?=20change=20paths)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/transform/__tests__/naming.test.ts | 59 ++++++ packages/transform/src/index.ts | 10 + packages/transform/src/naming.ts | 196 ++++++++++++++++++++ 3 files changed, 265 insertions(+) create mode 100644 packages/transform/__tests__/naming.test.ts create mode 100644 packages/transform/src/naming.ts diff --git a/packages/transform/__tests__/naming.test.ts b/packages/transform/__tests__/naming.test.ts new file mode 100644 index 00000000..51d500ca --- /dev/null +++ b/packages/transform/__tests__/naming.test.ts @@ -0,0 +1,59 @@ +import { loadModule } from 'plpgsql-parser'; + +import { classifyStatements } from '../src/facts'; +import { changePathFor, identityOf, pathFor } from '../src/naming'; + +beforeAll(async () => { + await loadModule(); +}); + +const pathOf = (sql: string): string | null => changePathFor(classifyStatements(sql)[0]); + +describe('PGPM naming spec v1', () => { + it('derives canonical paths per object kind', () => { + expect(pathOf('CREATE SCHEMA app;')).toBe('schemas/app/schema'); + expect(pathOf('CREATE TABLE app.users (id int);')).toBe('schemas/app/tables/users/table'); + expect(pathOf('CREATE VIEW app.v_users AS SELECT 1;')).toBe('schemas/app/views/v_users'); + expect(pathOf('CREATE FUNCTION app.fn() RETURNS int LANGUAGE sql AS $$ SELECT 1 $$;')) + .toBe('schemas/app/procedures/fn'); + expect(pathOf('CREATE TYPE app.status AS ENUM (\'a\');')).toBe('schemas/app/types/status'); + expect(pathOf('CREATE SEQUENCE app.seq;')).toBe('schemas/app/sequences/seq'); + expect(pathOf('CREATE EXTENSION pgcrypto;')).toBe('extensions/pgcrypto'); + }); + + it('scopes triggers, policies, and indexes to their table', () => { + expect(pathOf( + 'CREATE TRIGGER trg BEFORE INSERT ON app.users FOR EACH ROW EXECUTE FUNCTION app.fn();' + )).toBe('schemas/app/tables/users/triggers/trg'); + expect(pathOf('CREATE POLICY p ON app.users USING (true);')) + .toBe('schemas/app/tables/users/policies/p'); + expect(pathOf('CREATE INDEX users_email_idx ON app.users (email);')) + .toBe('schemas/app/tables/users/indexes/users_email_idx'); + }); + + it('routes ALTER TABLE constraint statements to the table constraints dir', () => { + const path = pathOf('ALTER TABLE app.users ADD CONSTRAINT users_pkey PRIMARY KEY (id);'); + expect(path).toBe('schemas/app/tables/users/constraints/users'); + }); + + it('returns null for statements with no identity of their own', () => { + expect(pathOf('GRANT SELECT ON app.users TO reader;')).toBeNull(); + expect(pathOf("COMMENT ON TABLE app.users IS 'x';")).toBeNull(); + }); + + it('defaults missing schema to public', () => { + expect(pathOf('CREATE TABLE users (id int);')).toBe('schemas/public/tables/users/table'); + }); + + it('pathFor is total and deterministic over identities', () => { + expect(pathFor({ kind: 'role', schema: null, name: 'admin' })).toBe('roles/admin'); + expect(pathFor({ kind: 'other', schema: 'app', name: 'thing' })).toBe('schemas/app/objects/thing'); + }); + + it('identity is the key, path is the rendering', () => { + const facts = classifyStatements('CREATE TABLE app.users (id int);')[0]; + const identity = identityOf(facts)!; + expect(identity).toEqual({ kind: 'table', schema: 'app', name: 'users' }); + expect(pathFor(identity)).toBe('schemas/app/tables/users/table'); + }); +}); diff --git a/packages/transform/src/index.ts b/packages/transform/src/index.ts index 4609a6ac..75c274f9 100644 --- a/packages/transform/src/index.ts +++ b/packages/transform/src/index.ts @@ -13,6 +13,16 @@ export type { StatementNode, } from './graph'; export { buildStatementGraph } from './graph'; +export type { + ObjectIdentity, + ObjectIdentityKind, +} from './naming'; +export { + changePathFor, + identityOf, + pathFor, + PGPM_NAMING_SPEC_VERSION, +} from './naming'; export type { Granularity, RestructureOptions, diff --git a/packages/transform/src/naming.ts b/packages/transform/src/naming.ts new file mode 100644 index 00000000..b216a39e --- /dev/null +++ b/packages/transform/src/naming.ts @@ -0,0 +1,196 @@ +/** + * PGPM naming spec v1 — canonical, derived change paths. + * + * A change path is never authored and never identity: it is a pure projection + * of an object's identity through this spec. Objects (content-addressed ASTs + * + dependency edges) are the source of truth; paths are re-derivable at any + * time, so regrouping, renaming schemes, or repartitioning packages can never + * break identity-keyed consumers (diff, dependency resolution). + * + * Identity tuple: `(kind, schema, name, table?)` — `table` scopes objects + * that are only unique per table (triggers, policies, indexes, constraints, + * seed data). Function overloads share a path in v1 (disambiguation via a + * signature suffix is reserved for a future spec version). + * + * Canonical templates (matching the conventions used across constructive-db + * deploy trees): + * + * schema schemas/{schema}/schema + * table schemas/{schema}/tables/{table}/table + * trigger schemas/{schema}/tables/{table}/triggers/{name} + * policy schemas/{schema}/tables/{table}/policies/{name} + * index schemas/{schema}/tables/{table}/indexes/{name} + * constraint schemas/{schema}/tables/{table}/constraints/{name} + * seed_dml schemas/{schema}/tables/{table}/fixtures/{name} + * function schemas/{schema}/procedures/{name} + * view schemas/{schema}/views/{name} + * type schemas/{schema}/types/{name} + * sequence schemas/{schema}/sequences/{name} + * extension extensions/{name} + * role roles/{name} + */ +import { StatementFacts } from './facts'; + +/** Spec version, so bundles/modules can declare which scheme derived their paths. */ +export const PGPM_NAMING_SPEC_VERSION = 1; + +/** The kinds of objects the naming spec assigns paths to. */ +export type ObjectIdentityKind = + | 'schema' + | 'extension' + | 'role' + | 'table' + | 'view' + | 'sequence' + | 'type' + | 'function' + | 'index' + | 'trigger' + | 'policy' + | 'constraint' + | 'seed_dml' + | 'other'; + +/** + * The identity of a database object — what a change path is derived from. + * Identity is the diff/dependency key; the path is only its rendering. + */ +export interface ObjectIdentity { + kind: ObjectIdentityKind; + /** Owning schema (`null` for non-schema objects: roles, extensions). */ + schema: string | null; + /** Object name, unqualified (for table-scoped kinds: without the table). */ + name: string; + /** Owning table, for objects only unique per table (trigger/policy/index/constraint/seed). */ + table?: string; +} + +/** Kinds whose objects are scoped to (and only unique within) a table. */ +const TABLE_SCOPED = new Set([ + 'trigger', + 'policy', + 'index', + 'constraint', + 'seed_dml' +]); + +/** Directory names for schema-scoped object kinds. */ +const SCHEMA_DIRS: Partial> = { + view: 'views', + sequence: 'sequences', + type: 'types', + function: 'procedures' +}; + +/** Directory names for table-scoped object kinds. */ +const TABLE_DIRS: Partial> = { + trigger: 'triggers', + policy: 'policies', + index: 'indexes', + constraint: 'constraints', + seed_dml: 'fixtures' +}; + +/** + * Derive the identity of the object a statement primarily creates or + * targets, or `null` when the statement creates nothing (grants, comments — + * such statements ride with the change of the object they attach to). + * + * Table-scoped kinds are recovered from the classifier's table-qualified + * names (`table.trigger`) and, for indexes and constraints, from the + * targeted relation. + */ +export function identityOf(facts: StatementFacts): ObjectIdentity | null { + if (facts.kind === 'extension' && facts.extension) { + return { kind: 'extension', schema: null, name: facts.extension.name }; + } + + const created = facts.creates[0]; + if (!created) return null; + + switch (facts.kind) { + case 'schema': + return { kind: 'schema', schema: null, name: created.name }; + case 'trigger': + case 'policy': { + const dot = created.name.indexOf('.'); + if (dot > 0) { + return { + kind: facts.kind, + schema: created.schema, + name: created.name.slice(dot + 1), + table: created.name.slice(0, dot) + }; + } + return { kind: facts.kind, schema: created.schema, name: created.name }; + } + case 'index': { + // IndexStmt records the index name in creates and the indexed relation + // in references (same-schema RangeVar). + const rel = facts.references.find(r => r.schema === created.schema) ?? facts.references[0]; + return { + kind: 'index', + schema: created.schema, + name: created.name, + table: rel?.name + }; + } + case 'fk_constraint': + case 'constraint': + case 'rls_enable': + // ALTER TABLE statements target their table. + return { kind: 'constraint', schema: created.schema, name: created.name, table: created.name }; + case 'seed_dml': + return { kind: 'seed_dml', schema: created.schema, name: created.name, table: created.name }; + case 'table': + // AlterTableStmt facts also classify as `table`-targeting; the created + // name is the table either way. + return { kind: 'table', schema: created.schema, name: created.name }; + case 'view': + case 'function': + case 'type': + return { kind: facts.kind, schema: created.schema, name: created.name }; + default: + if (facts.nodeTag === 'CreateSeqStmt') { + return { kind: 'sequence', schema: created.schema, name: created.name }; + } + return { kind: 'other', schema: created.schema, name: created.name }; + } +} + +/** + * Render an identity to its canonical pgpm change path (naming spec v1). + * Total: every identity gets a deterministic path. + */ +export function pathFor(identity: ObjectIdentity): string { + const { kind, name } = identity; + const schema = identity.schema ?? 'public'; + + if (kind === 'schema') return `schemas/${name}/schema`; + if (kind === 'extension') return `extensions/${name}`; + if (kind === 'role') return `roles/${name}`; + if (kind === 'table') return `schemas/${schema}/tables/${name}/table`; + + if (TABLE_SCOPED.has(kind)) { + const dir = TABLE_DIRS[kind]!; + if (identity.table && identity.table !== name) { + return `schemas/${schema}/tables/${identity.table}/${dir}/${name}`; + } + // Table-scoped object whose table equals the target (ALTER TABLE + // constraints, seed data keyed by table). + return `schemas/${schema}/tables/${identity.table ?? name}/${dir}/${name}`; + } + + const dir = SCHEMA_DIRS[kind]; + if (dir) return `schemas/${schema}/${dir}/${name}`; + return `schemas/${schema}/objects/${name}`; +} + +/** + * Convenience: canonical change path for a statement, or `null` when the + * statement has no identity of its own. + */ +export function changePathFor(facts: StatementFacts): string | null { + const identity = identityOf(facts); + return identity ? pathFor(identity) : null; +} From cfa7fb90dfa3dead6011617b28c805c397819295 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 31 Jul 2026 04:44:01 +0000 Subject: [PATCH 2/2] refactor(transform): keep only Postgres-native identityOf; move path rendering downstream --- packages/transform/__tests__/naming.test.ts | 64 +++++------- packages/transform/src/index.ts | 7 +- packages/transform/src/naming.ts | 108 +++----------------- 3 files changed, 41 insertions(+), 138 deletions(-) diff --git a/packages/transform/__tests__/naming.test.ts b/packages/transform/__tests__/naming.test.ts index 51d500ca..612187c6 100644 --- a/packages/transform/__tests__/naming.test.ts +++ b/packages/transform/__tests__/naming.test.ts @@ -1,59 +1,47 @@ import { loadModule } from 'plpgsql-parser'; import { classifyStatements } from '../src/facts'; -import { changePathFor, identityOf, pathFor } from '../src/naming'; +import { identityOf } from '../src/naming'; beforeAll(async () => { await loadModule(); }); -const pathOf = (sql: string): string | null => changePathFor(classifyStatements(sql)[0]); - -describe('PGPM naming spec v1', () => { - it('derives canonical paths per object kind', () => { - expect(pathOf('CREATE SCHEMA app;')).toBe('schemas/app/schema'); - expect(pathOf('CREATE TABLE app.users (id int);')).toBe('schemas/app/tables/users/table'); - expect(pathOf('CREATE VIEW app.v_users AS SELECT 1;')).toBe('schemas/app/views/v_users'); - expect(pathOf('CREATE FUNCTION app.fn() RETURNS int LANGUAGE sql AS $$ SELECT 1 $$;')) - .toBe('schemas/app/procedures/fn'); - expect(pathOf('CREATE TYPE app.status AS ENUM (\'a\');')).toBe('schemas/app/types/status'); - expect(pathOf('CREATE SEQUENCE app.seq;')).toBe('schemas/app/sequences/seq'); - expect(pathOf('CREATE EXTENSION pgcrypto;')).toBe('extensions/pgcrypto'); +const idOf = (sql: string) => identityOf(classifyStatements(sql)[0]); + +describe('identityOf', () => { + it('derives identities per object kind', () => { + expect(idOf('CREATE SCHEMA app;')).toEqual({ kind: 'schema', schema: null, name: 'app' }); + expect(idOf('CREATE TABLE app.users (id int);')).toEqual({ kind: 'table', schema: 'app', name: 'users' }); + expect(idOf('CREATE VIEW app.v_users AS SELECT 1;')).toEqual({ kind: 'view', schema: 'app', name: 'v_users' }); + expect(idOf('CREATE FUNCTION app.fn() RETURNS int LANGUAGE sql AS $$ SELECT 1 $$;')) + .toEqual({ kind: 'function', schema: 'app', name: 'fn' }); + expect(idOf("CREATE TYPE app.status AS ENUM ('a');")).toEqual({ kind: 'type', schema: 'app', name: 'status' }); + expect(idOf('CREATE SEQUENCE app.seq;')).toEqual({ kind: 'sequence', schema: 'app', name: 'seq' }); + expect(idOf('CREATE EXTENSION pgcrypto;')).toEqual({ kind: 'extension', schema: null, name: 'pgcrypto' }); }); it('scopes triggers, policies, and indexes to their table', () => { - expect(pathOf( + expect(idOf( 'CREATE TRIGGER trg BEFORE INSERT ON app.users FOR EACH ROW EXECUTE FUNCTION app.fn();' - )).toBe('schemas/app/tables/users/triggers/trg'); - expect(pathOf('CREATE POLICY p ON app.users USING (true);')) - .toBe('schemas/app/tables/users/policies/p'); - expect(pathOf('CREATE INDEX users_email_idx ON app.users (email);')) - .toBe('schemas/app/tables/users/indexes/users_email_idx'); + )).toEqual({ kind: 'trigger', schema: 'app', name: 'trg', table: 'users' }); + expect(idOf('CREATE POLICY p ON app.users USING (true);')) + .toEqual({ kind: 'policy', schema: 'app', name: 'p', table: 'users' }); + expect(idOf('CREATE INDEX users_email_idx ON app.users (email);')) + .toEqual({ kind: 'index', schema: 'app', name: 'users_email_idx', table: 'users' }); }); - it('routes ALTER TABLE constraint statements to the table constraints dir', () => { - const path = pathOf('ALTER TABLE app.users ADD CONSTRAINT users_pkey PRIMARY KEY (id);'); - expect(path).toBe('schemas/app/tables/users/constraints/users'); + it('targets ALTER TABLE constraint statements at their table', () => { + expect(idOf('ALTER TABLE app.users ADD CONSTRAINT users_pkey PRIMARY KEY (id);')) + .toEqual({ kind: 'constraint', schema: 'app', name: 'users', table: 'users' }); }); it('returns null for statements with no identity of their own', () => { - expect(pathOf('GRANT SELECT ON app.users TO reader;')).toBeNull(); - expect(pathOf("COMMENT ON TABLE app.users IS 'x';")).toBeNull(); + expect(idOf('GRANT SELECT ON app.users TO reader;')).toBeNull(); + expect(idOf("COMMENT ON TABLE app.users IS 'x';")).toBeNull(); }); - it('defaults missing schema to public', () => { - expect(pathOf('CREATE TABLE users (id int);')).toBe('schemas/public/tables/users/table'); - }); - - it('pathFor is total and deterministic over identities', () => { - expect(pathFor({ kind: 'role', schema: null, name: 'admin' })).toBe('roles/admin'); - expect(pathFor({ kind: 'other', schema: 'app', name: 'thing' })).toBe('schemas/app/objects/thing'); - }); - - it('identity is the key, path is the rendering', () => { - const facts = classifyStatements('CREATE TABLE app.users (id int);')[0]; - const identity = identityOf(facts)!; - expect(identity).toEqual({ kind: 'table', schema: 'app', name: 'users' }); - expect(pathFor(identity)).toBe('schemas/app/tables/users/table'); + it('leaves schema null when unqualified (resolution is a consumer concern)', () => { + expect(idOf('CREATE TABLE users (id int);')).toEqual({ kind: 'table', schema: null, name: 'users' }); }); }); diff --git a/packages/transform/src/index.ts b/packages/transform/src/index.ts index 75c274f9..1078f782 100644 --- a/packages/transform/src/index.ts +++ b/packages/transform/src/index.ts @@ -17,12 +17,7 @@ export type { ObjectIdentity, ObjectIdentityKind, } from './naming'; -export { - changePathFor, - identityOf, - pathFor, - PGPM_NAMING_SPEC_VERSION, -} from './naming'; +export { identityOf } from './naming'; export type { Granularity, RestructureOptions, diff --git a/packages/transform/src/naming.ts b/packages/transform/src/naming.ts index b216a39e..35d1c5c6 100644 --- a/packages/transform/src/naming.ts +++ b/packages/transform/src/naming.ts @@ -1,40 +1,23 @@ /** - * PGPM naming spec v1 — canonical, derived change paths. + * Object identity — the canonical, Postgres-native answer to "what object is + * this statement about?". * - * A change path is never authored and never identity: it is a pure projection - * of an object's identity through this spec. Objects (content-addressed ASTs - * + dependency edges) are the source of truth; paths are re-derivable at any - * time, so regrouping, renaming schemes, or repartitioning packages can never - * break identity-keyed consumers (diff, dependency resolution). + * Identity is the key used by dependency graphs, semantic diffing, and any + * downstream naming scheme. It is a pure function of classifier facts — + * grounded in the parser's node taxonomy (`CreateStmt`, `CreateTrigStmt`, + * `IndexStmt`, ...), never in surface syntax like RangeVars. Rendering an + * identity to a change path (e.g. a pgpm module layout) is deliberately NOT + * defined here: paths are derived projections that belong to whichever + * packaging layer consumes the identity, so nothing is ever attached to them. * * Identity tuple: `(kind, schema, name, table?)` — `table` scopes objects * that are only unique per table (triggers, policies, indexes, constraints, - * seed data). Function overloads share a path in v1 (disambiguation via a - * signature suffix is reserved for a future spec version). - * - * Canonical templates (matching the conventions used across constructive-db - * deploy trees): - * - * schema schemas/{schema}/schema - * table schemas/{schema}/tables/{table}/table - * trigger schemas/{schema}/tables/{table}/triggers/{name} - * policy schemas/{schema}/tables/{table}/policies/{name} - * index schemas/{schema}/tables/{table}/indexes/{name} - * constraint schemas/{schema}/tables/{table}/constraints/{name} - * seed_dml schemas/{schema}/tables/{table}/fixtures/{name} - * function schemas/{schema}/procedures/{name} - * view schemas/{schema}/views/{name} - * type schemas/{schema}/types/{name} - * sequence schemas/{schema}/sequences/{name} - * extension extensions/{name} - * role roles/{name} + * seed data). Function overloads share an identity for now (signature + * disambiguation is a planned refinement). */ import { StatementFacts } from './facts'; -/** Spec version, so bundles/modules can declare which scheme derived their paths. */ -export const PGPM_NAMING_SPEC_VERSION = 1; - -/** The kinds of objects the naming spec assigns paths to. */ +/** The kinds of objects an identity can describe. */ export type ObjectIdentityKind = | 'schema' | 'extension' @@ -52,8 +35,8 @@ export type ObjectIdentityKind = | 'other'; /** - * The identity of a database object — what a change path is derived from. - * Identity is the diff/dependency key; the path is only its rendering. + * The identity of a database object. Identity is the diff/dependency key; + * any path or name is only a downstream rendering of it. */ export interface ObjectIdentity { kind: ObjectIdentityKind; @@ -65,32 +48,6 @@ export interface ObjectIdentity { table?: string; } -/** Kinds whose objects are scoped to (and only unique within) a table. */ -const TABLE_SCOPED = new Set([ - 'trigger', - 'policy', - 'index', - 'constraint', - 'seed_dml' -]); - -/** Directory names for schema-scoped object kinds. */ -const SCHEMA_DIRS: Partial> = { - view: 'views', - sequence: 'sequences', - type: 'types', - function: 'procedures' -}; - -/** Directory names for table-scoped object kinds. */ -const TABLE_DIRS: Partial> = { - trigger: 'triggers', - policy: 'policies', - index: 'indexes', - constraint: 'constraints', - seed_dml: 'fixtures' -}; - /** * Derive the identity of the object a statement primarily creates or * targets, or `null` when the statement creates nothing (grants, comments — @@ -157,40 +114,3 @@ export function identityOf(facts: StatementFacts): ObjectIdentity | null { return { kind: 'other', schema: created.schema, name: created.name }; } } - -/** - * Render an identity to its canonical pgpm change path (naming spec v1). - * Total: every identity gets a deterministic path. - */ -export function pathFor(identity: ObjectIdentity): string { - const { kind, name } = identity; - const schema = identity.schema ?? 'public'; - - if (kind === 'schema') return `schemas/${name}/schema`; - if (kind === 'extension') return `extensions/${name}`; - if (kind === 'role') return `roles/${name}`; - if (kind === 'table') return `schemas/${schema}/tables/${name}/table`; - - if (TABLE_SCOPED.has(kind)) { - const dir = TABLE_DIRS[kind]!; - if (identity.table && identity.table !== name) { - return `schemas/${schema}/tables/${identity.table}/${dir}/${name}`; - } - // Table-scoped object whose table equals the target (ALTER TABLE - // constraints, seed data keyed by table). - return `schemas/${schema}/tables/${identity.table ?? name}/${dir}/${name}`; - } - - const dir = SCHEMA_DIRS[kind]; - if (dir) return `schemas/${schema}/${dir}/${name}`; - return `schemas/${schema}/objects/${name}`; -} - -/** - * Convenience: canonical change path for a statement, or `null` when the - * statement has no identity of its own. - */ -export function changePathFor(facts: StatementFacts): string | null { - const identity = identityOf(facts); - return identity ? pathFor(identity) : null; -}