From ad7756b457769cf97b8a2c2360f7f3bd322a6fb6 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 31 Jul 2026 04:46:18 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(naming-spec):=20@pgpmjs/naming-spec=20?= =?UTF-8?q?=E2=80=94=20PGPM=20naming=20spec=20v1=20(pathFor:=20derived=20c?= =?UTF-8?q?hange=20paths)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pgpm/naming-spec/README.md | 43 +++++++ .../naming-spec/__tests__/naming-spec.test.ts | 47 ++++++++ pgpm/naming-spec/jest.config.js | 18 +++ pgpm/naming-spec/package.json | 42 +++++++ pgpm/naming-spec/src/index.ts | 114 ++++++++++++++++++ pgpm/naming-spec/tsconfig.esm.json | 9 ++ pgpm/naming-spec/tsconfig.json | 9 ++ pnpm-lock.yaml | 7 ++ 8 files changed, 289 insertions(+) create mode 100644 pgpm/naming-spec/README.md create mode 100644 pgpm/naming-spec/__tests__/naming-spec.test.ts create mode 100644 pgpm/naming-spec/jest.config.js create mode 100644 pgpm/naming-spec/package.json create mode 100644 pgpm/naming-spec/src/index.ts create mode 100644 pgpm/naming-spec/tsconfig.esm.json create mode 100644 pgpm/naming-spec/tsconfig.json diff --git a/pgpm/naming-spec/README.md b/pgpm/naming-spec/README.md new file mode 100644 index 0000000000..287aa196f9 --- /dev/null +++ b/pgpm/naming-spec/README.md @@ -0,0 +1,43 @@ +# @pgpmjs/naming-spec + +

+ +

+ +

+ + + + + +

+ +The PGPM naming spec — 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). + +```ts +import { pathFor } from '@pgpmjs/naming-spec'; +import { classifyStatements, identityOf } from '@pgsql/transform'; + +const facts = classifyStatements('CREATE TABLE app.users (id int);')[0]; +pathFor(identityOf(facts)!); // 'schemas/app/tables/users/table' +``` + +Canonical templates (naming spec v1, the conventions used across constructive-db): + +| kind | path | +|------|------| +| schema | `schemas/{schema}/schema` | +| table | `schemas/{schema}/tables/{table}/table` | +| trigger / policy / index / constraint / seed | `schemas/{schema}/tables/{table}/{triggers\|policies\|indexes\|constraints\|fixtures}/{name}` | +| function / view / type / sequence | `schemas/{schema}/{procedures\|views\|types\|sequences}/{name}` | +| extension | `extensions/{name}` | +| role | `roles/{name}` | + +Pure leaf with zero dependencies. Identity derivation (`identityOf`) lives upstream +in `@pgsql/transform`; this package only renders identities to paths. diff --git a/pgpm/naming-spec/__tests__/naming-spec.test.ts b/pgpm/naming-spec/__tests__/naming-spec.test.ts new file mode 100644 index 0000000000..d3422e1021 --- /dev/null +++ b/pgpm/naming-spec/__tests__/naming-spec.test.ts @@ -0,0 +1,47 @@ +import { ObjectIdentity, pathFor, PGPM_NAMING_SPEC_VERSION } from '../src'; + +const id = (partial: Partial & Pick): ObjectIdentity => ({ + schema: 'app', + ...partial +}); + +describe('PGPM naming spec v1', () => { + it('declares its spec version', () => { + expect(PGPM_NAMING_SPEC_VERSION).toBe(1); + }); + + it('renders canonical paths per object kind', () => { + expect(pathFor(id({ kind: 'schema', schema: null, name: 'app' }))).toBe('schemas/app/schema'); + expect(pathFor(id({ kind: 'table', name: 'users' }))).toBe('schemas/app/tables/users/table'); + expect(pathFor(id({ kind: 'view', name: 'v_users' }))).toBe('schemas/app/views/v_users'); + expect(pathFor(id({ kind: 'function', name: 'fn' }))).toBe('schemas/app/procedures/fn'); + expect(pathFor(id({ kind: 'type', name: 'status' }))).toBe('schemas/app/types/status'); + expect(pathFor(id({ kind: 'sequence', name: 'seq' }))).toBe('schemas/app/sequences/seq'); + expect(pathFor(id({ kind: 'extension', schema: null, name: 'pgcrypto' }))).toBe('extensions/pgcrypto'); + expect(pathFor(id({ kind: 'role', schema: null, name: 'admin' }))).toBe('roles/admin'); + }); + + it('scopes table-owned objects under the table', () => { + expect(pathFor(id({ kind: 'trigger', name: 'trg', table: 'users' }))) + .toBe('schemas/app/tables/users/triggers/trg'); + expect(pathFor(id({ kind: 'policy', name: 'p', table: 'users' }))) + .toBe('schemas/app/tables/users/policies/p'); + expect(pathFor(id({ kind: 'index', name: 'users_email_idx', table: 'users' }))) + .toBe('schemas/app/tables/users/indexes/users_email_idx'); + expect(pathFor(id({ kind: 'constraint', name: 'users', table: 'users' }))) + .toBe('schemas/app/tables/users/constraints/users'); + expect(pathFor(id({ kind: 'seed_dml', name: 'users', table: 'users' }))) + .toBe('schemas/app/tables/users/fixtures/users'); + }); + + it('defaults missing schema to public and unknown kinds to objects/', () => { + expect(pathFor(id({ kind: 'table', schema: null, name: 'users' }))) + .toBe('schemas/public/tables/users/table'); + expect(pathFor(id({ kind: 'other', name: 'thing' }))).toBe('schemas/app/objects/thing'); + }); + + it('is a pure projection: same identity, same path, no state', () => { + const identity = id({ kind: 'table', name: 'users' }); + expect(pathFor(identity)).toBe(pathFor({ ...identity })); + }); +}); diff --git a/pgpm/naming-spec/jest.config.js b/pgpm/naming-spec/jest.config.js new file mode 100644 index 0000000000..057a9420ed --- /dev/null +++ b/pgpm/naming-spec/jest.config.js @@ -0,0 +1,18 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + babelConfig: false, + tsconfig: 'tsconfig.json', + }, + ], + }, + transformIgnorePatterns: [`/node_modules/*`], + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + modulePathIgnorePatterns: ['dist/*'] +}; diff --git a/pgpm/naming-spec/package.json b/pgpm/naming-spec/package.json new file mode 100644 index 0000000000..106cf2ac13 --- /dev/null +++ b/pgpm/naming-spec/package.json @@ -0,0 +1,42 @@ +{ + "name": "@pgpmjs/naming-spec", + "version": "0.0.1", + "author": "Constructive ", + "description": "PGPM naming spec — canonical, derived change paths rendered from object identity; paths are projections, never authored and never identity", + "main": "index.js", + "module": "esm/index.js", + "types": "index.d.ts", + "homepage": "https://github.com/constructive-io/constructive", + "license": "MIT", + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/constructive-io/constructive" + }, + "bugs": { + "url": "https://github.com/constructive-io/constructive/issues" + }, + "scripts": { + "clean": "makage clean", + "prepack": "npm run build", + "build": "makage build", + "build:dev": "makage build --dev", + "lint": "eslint . --fix", + "test": "jest --passWithNoTests", + "test:watch": "jest --watch" + }, + "keywords": [ + "pgpm", + "pgpmjs", + "naming", + "spec", + "postgresql", + "migrations" + ], + "devDependencies": { + "makage": "^0.3.0" + } +} diff --git a/pgpm/naming-spec/src/index.ts b/pgpm/naming-spec/src/index.ts new file mode 100644 index 0000000000..24031b2366 --- /dev/null +++ b/pgpm/naming-spec/src/index.ts @@ -0,0 +1,114 @@ +/** + * 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). + * + * The identity itself is produced upstream (`identityOf` in + * `@pgsql/transform`, a pure function of classifier facts). This package only + * renders identities to paths; the `ObjectIdentity` shape below is + * structurally identical to the upstream type, so either can be passed. + * + * Canonical templates (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} + */ + +/** 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. + * Structurally identical to `ObjectIdentity` in `@pgsql/transform`. + */ +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' +}; + +/** + * Render an identity to its canonical pgpm change path (naming spec v1). + * Total and deterministic: every identity gets exactly one 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]!; + 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}`; +} diff --git a/pgpm/naming-spec/tsconfig.esm.json b/pgpm/naming-spec/tsconfig.esm.json new file mode 100644 index 0000000000..800d7506d3 --- /dev/null +++ b/pgpm/naming-spec/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/esm", + "module": "es2022", + "rootDir": "src/", + "declaration": false + } +} diff --git a/pgpm/naming-spec/tsconfig.json b/pgpm/naming-spec/tsconfig.json new file mode 100644 index 0000000000..1a9d5696cb --- /dev/null +++ b/pgpm/naming-spec/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src/" + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "**/*.spec.*", "**/*.test.*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78c318f741..5c8583858f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3051,6 +3051,13 @@ importers: version: 0.3.0 publishDirectory: dist + pgpm/naming-spec: + devDependencies: + makage: + specifier: ^0.3.0 + version: 0.3.0 + publishDirectory: dist + pgpm/portability: dependencies: '@pgpmjs/core': From 392e89bf7d811307eef0a1105cea9ab07af41a6f Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 31 Jul 2026 04:50:56 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(naming-spec):=20align=20with=20db=5Fde?= =?UTF-8?q?ps=20reference=20implementation=20=E2=80=94=20directory/flat=20?= =?UTF-8?q?styles=20+=20alterationPathFor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../naming-spec/__tests__/naming-spec.test.ts | 29 +++++-- pgpm/naming-spec/src/index.ts | 76 ++++++++++++++++--- 2 files changed, 88 insertions(+), 17 deletions(-) diff --git a/pgpm/naming-spec/__tests__/naming-spec.test.ts b/pgpm/naming-spec/__tests__/naming-spec.test.ts index d3422e1021..5a7dad8236 100644 --- a/pgpm/naming-spec/__tests__/naming-spec.test.ts +++ b/pgpm/naming-spec/__tests__/naming-spec.test.ts @@ -1,4 +1,4 @@ -import { ObjectIdentity, pathFor, PGPM_NAMING_SPEC_VERSION } from '../src'; +import { alterationPathFor, ObjectIdentity, pathFor, PGPM_NAMING_SPEC_VERSION } from '../src'; const id = (partial: Partial & Pick): ObjectIdentity => ({ schema: 'app', @@ -10,30 +10,45 @@ describe('PGPM naming spec v1', () => { expect(PGPM_NAMING_SPEC_VERSION).toBe(1); }); - it('renders canonical paths per object kind', () => { + it('renders canonical directory-style paths per object kind (db_deps parity)', () => { expect(pathFor(id({ kind: 'schema', schema: null, name: 'app' }))).toBe('schemas/app/schema'); expect(pathFor(id({ kind: 'table', name: 'users' }))).toBe('schemas/app/tables/users/table'); - expect(pathFor(id({ kind: 'view', name: 'v_users' }))).toBe('schemas/app/views/v_users'); - expect(pathFor(id({ kind: 'function', name: 'fn' }))).toBe('schemas/app/procedures/fn'); + expect(pathFor(id({ kind: 'view', name: 'v_users' }))).toBe('schemas/app/views/v_users/view'); + expect(pathFor(id({ kind: 'function', name: 'fn' }))).toBe('schemas/app/procedures/fn/procedure'); expect(pathFor(id({ kind: 'type', name: 'status' }))).toBe('schemas/app/types/status'); expect(pathFor(id({ kind: 'sequence', name: 'seq' }))).toBe('schemas/app/sequences/seq'); expect(pathFor(id({ kind: 'extension', schema: null, name: 'pgcrypto' }))).toBe('extensions/pgcrypto'); expect(pathFor(id({ kind: 'role', schema: null, name: 'admin' }))).toBe('roles/admin'); }); + it('renders flat-style paths for hand-authored layouts', () => { + const flat = { style: 'flat' as const }; + expect(pathFor(id({ kind: 'function', name: 'fn' }), flat)).toBe('schemas/app/procedures/fn'); + expect(pathFor(id({ kind: 'view', name: 'v_users' }), flat)).toBe('schemas/app/views/v_users'); + expect(pathFor(id({ kind: 'policy', name: 'p', table: 'users' }), flat)) + .toBe('schemas/app/tables/users/policies/p'); + }); + it('scopes table-owned objects under the table', () => { expect(pathFor(id({ kind: 'trigger', name: 'trg', table: 'users' }))) .toBe('schemas/app/tables/users/triggers/trg'); expect(pathFor(id({ kind: 'policy', name: 'p', table: 'users' }))) - .toBe('schemas/app/tables/users/policies/p'); + .toBe('schemas/app/tables/users/policies/p/policy'); expect(pathFor(id({ kind: 'index', name: 'users_email_idx', table: 'users' }))) .toBe('schemas/app/tables/users/indexes/users_email_idx'); - expect(pathFor(id({ kind: 'constraint', name: 'users', table: 'users' }))) - .toBe('schemas/app/tables/users/constraints/users'); + expect(pathFor(id({ kind: 'constraint', name: 'users_pkey', table: 'users' }))) + .toBe('schemas/app/tables/users/constraints/users_pkey/constraint'); expect(pathFor(id({ kind: 'seed_dml', name: 'users', table: 'users' }))) .toBe('schemas/app/tables/users/fixtures/users'); }); + it('numbers re-alterations like db_deps.next_alteration', () => { + const parent = 'schemas/app/tables/users/table'; + expect(alterationPathFor(parent, 1)).toBe('schemas/app/tables/users/table/alterations/alt0000000001'); + expect(alterationPathFor(alterationPathFor(parent, 1), 2)) + .toBe('schemas/app/tables/users/table/alterations/alt0000000002'); + }); + it('defaults missing schema to public and unknown kinds to objects/', () => { expect(pathFor(id({ kind: 'table', schema: null, name: 'users' }))) .toBe('schemas/public/tables/users/table'); diff --git a/pgpm/naming-spec/src/index.ts b/pgpm/naming-spec/src/index.ts index 24031b2366..528c0d0d65 100644 --- a/pgpm/naming-spec/src/index.ts +++ b/pgpm/naming-spec/src/index.ts @@ -12,22 +12,37 @@ * renders identities to paths; the `ObjectIdentity` shape below is * structurally identical to the upstream type, so either can be passed. * - * Canonical templates (the conventions used across constructive-db deploy - * trees): + * The reference implementation of these templates is constructive-db's + * `db_deps` SQL package (`db_deps.table_deps`, `db_deps.column_deps`, + * `db_deps.next_alteration`, ...); this package is the TypeScript rendering + * of the same spec, so paths derived in SQL and in TS agree byte-for-byte. + * + * Canonical templates (naming spec v1, `directory` style — matching + * `db_deps.*` and the generated `application/constructive` plan): * * schema schemas/{schema}/schema * table schemas/{schema}/tables/{table}/table + * column schemas/{schema}/tables/{table}/columns/{name}/column + * constraint schemas/{schema}/tables/{table}/constraints/{name}/constraint + * policy schemas/{schema}/tables/{table}/policies/{name}/policy + * rls schemas/{schema}/tables/{table}/policies/enable_row_level_security * 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} + * function schemas/{schema}/procedures/{name}/procedure + * view schemas/{schema}/views/{name}/view * type schemas/{schema}/types/{name} * sequence schemas/{schema}/sequences/{name} * extension extensions/{name} * role roles/{name} + * + * `flat` style drops the trailing kind token for functions/views/columns/ + * constraints/policies (`schemas/{s}/procedures/{n}`) — the convention used + * by hand-authored constructive-db packages (`db_deps.fn_deps`). + * + * Conflicts/re-alterations: when the same object is altered again, the spec + * appends a monotonically numbered alteration segment (`db_deps.next_alteration`): + * `/alterations/alt0000000042` — see {@link alterationPathFor}. */ /** Spec version, so bundles/modules can declare which scheme derived their paths. */ @@ -64,6 +79,21 @@ export interface ObjectIdentity { table?: string; } +/** + * Rendering style. + * + * - `directory` — every object is a directory closed by a kind token + * (`.../procedures/{n}/procedure`), as produced by `db_deps.*` and the + * generated `application/constructive` plan. The default. + * - `flat` — schema-scoped objects are leaf files (`.../procedures/{n}`), + * as used by hand-authored constructive-db packages. + */ +export type PathStyle = 'directory' | 'flat'; + +export interface PathForOptions { + style?: PathStyle; +} + /** Kinds whose objects are scoped to (and only unique within) a table. */ const TABLE_SCOPED = new Set([ 'trigger', @@ -90,13 +120,23 @@ const TABLE_DIRS: Partial> = { seed_dml: 'fixtures' }; +/** Trailing kind tokens in `directory` style (mirrors `db_deps.*_deps`). */ +const KIND_TOKENS: Partial> = { + function: 'procedure', + view: 'view', + policy: 'policy', + constraint: 'constraint' +}; + /** * Render an identity to its canonical pgpm change path (naming spec v1). - * Total and deterministic: every identity gets exactly one path. + * Total and deterministic: every identity gets exactly one path per style. */ -export function pathFor(identity: ObjectIdentity): string { +export function pathFor(identity: ObjectIdentity, options: PathForOptions = {}): string { + const style = options.style ?? 'directory'; const { kind, name } = identity; const schema = identity.schema ?? 'public'; + const token = style === 'directory' ? KIND_TOKENS[kind] : undefined; if (kind === 'schema') return `schemas/${name}/schema`; if (kind === 'extension') return `extensions/${name}`; @@ -105,10 +145,26 @@ export function pathFor(identity: ObjectIdentity): string { if (TABLE_SCOPED.has(kind)) { const dir = TABLE_DIRS[kind]!; - return `schemas/${schema}/tables/${identity.table ?? name}/${dir}/${name}`; + const base = `schemas/${schema}/tables/${identity.table ?? name}/${dir}/${name}`; + return token ? `${base}/${token}` : base; } const dir = SCHEMA_DIRS[kind]; - if (dir) return `schemas/${schema}/${dir}/${name}`; + if (dir) { + const base = `schemas/${schema}/${dir}/${name}`; + return token ? `${base}/${token}` : base; + } return `schemas/${schema}/objects/${name}`; } + +/** + * Path for the nth re-alteration of an object (the conflict convention): + * `/alterations/alt0000000042`, mirroring `db_deps.next_alteration`. + * Any existing alteration suffix on `parent` is stripped first, so the same + * parent can be renumbered. Sequencing (the counter) is the caller's state; + * this renders deterministically from `(parent, n)`. + */ +export function alterationPathFor(parent: string, n: number, prefix = 'alt'): string { + const stripped = parent.replace(new RegExp(`/alterations/${prefix}[0-9]+$`), ''); + return `${stripped}/alterations/${prefix}${String(n).padStart(10, '0')}`; +}