Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions pgpm/naming-spec/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# @pgpmjs/naming-spec

<p align="center" width="100%">
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
</p>

<p align="center" width="100%">
<a href="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml">
<img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
</a>
<a href="https://github.com/constructive-io/constructive/blob/main/LICENSE"><img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/></a>
<a href="https://www.npmjs.com/package/@pgpmjs/naming-spec"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=pgpm%2Fnaming-spec%2Fpackage.json"/></a>
</p>

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.
62 changes: 62 additions & 0 deletions pgpm/naming-spec/__tests__/naming-spec.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { alterationPathFor, ObjectIdentity, pathFor, PGPM_NAMING_SPEC_VERSION } from '../src';

const id = (partial: Partial<ObjectIdentity> & Pick<ObjectIdentity, 'kind' | 'name'>): 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 }));
});
});
18 changes: 18 additions & 0 deletions pgpm/naming-spec/jest.config.js
Original file line number Diff line number Diff line change
@@ -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/*']
};
42 changes: 42 additions & 0 deletions pgpm/naming-spec/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"name": "@pgpmjs/naming-spec",
"version": "0.0.1",
"author": "Constructive <developers@constructive.io>",
"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"
}
}
170 changes: 170 additions & 0 deletions pgpm/naming-spec/src/index.ts
Original file line number Diff line number Diff line change
@@ -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`):
* `<parent>/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<ObjectIdentityKind>([
'trigger',
'policy',
'index',
'constraint',
'seed_dml'
]);

/** Directory names for schema-scoped object kinds. */
const SCHEMA_DIRS: Partial<Record<ObjectIdentityKind, string>> = {
view: 'views',
sequence: 'sequences',
type: 'types',
function: 'procedures'
};

/** Directory names for table-scoped object kinds. */
const TABLE_DIRS: Partial<Record<ObjectIdentityKind, string>> = {
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<Record<ObjectIdentityKind, string>> = {
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):
* `<parent>/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')}`;
}
9 changes: 9 additions & 0 deletions pgpm/naming-spec/tsconfig.esm.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist/esm",
"module": "es2022",
"rootDir": "src/",
"declaration": false
}
}
9 changes: 9 additions & 0 deletions pgpm/naming-spec/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src/"
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules", "**/*.spec.*", "**/*.test.*"]
}
7 changes: 7 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading