From 30ffa94191caa495e4049fd46c5b433ec46dd5c5 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 22 Jul 2026 05:21:19 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(pgpm-core):=20rebundleWorkspace=20?= =?UTF-8?q?=E2=80=94=20emit=20chunks=20as=20separate=20modules=20with=20co?= =?UTF-8?q?ntrol-only=20cross-chunk=20deps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/__tests__/rebundle/workspace.test.ts | 136 ++++++++++++++ pgpm/core/src/rebundle/index.ts | 1 + pgpm/core/src/rebundle/module.ts | 26 ++- pgpm/core/src/rebundle/types.ts | 68 +++++++ pgpm/core/src/rebundle/workspace.ts | 168 ++++++++++++++++++ 5 files changed, 396 insertions(+), 3 deletions(-) create mode 100644 pgpm/core/__tests__/rebundle/workspace.test.ts create mode 100644 pgpm/core/src/rebundle/workspace.ts diff --git a/pgpm/core/__tests__/rebundle/workspace.test.ts b/pgpm/core/__tests__/rebundle/workspace.test.ts new file mode 100644 index 000000000..58ea05f2e --- /dev/null +++ b/pgpm/core/__tests__/rebundle/workspace.test.ts @@ -0,0 +1,136 @@ +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 # schema +schemas/auth/tables/users [schemas/auth/schema] 2024-01-01T00:00:01Z Dev # users +schemas/billing/schema [schemas/auth/schema] 2024-01-01T00:00:02Z Dev # billing schema +schemas/billing/tables/invoices [schemas/billing/schema schemas/auth/tables/users] 2024-01-01T00:00:03Z Dev # invoices +@v1.0.0 2024-01-01T00:00:04Z Dev # release +`; + +const DEPLOY: Record = { + '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 = { + '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 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(outputDir, 'pgpm-workspace.json'))).toBe(true); + }); + + 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/); + }); +}); diff --git a/pgpm/core/src/rebundle/index.ts b/pgpm/core/src/rebundle/index.ts index dd4667ff7..b6e0f08a6 100644 --- a/pgpm/core/src/rebundle/index.ts +++ b/pgpm/core/src/rebundle/index.ts @@ -1,3 +1,4 @@ export * from './types'; export * from './rebundle'; export * from './module'; +export * from './workspace'; diff --git a/pgpm/core/src/rebundle/module.ts b/pgpm/core/src/rebundle/module.ts index 44ffd756a..25482867e 100644 --- a/pgpm/core/src/rebundle/module.ts +++ b/pgpm/core/src/rebundle/module.ts @@ -6,9 +6,10 @@ import { parsePlanFile } from '../files/plan/parser'; import { mergeSqlStatements, packageModule } from '../packaging/package'; import { generatePlanContent } from '../slice/slice'; import { assembleChunkSql, rebundlePlan } from './rebundle'; -import { RebundleModuleOptions, RebundleModuleResult } from './types'; +import { Chunk, RebundleModuleOptions, RebundleModuleResult } from './types'; -const SCRIPT_DIRS = ['deploy', 'revert', 'verify'] as const; +export const SCRIPT_DIRS = ['deploy', 'revert', 'verify'] as const; +export type ScriptDir = (typeof SCRIPT_DIRS)[number]; /** * Wrap a merged chunk body in a single transaction. Deploy/revert commit; @@ -16,12 +17,31 @@ const SCRIPT_DIRS = ['deploy', 'revert', 'verify'] as const; * member files' own BEGIN/COMMIT were stripped during the merge, so each * rebundled chunk deploys as exactly one transaction. */ -function wrapTransaction(body: string, scriptType: (typeof SCRIPT_DIRS)[number]): string { +function wrapTransaction(body: string, scriptType: ScriptDir): string { const trimmed = body.trim(); const tail = scriptType === 'verify' ? 'ROLLBACK;' : 'COMMIT;'; return `BEGIN;\n\n${trimmed}\n\n${tail}\n`; } +/** + * Merge one chunk's member scripts (in the correct order per direction) into a + * single deparsed, single-transaction migration string. Shared by the + * single-module (`rebundleModule`) and workspace (`rebundleWorkspace`) emitters. + */ +export async function mergeChunkScript( + sourceDir: string, + chunk: Chunk, + scriptType: ScriptDir, + opts: { pretty?: boolean; functionDelimiter?: string } = {} +): Promise { + const merged = await mergeSqlStatements(assembleChunkSql(sourceDir, chunk, scriptType), { + stripTransactions: true, + pretty: opts.pretty ?? true, + functionDelimiter: opts.functionDelimiter ?? '$EOFCODE$', + }); + return wrapTransaction(merged.sql, scriptType); +} + /** * Copy any top-level files (control, Makefile, package.json, README, ...) from * the source module, excluding the plan and the per-change script directories diff --git a/pgpm/core/src/rebundle/types.ts b/pgpm/core/src/rebundle/types.ts index 456bca25e..0fa2fff99 100644 --- a/pgpm/core/src/rebundle/types.ts +++ b/pgpm/core/src/rebundle/types.ts @@ -92,6 +92,74 @@ export interface RebundleModuleOptions extends RebundleStrategy { functionDelimiter?: string; } +/** + * How cross-chunk dependencies are represented when chunks are emitted as + * separate modules in a workspace. + * + * - `change`: also record the fine-grained plan cross-reference (`dep:dep`) in + * addition to the control `requires`. + * - `control-only`: carry the dependency solely via the control-file `requires`, + * dropping the per-change plan reference. Extension install ordering deploys + * all of the dependency before any of the dependent, which is strictly + * stronger than the fine edge — the right mode for publishing chunks as + * independent, versioned module forks. + */ +export type CrossChunkDepMode = 'change' | 'control-only'; + +/** + * Options for materializing a rebundled workspace (one module per chunk). + */ +export interface RebundleWorkspaceOptions extends RebundleStrategy { + /** Directory to write the workspace into (packages land in `packages//`) */ + outputDir: string; + + /** Overwrite an existing, non-empty output directory (default: false) */ + overwrite?: boolean; + + /** Deparse pretty-printing (default: true) */ + pretty?: boolean; + + /** Function body delimiter used during deparse (default: '$EOFCODE$') */ + functionDelimiter?: string; + + /** How cross-chunk deps are represented (default: 'control-only') */ + crossChunkDepMode?: CrossChunkDepMode; +} + +/** + * A single emitted chunk-module within a rebundled workspace. + */ +export interface RebundlePackage { + /** Chunk/module name (also the extension name) */ + name: string; + + /** Package directory, relative to the workspace outputDir */ + dir: string; + + /** Names of other chunk-modules this one depends on */ + dependencies: string[]; +} + +/** + * Result of materializing a rebundled workspace. + */ +export interface RebundleWorkspaceResult extends RebundleResult { + /** Directory the workspace was written to */ + outputDir: string; + + /** The emitted chunk-modules, in deploy order */ + packages: RebundlePackage[]; + + /** The dep representation used */ + crossChunkDepMode: CrossChunkDepMode; + + /** + * Byte-identical gate: the merged concatenation of all package deploy scripts + * (in workspace deploy order) equals the merged original module deploy output. + */ + invariant: { ok: boolean }; +} + /** * Result of materializing a rebundled module. */ diff --git a/pgpm/core/src/rebundle/workspace.ts b/pgpm/core/src/rebundle/workspace.ts new file mode 100644 index 000000000..912e28436 --- /dev/null +++ b/pgpm/core/src/rebundle/workspace.ts @@ -0,0 +1,168 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +import { Change, Tag } from '../files/types'; +import { parsePlanFile } from '../files/plan/parser'; +import { mergeSqlStatements } from '../packaging/package'; +import { resolveWithPlan } from '../resolution/resolve'; +import { generateControlContent, generatePlanContent } from '../slice/slice'; +import { mergeChunkScript, SCRIPT_DIRS } from './module'; +import { rebundlePlan } from './rebundle'; +import { + CrossChunkDepMode, + RebundlePackage, + RebundleWorkspaceOptions, + RebundleWorkspaceResult, +} from './types'; + +/** + * Materialize a rebundled workspace: emit each chunk as its own tiny module + * (one merged change) under `packages//`, carrying cross-chunk deps via + * the control-file `requires` (and optionally a plan cross-reference). + * + * This is the "modular workspace" carve — the same monolith sliced into + * independently publishable module forks (e.g. a users module, an admin module). + * It reuses `rebundlePlan` for the chunk model, `mergeChunkScript` for the + * per-chunk merge, and slice's `generatePlanContent`/`generateControlContent` + * for plan and control emission. + */ +export async function rebundleWorkspace( + sourceDir: string, + options: RebundleWorkspaceOptions +): Promise { + const { + outputDir, + overwrite = false, + pretty = true, + functionDelimiter = '$EOFCODE$', + crossChunkDepMode = 'control-only', + ...strategy + } = options; + + if (existsSync(outputDir) && readdirSync(outputDir).length > 0 && !overwrite) { + throw new Error(`Output directory is not empty: ${outputDir}. Pass overwrite: true to replace.`); + } + + const plan = rebundlePlan(sourceDir, strategy); + + // Remap tags onto their sealing chunk (each tag anchored a chunk boundary). + const parsed = parsePlanFile(join(sourceDir, 'pgpm.plan')); + const sourceTags: Tag[] = parsed.data?.tags ?? []; + const memberToChunk = new Map(); + for (const chunk of plan.chunks) { + for (const member of chunk.deploy) memberToChunk.set(member, chunk.name); + } + const remappedTags: Tag[] = sourceTags.map(tag => ({ + ...tag, + change: memberToChunk.get(tag.change) ?? tag.change, + })); + + mkdirSync(join(outputDir, 'packages'), { recursive: true }); + + const byDeployOrder = [...plan.chunks].sort( + (a, b) => plan.deployOrder.indexOf(a.name) - plan.deployOrder.indexOf(b.name) + ); + + const packages: RebundlePackage[] = []; + + for (const chunk of byDeployOrder) { + const pkgRel = join('packages', chunk.name); + const pkgDir = join(outputDir, pkgRel); + for (const dir of SCRIPT_DIRS) mkdirSync(join(pkgDir, dir), { recursive: true }); + + for (const dir of SCRIPT_DIRS) { + const script = await mergeChunkScript(sourceDir, chunk, dir, { pretty, functionDelimiter }); + writeFileSync(join(pkgDir, dir, `${chunk.name}.sql`), script); + } + + // Control-file requires always carry the module-level dependency; in + // 'change' mode the plan additionally records the fine-grained cross-ref. + const deps = chunk.dependencies; + writeFileSync( + join(pkgDir, `${chunk.name}.control`), + generateControlContent(chunk.name, new Set(deps)) + ); + + const planChange: Change = { + name: chunk.name, + dependencies: planChangeDeps(deps, crossChunkDepMode), + }; + const pkgTags = remappedTags.filter(t => t.change === chunk.name); + writeFileSync( + join(pkgDir, 'pgpm.plan'), + generatePlanContent(chunk.name, [planChange], pkgTags) + ); + + writeFileSync( + join(pkgDir, 'package.json'), + JSON.stringify({ name: chunk.name, version: '0.0.1' }, null, 2) + '\n' + ); + + packages.push({ name: chunk.name, dir: pkgRel, dependencies: [...deps] }); + } + + writeWorkspaceManifest(outputDir, plan.project, packages, plan.deployOrder); + + const invariant = { ok: await checkWorkspaceInvariant(sourceDir, outputDir, packages, { pretty, functionDelimiter }) }; + + return { ...plan, outputDir, packages, crossChunkDepMode, invariant }; +} + +/** + * In 'change' mode, express each cross-chunk dep as a plan cross-reference + * (`:`, since each chunk-module has a single change named after it); + * in 'control-only' mode the plan carries no deps — the control `requires` + * governs ordering. + */ +function planChangeDeps(deps: string[], mode: CrossChunkDepMode): string[] { + if (mode === 'control-only') return []; + return deps.map(dep => `${dep}:${dep}`); +} + +function writeWorkspaceManifest( + outputDir: string, + project: string, + packages: RebundlePackage[], + deployOrder: string[] +): void { + const manifest = { + project, + packages: packages.map(p => p.dir), + rebundle: { + deployOrder, + dependencies: Object.fromEntries(packages.map(p => [p.name, p.dependencies])), + }, + }; + writeFileSync(join(outputDir, 'pgpm-workspace.json'), JSON.stringify(manifest, null, 2) + '\n'); +} + +/** + * Byte-identical gate for a workspace: concatenate every package's deploy + * script in workspace deploy order, merge it, and compare against the merged + * original module deploy output. Both go through the same `mergeSqlStatements` + * pipeline, so equality proves the carve preserved the deployed statements and + * their order. + */ +async function checkWorkspaceInvariant( + sourceDir: string, + outputDir: string, + packages: RebundlePackage[], + opts: { pretty: boolean; functionDelimiter: string } +): Promise { + const concatenated = packages + .map(p => readFileSync(join(outputDir, p.dir, 'deploy', `${p.name}.sql`), 'utf-8')) + .join('\n'); + + const workspaceMerged = await mergeSqlStatements(concatenated, { + stripTransactions: true, + pretty: opts.pretty, + functionDelimiter: opts.functionDelimiter, + }); + const sourceMerged = await mergeSqlStatements(resolveWithPlan(sourceDir, 'deploy'), { + stripTransactions: true, + pretty: opts.pretty, + functionDelimiter: opts.functionDelimiter, + }); + + return workspaceMerged.sql === sourceMerged.sql; +} From 7d758a459672fbc800b56b4a522c25b9d20770ef Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 22 Jul 2026 05:46:04 +0000 Subject: [PATCH 2/2] feat(pgpm-core): emit real deployable workspace via writeMinimalWorkspace/writeMinimalModule --- .../workspace-deploy-integration.test.ts | 100 ++++++++++++++ .../core/__tests__/rebundle/workspace.test.ts | 9 +- pgpm/core/__tests__/workspace/minimal.test.ts | 98 +++++++++++++ pgpm/core/src/index.ts | 1 + pgpm/core/src/rebundle/workspace.ts | 71 ++++------ pgpm/core/src/workspace/minimal.ts | 130 ++++++++++++++++++ 6 files changed, 361 insertions(+), 48 deletions(-) create mode 100644 pgpm/core/__tests__/rebundle/workspace-deploy-integration.test.ts create mode 100644 pgpm/core/__tests__/workspace/minimal.test.ts create mode 100644 pgpm/core/src/workspace/minimal.ts diff --git a/pgpm/core/__tests__/rebundle/workspace-deploy-integration.test.ts b/pgpm/core/__tests__/rebundle/workspace-deploy-integration.test.ts new file mode 100644 index 000000000..18be93014 --- /dev/null +++ b/pgpm/core/__tests__/rebundle/workspace-deploy-integration.test.ts @@ -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 # schema +schemas/auth/tables/users [schemas/auth/schema] 2024-01-01T00:00:01Z Dev # users +schemas/billing/schema [schemas/auth/schema] 2024-01-01T00:00:02Z Dev # billing schema +schemas/billing/tables/invoices [schemas/billing/schema schemas/auth/tables/users] 2024-01-01T00:00:03Z Dev # invoices +`; + + const DEPLOY: Record = { + '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 = { + '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); + }); +}); diff --git a/pgpm/core/__tests__/rebundle/workspace.test.ts b/pgpm/core/__tests__/rebundle/workspace.test.ts index 58ea05f2e..c7dc763df 100644 --- a/pgpm/core/__tests__/rebundle/workspace.test.ts +++ b/pgpm/core/__tests__/rebundle/workspace.test.ts @@ -63,7 +63,7 @@ afterEach(() => { }); describe('rebundleWorkspace', () => { - it('emits one module per chunk under packages/, in deploy order', async () => { + 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']); @@ -74,8 +74,13 @@ describe('rebundleWorkspace', () => { 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); } - expect(existsSync(join(outputDir, 'pgpm-workspace.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 () => { diff --git a/pgpm/core/__tests__/workspace/minimal.test.ts b/pgpm/core/__tests__/workspace/minimal.test.ts new file mode 100644 index 000000000..a49290c9c --- /dev/null +++ b/pgpm/core/__tests__/workspace/minimal.test.ts @@ -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)'); + }); +}); diff --git a/pgpm/core/src/index.ts b/pgpm/core/src/index.ts index 094badea9..2309e0341 100644 --- a/pgpm/core/src/index.ts +++ b/pgpm/core/src/index.ts @@ -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'; diff --git a/pgpm/core/src/rebundle/workspace.ts b/pgpm/core/src/rebundle/workspace.ts index 912e28436..b338b01ec 100644 --- a/pgpm/core/src/rebundle/workspace.ts +++ b/pgpm/core/src/rebundle/workspace.ts @@ -1,12 +1,12 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'; +import { existsSync, readdirSync, readFileSync } from 'fs'; import { join } from 'path'; import { Change, Tag } from '../files/types'; import { parsePlanFile } from '../files/plan/parser'; import { mergeSqlStatements } from '../packaging/package'; import { resolveWithPlan } from '../resolution/resolve'; -import { generateControlContent, generatePlanContent } from '../slice/slice'; -import { mergeChunkScript, SCRIPT_DIRS } from './module'; +import { writeMinimalModule, writeMinimalWorkspace } from '../workspace/minimal'; +import { mergeChunkScript } from './module'; import { rebundlePlan } from './rebundle'; import { CrossChunkDepMode, @@ -22,9 +22,11 @@ import { * * This is the "modular workspace" carve — the same monolith sliced into * independently publishable module forks (e.g. a users module, an admin module). - * It reuses `rebundlePlan` for the chunk model, `mergeChunkScript` for the - * per-chunk merge, and slice's `generatePlanContent`/`generateControlContent` - * for plan and control emission. + * The emitted directory is a real, discoverable pgpm workspace: it reuses + * `writeMinimalWorkspace`/`writeMinimalModule` (the minimal deployable file set + * pgpm recognizes — `pgpm.json`, `pgpm.plan`, `.control`, `Makefile`, + * `package.json`), `rebundlePlan` for the chunk model, and `mergeChunkScript` + * for the per-chunk merge. */ export async function rebundleWorkspace( sourceDir: string, @@ -57,7 +59,7 @@ export async function rebundleWorkspace( change: memberToChunk.get(tag.change) ?? tag.change, })); - mkdirSync(join(outputDir, 'packages'), { recursive: true }); + writeMinimalWorkspace(outputDir, { packages: ['packages/*'] }); const byDeployOrder = [...plan.chunks].sort( (a, b) => plan.deployOrder.indexOf(a.name) - plan.deployOrder.indexOf(b.name) @@ -68,41 +70,35 @@ export async function rebundleWorkspace( for (const chunk of byDeployOrder) { const pkgRel = join('packages', chunk.name); const pkgDir = join(outputDir, pkgRel); - for (const dir of SCRIPT_DIRS) mkdirSync(join(pkgDir, dir), { recursive: true }); - for (const dir of SCRIPT_DIRS) { - const script = await mergeChunkScript(sourceDir, chunk, dir, { pretty, functionDelimiter }); - writeFileSync(join(pkgDir, dir, `${chunk.name}.sql`), script); - } + const scripts: Record = { + [chunk.name]: { + deploy: await mergeChunkScript(sourceDir, chunk, 'deploy', { pretty, functionDelimiter }), + revert: await mergeChunkScript(sourceDir, chunk, 'revert', { pretty, functionDelimiter }), + verify: await mergeChunkScript(sourceDir, chunk, 'verify', { pretty, functionDelimiter }), + }, + }; - // Control-file requires always carry the module-level dependency; in + // Control `requires` always carries the module-level dependency; in // 'change' mode the plan additionally records the fine-grained cross-ref. const deps = chunk.dependencies; - writeFileSync( - join(pkgDir, `${chunk.name}.control`), - generateControlContent(chunk.name, new Set(deps)) - ); - const planChange: Change = { name: chunk.name, dependencies: planChangeDeps(deps, crossChunkDepMode), }; - const pkgTags = remappedTags.filter(t => t.change === chunk.name); - writeFileSync( - join(pkgDir, 'pgpm.plan'), - generatePlanContent(chunk.name, [planChange], pkgTags) - ); - writeFileSync( - join(pkgDir, 'package.json'), - JSON.stringify({ name: chunk.name, version: '0.0.1' }, null, 2) + '\n' - ); + writeMinimalModule(pkgDir, { + name: chunk.name, + changes: [planChange], + scripts, + tags: remappedTags.filter(t => t.change === chunk.name), + requires: deps, + overwrite: true, + }); packages.push({ name: chunk.name, dir: pkgRel, dependencies: [...deps] }); } - writeWorkspaceManifest(outputDir, plan.project, packages, plan.deployOrder); - const invariant = { ok: await checkWorkspaceInvariant(sourceDir, outputDir, packages, { pretty, functionDelimiter }) }; return { ...plan, outputDir, packages, crossChunkDepMode, invariant }; @@ -119,23 +115,6 @@ function planChangeDeps(deps: string[], mode: CrossChunkDepMode): string[] { return deps.map(dep => `${dep}:${dep}`); } -function writeWorkspaceManifest( - outputDir: string, - project: string, - packages: RebundlePackage[], - deployOrder: string[] -): void { - const manifest = { - project, - packages: packages.map(p => p.dir), - rebundle: { - deployOrder, - dependencies: Object.fromEntries(packages.map(p => [p.name, p.dependencies])), - }, - }; - writeFileSync(join(outputDir, 'pgpm-workspace.json'), JSON.stringify(manifest, null, 2) + '\n'); -} - /** * Byte-identical gate for a workspace: concatenate every package's deploy * script in workspace deploy order, merge it, and compare against the merged diff --git a/pgpm/core/src/workspace/minimal.ts b/pgpm/core/src/workspace/minimal.ts new file mode 100644 index 000000000..b58f7100e --- /dev/null +++ b/pgpm/core/src/workspace/minimal.ts @@ -0,0 +1,130 @@ +import fs from 'fs'; +import path from 'path'; + +import { Change, Tag } from '../files/types'; +import { generatePlanContent } from '../slice/slice'; + +const SCRIPT_DIRS = ['deploy', 'revert', 'verify'] as const; +export type MinimalScriptDir = (typeof SCRIPT_DIRS)[number]; + +/** + * A single change's SQL scripts for a minimal module. `verify` is optional. + */ +export interface MinimalChangeScripts { + deploy: string; + revert: string; + verify?: string; +} + +export interface WriteMinimalModuleOptions { + /** Module (extension) name */ + name: string; + + /** Plan changes, in order. Each must have a matching entry in `scripts`. */ + changes: Change[]; + + /** SQL scripts keyed by change name */ + scripts: Record; + + /** Tags to include in the plan */ + tags?: Tag[]; + + /** Extensions/modules this module requires (control `requires`) */ + requires?: string[]; + + /** package.json version (default '0.0.1') */ + version?: string; + + /** Overwrite an existing directory (default: false) */ + overwrite?: boolean; +} + +/** + * The minimal set of files pgpm needs to recognize and deploy a module: + * `pgpm.plan` (module marker + change order), `.control` (extension + * metadata), `Makefile` (PGXS packaging), `package.json`, and the + * deploy/revert/verify scripts. Mirrors what `pgpm init` scaffolds, minus the + * network-fetched boilerplate (README/LICENSE/jest/tests/lint configs). + */ +export function writeMinimalModule(moduleDir: string, options: WriteMinimalModuleOptions): void { + const { name, changes, scripts, tags = [], requires = [], version = '0.0.1', overwrite = false } = options; + + if (fs.existsSync(moduleDir) && fs.readdirSync(moduleDir).length > 0 && !overwrite) { + throw new Error(`Module directory is not empty: ${moduleDir}. Pass overwrite: true to replace.`); + } + + for (const dir of SCRIPT_DIRS) { + fs.mkdirSync(path.join(moduleDir, dir), { recursive: true }); + } + + for (const change of changes) { + const changeScripts = scripts[change.name]; + if (!changeScripts) { + throw new Error(`Missing scripts for change "${change.name}" in module "${name}"`); + } + fs.writeFileSync(path.join(moduleDir, 'deploy', `${change.name}.sql`), changeScripts.deploy); + fs.writeFileSync(path.join(moduleDir, 'revert', `${change.name}.sql`), changeScripts.revert); + fs.writeFileSync( + path.join(moduleDir, 'verify', `${change.name}.sql`), + changeScripts.verify ?? `-- Verify ${change.name}\n\nBEGIN;\n\nSELECT 1;\n\nROLLBACK;\n` + ); + } + + fs.writeFileSync(path.join(moduleDir, 'pgpm.plan'), generatePlanContent(name, changes, tags)); + fs.writeFileSync(path.join(moduleDir, `${name}.control`), generateModuleControl(name, requires, version)); + fs.writeFileSync(path.join(moduleDir, 'Makefile'), generateModuleMakefile(name, version)); + fs.writeFileSync( + path.join(moduleDir, 'package.json'), + JSON.stringify({ name, version, description: `${name} module`, license: 'MIT' }, null, 2) + '\n' + ); +} + +export interface WriteMinimalWorkspaceOptions { + /** Workspace package globs (default: ['packages/*']) */ + packages?: string[]; + + /** Overwrite an existing pgpm.json (default: true) */ + overwrite?: boolean; +} + +/** + * Write the minimal workspace marker (`pgpm.json`) so pgpm discovers the + * directory as a workspace. This is what `getWorkspacePath` walks up to find. + */ +export function writeMinimalWorkspace(workspaceDir: string, options: WriteMinimalWorkspaceOptions = {}): void { + const { packages = ['packages/*'], overwrite = true } = options; + const configPath = path.join(workspaceDir, 'pgpm.json'); + if (fs.existsSync(configPath) && !overwrite) return; + fs.mkdirSync(workspaceDir, { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ packages }, null, 2) + '\n'); +} + +/** + * Full extension control file, matching the shape `pgpm init` emits (comment, + * default_version, module_pathname, requires, relocatable, superuser). + */ +export function generateModuleControl(name: string, requires: string[] = [], version = '0.0.1'): string { + let content = `# ${name} extension\n`; + content += `comment = '${name} extension'\n`; + content += `default_version = '${version}'\n`; + content += `module_pathname = '$libdir/${name}'\n`; + if (requires.length > 0) { + content += `requires = '${requires.join(',')}'\n`; + } + content += `relocatable = false\n`; + content += `superuser = false\n`; + return content; +} + +/** + * PGXS Makefile for `pgpm package`/`make install`. + */ +export function generateModuleMakefile(name: string, version = '0.0.1'): string { + return ( + `EXTENSION = ${name}\n` + + `DATA = sql/${name}--${version}.sql\n\n` + + `PG_CONFIG = pg_config\n` + + `PGXS := $(shell $(PG_CONFIG) --pgxs)\n` + + `include $(PGXS)\n` + ); +}