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
100 changes: 100 additions & 0 deletions pgpm/core/__tests__/rebundle/workspace-deploy-integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { mkdirSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { dirname, join } from 'path';

import { PgpmMigrate } from '../../src/migrate/client';
import { rebundleWorkspace } from '../../src/rebundle';
import { MigrateTestFixture, teardownAllPools, TestDatabase } from '../../test-utils';

describe('rebundleWorkspace deploy integration', () => {
let fixture: MigrateTestFixture;
let db: TestDatabase;
let client: PgpmMigrate;
let tempDir: string;
let sourceDir: string;
let outputDir: string;

const PLAN = `%syntax-version=1.0.0
%project=shop
%uri=shop

schemas/auth/schema 2024-01-01T00:00:00Z Dev <dev@example.com> # schema
schemas/auth/tables/users [schemas/auth/schema] 2024-01-01T00:00:01Z Dev <dev@example.com> # users
schemas/billing/schema [schemas/auth/schema] 2024-01-01T00:00:02Z Dev <dev@example.com> # billing schema
schemas/billing/tables/invoices [schemas/billing/schema schemas/auth/tables/users] 2024-01-01T00:00:03Z Dev <dev@example.com> # invoices
`;

const DEPLOY: Record<string, string> = {
'schemas/auth/schema': 'CREATE SCHEMA auth;',
'schemas/auth/tables/users': 'CREATE TABLE auth.users (id int PRIMARY KEY);',
'schemas/billing/schema': 'CREATE SCHEMA billing;',
'schemas/billing/tables/invoices':
'CREATE TABLE billing.invoices (id int PRIMARY KEY, user_id int REFERENCES auth.users(id));',
};

const REVERT: Record<string, string> = {
'schemas/auth/schema': 'DROP SCHEMA auth CASCADE;',
'schemas/auth/tables/users': 'DROP TABLE auth.users;',
'schemas/billing/schema': 'DROP SCHEMA billing CASCADE;',
'schemas/billing/tables/invoices': 'DROP TABLE billing.invoices;',
};

function write(rel: string, content: string): void {
const file = join(sourceDir, rel);
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, content);
}

beforeEach(async () => {
fixture = new MigrateTestFixture();
db = await fixture.setupTestDatabase();
client = new PgpmMigrate(db.config);
tempDir = join(tmpdir(), `rb-ws-deploy-${Date.now()}`);
sourceDir = join(tempDir, 'source');
outputDir = join(tempDir, 'workspace');
mkdirSync(sourceDir, { recursive: true });
writeFileSync(join(sourceDir, 'pgpm.plan'), PLAN);
for (const [change, sql] of Object.entries(DEPLOY)) {
write(`deploy/${change}.sql`, `-- Deploy ${change}\nBEGIN;\n${sql}\nCOMMIT;\n`);
}
for (const [change, sql] of Object.entries(REVERT)) {
write(`revert/${change}.sql`, `-- Revert ${change}\nBEGIN;\n${sql}\nCOMMIT;\n`);
}
for (const change of Object.keys(DEPLOY)) {
write(`verify/${change}.sql`, `-- Verify ${change}\nBEGIN;\nSELECT 1;\nROLLBACK;\n`);
}
});

afterEach(async () => {
await fixture.cleanup();
try {
rmSync(tempDir, { recursive: true, force: true });
} catch (e) {
// ignore
}
});

afterAll(async () => {
await teardownAllPools();
});

test('control-only workspace deploys module-by-module in deploy order', async () => {
const result = await rebundleWorkspace(sourceDir, { outputDir, overwrite: true });
expect(result.invariant.ok).toBe(true);

for (const pkgName of result.deployOrder) {
const pkg = result.packages.find(p => p.name === pkgName)!;
const deployResult = await client.deploy({ modulePath: join(outputDir, pkg.dir) });
expect(deployResult.deployed.length).toBeGreaterThan(0);
}

expect(await db.exists('schema', 'auth')).toBe(true);
expect(await db.exists('schema', 'billing')).toBe(true);
expect(await db.exists('table', 'auth.users')).toBe(true);
expect(await db.exists('table', 'billing.invoices')).toBe(true);

// one merged change per chunk module
const deployed = await db.getDeployedChanges();
expect(deployed).toHaveLength(2);
});
});
141 changes: 141 additions & 0 deletions pgpm/core/__tests__/rebundle/workspace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { dirname, join } from 'path';

import { rebundleWorkspace } from '../../src/rebundle';

let sourceDir: string;
let outputDir: string;

const PLAN = `%syntax-version=1.0.0
%project=shop
%uri=shop

schemas/auth/schema 2024-01-01T00:00:00Z Dev <dev@example.com> # schema
schemas/auth/tables/users [schemas/auth/schema] 2024-01-01T00:00:01Z Dev <dev@example.com> # users
schemas/billing/schema [schemas/auth/schema] 2024-01-01T00:00:02Z Dev <dev@example.com> # billing schema
schemas/billing/tables/invoices [schemas/billing/schema schemas/auth/tables/users] 2024-01-01T00:00:03Z Dev <dev@example.com> # invoices
@v1.0.0 2024-01-01T00:00:04Z Dev <dev@example.com> # release
`;

const DEPLOY: Record<string, string> = {
'schemas/auth/schema': 'CREATE SCHEMA auth;',
'schemas/auth/tables/users': 'CREATE TABLE auth.users (id int PRIMARY KEY);',
'schemas/billing/schema': 'CREATE SCHEMA billing;',
'schemas/billing/tables/invoices':
'CREATE TABLE billing.invoices (id int PRIMARY KEY, user_id int REFERENCES auth.users(id));',
};

const REVERT: Record<string, string> = {
'schemas/auth/schema': 'DROP SCHEMA auth;',
'schemas/auth/tables/users': 'DROP TABLE auth.users;',
'schemas/billing/schema': 'DROP SCHEMA billing;',
'schemas/billing/tables/invoices': 'DROP TABLE billing.invoices;',
};

function write(rel: string, content: string): void {
const file = join(sourceDir, rel);
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, content);
}

beforeEach(() => {
sourceDir = mkdtempSync(join(tmpdir(), 'pgpm-ws-src-'));
outputDir = mkdtempSync(join(tmpdir(), 'pgpm-ws-out-'));
writeFileSync(join(sourceDir, 'pgpm.plan'), PLAN);
writeFileSync(join(sourceDir, 'shop.control'), `default_version = '0.0.1'\n`);
writeFileSync(join(sourceDir, 'package.json'), JSON.stringify({ name: 'shop', version: '0.0.1' }));

for (const [change, sql] of Object.entries(DEPLOY)) {
write(`deploy/${change}.sql`, `-- Deploy ${change}\nBEGIN;\n${sql}\nCOMMIT;\n`);
}
for (const [change, sql] of Object.entries(REVERT)) {
write(`revert/${change}.sql`, `-- Revert ${change}\nBEGIN;\n${sql}\nCOMMIT;\n`);
}
for (const change of Object.keys(DEPLOY)) {
write(`verify/${change}.sql`, `-- Verify ${change}\nBEGIN;\nSELECT 1;\nROLLBACK;\n`);
}
});

afterEach(() => {
rmSync(sourceDir, { recursive: true, force: true });
rmSync(outputDir, { recursive: true, force: true });
});

describe('rebundleWorkspace', () => {
it('emits one deployable module per chunk under packages/, in deploy order', async () => {
const result = await rebundleWorkspace(sourceDir, { outputDir, overwrite: true });

expect(result.packages.map(p => p.name)).toEqual(['auth', 'billing']);
for (const pkg of ['auth', 'billing']) {
const dir = join(outputDir, 'packages', pkg);
expect(existsSync(join(dir, 'deploy', `${pkg}.sql`))).toBe(true);
expect(existsSync(join(dir, 'revert', `${pkg}.sql`))).toBe(true);
expect(existsSync(join(dir, 'verify', `${pkg}.sql`))).toBe(true);
expect(existsSync(join(dir, `${pkg}.control`))).toBe(true);
expect(existsSync(join(dir, 'pgpm.plan'))).toBe(true);
expect(existsSync(join(dir, 'Makefile'))).toBe(true);
expect(existsSync(join(dir, 'package.json'))).toBe(true);
}
// pgpm discovers the emitted dir as a workspace via pgpm.json.
expect(existsSync(join(outputDir, 'pgpm.json'))).toBe(true);
const cfg = JSON.parse(readFileSync(join(outputDir, 'pgpm.json'), 'utf-8'));
expect(cfg.packages).toEqual(['packages/*']);
});

it('control-only (default): cross-chunk dep lives in control requires, not the plan', async () => {
await rebundleWorkspace(sourceDir, { outputDir, overwrite: true });

const billingControl = readFileSync(join(outputDir, 'packages', 'billing', 'billing.control'), 'utf-8');
expect(billingControl).toContain("requires = 'auth'");

const billingPlan = readFileSync(join(outputDir, 'packages', 'billing', 'pgpm.plan'), 'utf-8');
// Single change line, no plan cross-reference.
expect(billingPlan).toMatch(/^billing$/m);
expect(billingPlan).not.toContain('auth:');
});

it('change mode: cross-chunk dep also recorded as a plan cross-reference', async () => {
await rebundleWorkspace(sourceDir, { outputDir, overwrite: true, crossChunkDepMode: 'change' });

const billingControl = readFileSync(join(outputDir, 'packages', 'billing', 'billing.control'), 'utf-8');
expect(billingControl).toContain("requires = 'auth'");

const billingPlan = readFileSync(join(outputDir, 'packages', 'billing', 'pgpm.plan'), 'utf-8');
expect(billingPlan).toMatch(/^billing \[auth:auth\]/m);
});

it('auth module has no dependencies', async () => {
const result = await rebundleWorkspace(sourceDir, { outputDir, overwrite: true });
const auth = result.packages.find(p => p.name === 'auth')!;
expect(auth.dependencies).toEqual([]);
const authControl = readFileSync(join(outputDir, 'packages', 'auth', 'auth.control'), 'utf-8');
expect(authControl).not.toContain('requires =');
});

it('remaps tags onto the sealing chunk-module', async () => {
await rebundleWorkspace(sourceDir, { outputDir, overwrite: true });
// @v1.0.0 anchored invoices → last member of billing.
const billingPlan = readFileSync(join(outputDir, 'packages', 'billing', 'pgpm.plan'), 'utf-8');
expect(billingPlan).toContain('@v1.0.0');
});

it('is byte-identical: merged workspace deploy === merged source deploy', async () => {
const result = await rebundleWorkspace(sourceDir, { outputDir, overwrite: true });
expect(result.invariant.ok).toBe(true);
});

it('is byte-identical in change mode too', async () => {
const result = await rebundleWorkspace(sourceDir, {
outputDir,
overwrite: true,
crossChunkDepMode: 'change',
});
expect(result.invariant.ok).toBe(true);
});

it('refuses to overwrite a non-empty output dir without overwrite', async () => {
writeFileSync(join(outputDir, 'sentinel'), 'x');
await expect(rebundleWorkspace(sourceDir, { outputDir })).rejects.toThrow(/not empty/);
});
});
98 changes: 98 additions & 0 deletions pgpm/core/__tests__/workspace/minimal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';

import { modulePath, pgpmPath } from '../../src/workspace/paths';
import {
generateModuleControl,
generateModuleMakefile,
writeMinimalModule,
writeMinimalWorkspace,
} from '../../src/workspace/minimal';

let dir: string;

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'pgpm-minimal-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});

describe('writeMinimalWorkspace', () => {
it('writes a discoverable pgpm.json', () => {
writeMinimalWorkspace(dir);
expect(pgpmPath(dir)).toBe(dir);
expect(JSON.parse(readFileSync(join(dir, 'pgpm.json'), 'utf-8')).packages).toEqual(['packages/*']);
});

it('does not clobber an existing pgpm.json when overwrite: false', () => {
writeFileSync(join(dir, 'pgpm.json'), JSON.stringify({ packages: ['custom/*'] }));
writeMinimalWorkspace(dir, { overwrite: false });
expect(JSON.parse(readFileSync(join(dir, 'pgpm.json'), 'utf-8')).packages).toEqual(['custom/*']);
});
});

describe('writeMinimalModule', () => {
it('writes the minimal deployable module file set', () => {
const mod = join(dir, 'packages', 'users');
writeMinimalModule(mod, {
name: 'users',
changes: [{ name: 'users', dependencies: [] }],
scripts: {
users: {
deploy: '-- Deploy users\nBEGIN;\nCREATE SCHEMA users;\nCOMMIT;\n',
revert: '-- Revert users\nBEGIN;\nDROP SCHEMA users;\nCOMMIT;\n',
},
},
requires: ['auth'],
});

expect(modulePath(mod)).toBe(mod);
for (const f of ['pgpm.plan', 'users.control', 'Makefile', 'package.json', 'deploy/users.sql', 'revert/users.sql', 'verify/users.sql']) {
expect(existsSync(join(mod, f))).toBe(true);
}
// default verify emitted when none provided
expect(readFileSync(join(mod, 'verify', 'users.sql'), 'utf-8')).toContain('ROLLBACK;');
expect(readFileSync(join(mod, 'users.control'), 'utf-8')).toContain("requires = 'auth'");
});

it('throws on a missing script and refuses non-empty dirs', () => {
const mod = join(dir, 'packages', 'x');
expect(() =>
writeMinimalModule(mod, {
name: 'x',
changes: [{ name: 'x', dependencies: [] }],
scripts: {},
})
).toThrow(/Missing scripts/);

writeFileSync(join(dir, 'sentinel'), 'x');
expect(() =>
writeMinimalModule(dir, { name: 'x', changes: [], scripts: {} })
).toThrow(/not empty/);
});
});

describe('control + Makefile generators', () => {
it('emits full control fields matching pgpm init shape', () => {
const control = generateModuleControl('users', ['auth', 'citext']);
expect(control).toContain("comment = 'users extension'");
expect(control).toContain("default_version = '0.0.1'");
expect(control).toContain("module_pathname = '$libdir/users'");
expect(control).toContain("requires = 'auth,citext'");
expect(control).toContain('relocatable = false');
expect(control).toContain('superuser = false');
});

it('omits requires when there are no deps', () => {
expect(generateModuleControl('users')).not.toContain('requires =');
});

it('emits a PGXS Makefile', () => {
const mk = generateModuleMakefile('users');
expect(mk).toContain('EXTENSION = users');
expect(mk).toContain('DATA = sql/users--0.0.1.sql');
expect(mk).toContain('include $(PGXS)');
});
});
1 change: 1 addition & 0 deletions pgpm/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from './resolution/deps';
export * from './resolution/resolve';
export * from './workspace/paths';
export * from './workspace/utils';
export * from './workspace/minimal';
export * from './core/template-scaffold';
export * from './core/boilerplate-types';
export * from './core/boilerplate-scanner';
Expand Down
1 change: 1 addition & 0 deletions pgpm/core/src/rebundle/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './types';
export * from './rebundle';
export * from './module';
export * from './workspace';
Loading
Loading