diff --git a/pgpm/naming-spec/README.md b/pgpm/naming-spec/README.md new file mode 100644 index 000000000..287aa196f --- /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 000000000..5a7dad823 --- /dev/null +++ b/pgpm/naming-spec/__tests__/naming-spec.test.ts @@ -0,0 +1,62 @@ +import { alterationPathFor, 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 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/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/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_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'); + 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 000000000..057a9420e --- /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 000000000..106cf2ac1 --- /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 000000000..528c0d0d6 --- /dev/null +++ b/pgpm/naming-spec/src/index.ts @@ -0,0 +1,170 @@ +/** + * 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. + * + * 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} + * index schemas/{schema}/tables/{table}/indexes/{name} + * seed_dml schemas/{schema}/tables/{table}/fixtures/{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. */ +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; +} + +/** + * 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', + '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' +}; + +/** 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 per style. + */ +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}`; + 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]!; + const base = `schemas/${schema}/tables/${identity.table ?? name}/${dir}/${name}`; + return token ? `${base}/${token}` : base; + } + + const dir = SCHEMA_DIRS[kind]; + 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')}`; +} diff --git a/pgpm/naming-spec/tsconfig.esm.json b/pgpm/naming-spec/tsconfig.esm.json new file mode 100644 index 000000000..800d7506d --- /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 000000000..1a9d5696c --- /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 78c318f74..5c8583858 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':