From 9b1a6ca7819caed75e9eb4cb27b27963ae77d72d Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 30 Jul 2026 00:28:43 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(bundle):=20splitBundle=20=E2=80=94=20p?= =?UTF-8?q?artition=20a=20bundle=20into=20shared=20+=20per-tenant=20module?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits a MigrationBundle along a change-level partition (from @pgpmjs/slice's partitionModule): shared changes keep their identity as a module deployed once; per-tenant changes become a second module whose references to shared changes are rewritten into cross-module (:) dependencies in both the plan and each script's -- requires: header, with the shared module added to control requires. Digests recomputed so both bundles verify independently. Rejects unsound partitions where a shared change depends on a per-tenant change. --- pgpm/bundle/__tests__/split.test.ts | 183 +++++++++++++++++++++ pgpm/bundle/src/index.ts | 1 + pgpm/bundle/src/split.ts | 237 ++++++++++++++++++++++++++++ 3 files changed, 421 insertions(+) create mode 100644 pgpm/bundle/__tests__/split.test.ts create mode 100644 pgpm/bundle/src/split.ts diff --git a/pgpm/bundle/__tests__/split.test.ts b/pgpm/bundle/__tests__/split.test.ts new file mode 100644 index 0000000000..fd1a490bc7 --- /dev/null +++ b/pgpm/bundle/__tests__/split.test.ts @@ -0,0 +1,183 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { dirname, join } from 'path'; + +import { bundleFromModule, splitBundle, verifyBundle } from '../src'; + +let sourceDir: string; + +// catalog: a schema, a pure helper (slugify), a tenant table (products), and a +// function that reads the table + calls the helper. Partition seed = products, +// so products + product_slug are per-tenant; schema + slugify are shared. +const PLAN = `%syntax-version=1.0.0 +%project=catalog +%uri=catalog + +schemas/catalog/schema 2024-01-01T00:00:00Z Dev # add schema +schemas/catalog/functions/slugify [schemas/catalog/schema] 2024-01-01T00:00:01Z Dev # add slugify +schemas/catalog/tables/products [schemas/catalog/schema] 2024-01-01T00:00:02Z Dev # add products +schemas/catalog/functions/product_slug [schemas/catalog/schema schemas/catalog/functions/slugify schemas/catalog/tables/products] 2024-01-01T00:00:03Z Dev # add product_slug +`; + +const DEPLOY: Record = { + 'schemas/catalog/schema': 'CREATE SCHEMA catalog;', + 'schemas/catalog/functions/slugify': + 'CREATE FUNCTION catalog.slugify(t text) RETURNS text AS $$ SELECT lower(t) $$ LANGUAGE sql IMMUTABLE;', + 'schemas/catalog/tables/products': + 'CREATE TABLE catalog.products (id serial PRIMARY KEY, name text);', + 'schemas/catalog/functions/product_slug': + 'CREATE FUNCTION catalog.product_slug() RETURNS text AS $$ SELECT catalog.slugify(name) FROM catalog.products LIMIT 1 $$ LANGUAGE sql STABLE;' +}; + +const REQUIRES: Record = { + 'schemas/catalog/functions/slugify': ['schemas/catalog/schema'], + 'schemas/catalog/tables/products': ['schemas/catalog/schema'], + 'schemas/catalog/functions/product_slug': [ + 'schemas/catalog/schema', + 'schemas/catalog/functions/slugify', + 'schemas/catalog/tables/products' + ] +}; + +function write(rel: string, content: string): void { + const file = join(sourceDir, rel); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, content); +} + +function header(change: string, verb: string): string { + const reqs = (REQUIRES[change] ?? []).map(r => `-- requires: ${r}`).join('\n'); + return `-- ${verb} ${change}\n${reqs ? reqs + '\n' : ''}`; +} + +beforeEach(() => { + sourceDir = mkdtempSync(join(tmpdir(), 'pgpm-split-src-')); + writeFileSync(join(sourceDir, 'pgpm.plan'), PLAN); + writeFileSync( + join(sourceDir, 'catalog.control'), + `# catalog\ncomment = 'catalog'\ndefault_version = '0.0.1'\nrequires = 'plpgsql'\n` + ); + for (const [change, sql] of Object.entries(DEPLOY)) { + write(`deploy/${change}.sql`, `${header(change, 'Deploy')}BEGIN;\n${sql}\nCOMMIT;\n`); + write(`revert/${change}.sql`, `${header(change, 'Revert')}BEGIN;\nDROP THING;\nCOMMIT;\n`); + } +}); + +afterEach(() => { + rmSync(sourceDir, { recursive: true, force: true }); +}); + +describe('splitBundle', () => { + const PER_TENANT = [ + 'schemas/catalog/tables/products', + 'schemas/catalog/functions/product_slug' + ]; + + it('partitions changes into shared and per-tenant modules', () => { + const src = bundleFromModule(sourceDir); + const { shared, perTenant } = splitBundle(src, { + perTenantChanges: PER_TENANT, + sharedName: 'catalog-shared', + perTenantName: 'catalog-tenant' + }); + + expect(shared.manifest.name).toBe('catalog-shared'); + expect(shared.manifest.deployOrder).toEqual([ + 'schemas/catalog/schema', + 'schemas/catalog/functions/slugify' + ]); + expect(perTenant.manifest.name).toBe('catalog-tenant'); + expect(perTenant.manifest.deployOrder).toEqual(PER_TENANT); + }); + + it('rewrites per-tenant dependencies on shared changes to cross-module refs', () => { + const src = bundleFromModule(sourceDir); + const { perTenant } = splitBundle(src, { + perTenantChanges: PER_TENANT, + sharedName: 'catalog-shared', + perTenantName: 'catalog-tenant' + }); + + const productSlug = perTenant.changes.find( + c => c.name === 'schemas/catalog/functions/product_slug' + )!; + // shared deps become cross-module; the per-tenant dep stays local + expect(productSlug.dependencies).toEqual([ + 'catalog-shared:schemas/catalog/schema', + 'catalog-shared:schemas/catalog/functions/slugify', + 'schemas/catalog/tables/products' + ]); + // the header is rewritten to match + expect(productSlug.deploy!.sql).toContain( + '-- requires: catalog-shared:schemas/catalog/functions/slugify' + ); + expect(productSlug.deploy!.sql).toContain('-- requires: schemas/catalog/tables/products'); + // plan carries the cross-module ref too + expect(perTenant.plan).toContain('catalog-shared:schemas/catalog/functions/slugify'); + }); + + it('renames plan project and control, and adds the shared module to requires', () => { + const src = bundleFromModule(sourceDir); + const { shared, perTenant } = splitBundle(src, { + perTenantChanges: PER_TENANT, + sharedName: 'catalog-shared', + perTenantName: 'catalog-tenant' + }); + + expect(shared.plan).toContain('%project=catalog-shared'); + expect(shared.control!.fileName).toBe('catalog-shared.control'); + expect(perTenant.plan).toContain('%project=catalog-tenant'); + expect(perTenant.control!.fileName).toBe('catalog-tenant.control'); + expect(perTenant.control!.content).toMatch(/requires = 'plpgsql,catalog-shared'/); + // the shared side gets no extra require + expect(shared.control!.content).toMatch(/requires = 'plpgsql'/); + }); + + it('produces independently verifiable bundles with recomputed digests', () => { + const src = bundleFromModule(sourceDir); + const { shared, perTenant } = splitBundle(src, { + perTenantChanges: PER_TENANT, + sharedName: 'catalog-shared', + perTenantName: 'catalog-tenant' + }); + + expect(verifyBundle(shared)).toEqual([]); + expect(verifyBundle(perTenant)).toEqual([]); + // splitting is deterministic + const again = splitBundle(src, { + perTenantChanges: PER_TENANT, + sharedName: 'catalog-shared', + perTenantName: 'catalog-tenant' + }); + expect(again.shared.manifest.digest).toBe(shared.manifest.digest); + expect(again.perTenant.manifest.digest).toBe(perTenant.manifest.digest); + }); + + it('rejects a partition where a shared change depends on a per-tenant change', () => { + const src = bundleFromModule(sourceDir); + // slugify is shared but we mark only products per-tenant; make slugify + // "shared" while it depends on a per-tenant change by seeding schema. + expect(() => + splitBundle(src, { + // products is per-tenant, but schema (which products depends on) is + // shared — that's fine. To trip the guard, mark slugify per-tenant and + // leave product_slug shared: product_slug (shared) would depend on + // slugify (per-tenant). + perTenantChanges: ['schemas/catalog/functions/slugify'], + sharedName: 'catalog-shared', + perTenantName: 'catalog-tenant' + }) + ).toThrow(/shared change .* depends on per-tenant change/); + }); + + it('throws when a per-tenant change is not in the bundle', () => { + const src = bundleFromModule(sourceDir); + expect(() => + splitBundle(src, { + perTenantChanges: ['schemas/catalog/tables/nope'], + sharedName: 'catalog-shared', + perTenantName: 'catalog-tenant' + }) + ).toThrow(/not in the bundle/); + }); +}); diff --git a/pgpm/bundle/src/index.ts b/pgpm/bundle/src/index.ts index 683f25607b..f351397f0f 100644 --- a/pgpm/bundle/src/index.ts +++ b/pgpm/bundle/src/index.ts @@ -16,6 +16,7 @@ export * from './diff'; export * from './envelope'; export * from './io'; export * from './reconcile'; +export * from './split'; export * from './transpile'; export * from './types'; export * from './verify'; diff --git a/pgpm/bundle/src/split.ts b/pgpm/bundle/src/split.ts new file mode 100644 index 0000000000..4a5818cce7 --- /dev/null +++ b/pgpm/bundle/src/split.ts @@ -0,0 +1,237 @@ +import { hashString } from '@pgpmjs/ast'; +import { parsePlanContent } from '@pgpmjs/ast/files/plan/parser'; +import { generatePlanFileContent } from '@pgpmjs/ast/files/plan/writer'; +import { parsePgpmHeader, renameInHeader, writePgpmScript } from '@pgpmjs/ast/files/sql/header'; +import { Change, ExtendedPlanFile } from '@pgpmjs/ast/files/types'; + +import { computeBundleDigest, computeChangeDigest } from './create'; +import { BundleChange, BundleScript, MigrationBundle } from './types'; + +/** + * Options for {@link splitBundle}. + */ +export interface SplitBundleOptions { + /** + * Change names that must be materialized per tenant (typically the + * `perTenant` set computed by `@pgpmjs/slice`'s `partitionModule`). Every + * other change in the bundle is treated as shared. + */ + perTenantChanges: Iterable; + /** Module name for the shared bundle (deployed once, reused by all tenants). */ + sharedName: string; + /** Module name for the per-tenant bundle. */ + perTenantName: string; +} + +/** + * The two bundles a {@link splitBundle} produces. + */ +export interface SplitBundleResult { + /** Tenant-independent changes, as their own deployable module. */ + shared: MigrationBundle; + /** + * Per-tenant changes, as their own module. Dependencies that pointed at a + * shared change are rewritten to cross-module references + * (`:`), and the shared module is added to the control + * `requires` so it deploys first. + */ + perTenant: MigrationBundle; +} + +/** Cross-module rewrite context handed to {@link buildSubBundle}. */ +interface CrossModuleRefs { + sharedName: string; + isShared: (change: string) => boolean; +} + +/** + * Split a {@link MigrationBundle} into a shared bundle and a per-tenant bundle + * along a change-level partition (see `@pgpmjs/slice`). + * + * Pure and deterministic (no I/O). The shared changes keep their identity and + * become a module deployed once; the per-tenant changes become a second module + * whose references to shared changes are rewritten into cross-module + * (`:`) dependencies — in both the plan and each script's + * `-- requires:` header — with the shared module added to its control + * `requires`. Digests are recomputed so both bundles are independently + * verifiable. + * + * @throws when a per-tenant name is not in the bundle, or when a shared change + * depends on a per-tenant change (an unsound partition — a shared object must + * never require tenant-specific state). + */ +export function splitBundle( + bundle: MigrationBundle, + options: SplitBundleOptions +): SplitBundleResult { + const perTenant = new Set(options.perTenantChanges); + const names = new Set(bundle.changes.map(c => c.name)); + for (const name of perTenant) { + if (!names.has(name)) { + throw new Error(`splitBundle: per-tenant change "${name}" is not in the bundle`); + } + } + + const isShared = (name: string): boolean => !perTenant.has(name); + + for (const change of bundle.changes) { + if (!isShared(change.name)) continue; + for (const dep of change.dependencies) { + if (perTenant.has(dep)) { + throw new Error( + `splitBundle: shared change "${change.name}" depends on per-tenant change "${dep}"; ` + + `a shared object cannot require tenant-specific state (unsound partition)` + ); + } + } + } + + const shared = buildSubBundle(bundle, isShared, options.sharedName, null); + const perTenantBundle = buildSubBundle( + bundle, + name => perTenant.has(name), + options.perTenantName, + { sharedName: options.sharedName, isShared } + ); + + return { shared, perTenant: perTenantBundle }; +} + +/** Build one side of the split: the changes matching `include`, as a module. */ +function buildSubBundle( + bundle: MigrationBundle, + include: (name: string) => boolean, + moduleName: string, + cross: CrossModuleRefs | null +): MigrationBundle { + // Rename map applied to per-tenant scripts: a bare reference to a shared + // change becomes a cross-module reference `:`. + const crossRename = cross + ? new Map( + bundle.changes + .filter(c => cross.isShared(c.name)) + .map(c => [c.name, `${cross.sharedName}:${c.name}`]) + ) + : null; + + const changes: BundleChange[] = bundle.changes + .filter(c => include(c.name)) + .map(change => { + const dependencies = change.dependencies.map(dep => + cross && cross.isShared(dep) ? `${cross.sharedName}:${dep}` : dep + ); + const rewrite = (script: BundleScript | null): BundleScript | null => { + if (!script) return null; + if (!crossRename || crossRename.size === 0) return script; + const parsed = parsePgpmHeader(script.sql); + if (renameInHeader(parsed, crossRename) === 0) return script; + const sql = writePgpmScript(parsed); + return { kind: script.kind, sql, digest: hashString(sql) }; + }; + const deploy = rewrite(change.deploy); + const revert = rewrite(change.revert); + const verify = rewrite(change.verify); + const digest = computeChangeDigest(change.name, { + deploy: deploy?.digest, + revert: revert?.digest, + verify: verify?.digest + }); + return { name: change.name, dependencies, deploy, revert, verify, digest }; + }); + + const plan = buildPlan(bundle.plan, moduleName, include, cross); + + let control = bundle.control + ? { fileName: `${moduleName}.control`, content: bundle.control.content } + : null; + if (control && cross) { + control = { fileName: control.fileName, content: addControlRequire(control.content, cross.sharedName) }; + } + + const digest = computeBundleDigest( + plan, + control?.content ?? null, + changes.map(c => c.digest) + ); + + return { + manifest: { + formatVersion: bundle.manifest.formatVersion, + name: moduleName, + createdWith: bundle.manifest.createdWith, + changeCount: changes.length, + deployOrder: changes.map(c => c.name), + digest, + provenance: { + ...(bundle.manifest.provenance ?? {}), + splitFrom: bundle.manifest.name + } + }, + plan, + control, + changes + }; +} + +/** + * Rebuild a plan for one side of the split: keep only the included changes (and + * their tags), rewrite the project identity, and cross-reference shared + * dependencies for the per-tenant side. + */ +function buildPlan( + planContent: string, + moduleName: string, + include: (name: string) => boolean, + cross: CrossModuleRefs | null +): string { + const parsed = parsePlanContent(planContent); + if (!parsed.data) { + const detail = parsed.errors?.map(e => `Line ${e.line}: ${e.message}`).join('; ') || 'unknown'; + throw new Error(`splitBundle: could not parse source plan: ${detail}`); + } + const source = parsed.data; + + const changes: Change[] = source.changes + .filter(c => include(c.name)) + .map(c => ({ + ...c, + dependencies: (c.dependencies ?? []).map(dep => + cross && cross.isShared(dep) ? `${cross.sharedName}:${dep}` : dep + ) + })); + + const kept = new Set(changes.map(c => c.name)); + const tags = source.tags.filter(t => kept.has(t.change)); + + const plan: ExtendedPlanFile = { + ...source, + package: moduleName, + uri: moduleName, + changes, + tags + }; + + return generatePlanFileContent(plan); +} + +/** + * Add a module to a `.control` file's comma-separated `requires`, creating the + * line when absent. Idempotent — an already-present requirement is left alone. + */ +function addControlRequire(content: string, requireName: string): string { + const lines = content.split('\n'); + const idx = lines.findIndex(l => /^\s*requires\s*=/.test(l)); + if (idx === -1) { + return `${content.replace(/\n?$/, '\n')}requires = '${requireName}'\n`; + } + const match = lines[idx].match(/^(\s*requires\s*=\s*')([^']*)(')(.*)$/); + if (!match) return content; + const existing = match[2] + .split(',') + .map(s => s.trim()) + .filter(Boolean); + if (existing.includes(requireName)) return content; + existing.push(requireName); + lines[idx] = `${match[1]}${existing.join(',')}${match[3]}${match[4]}`; + return lines.join('\n'); +} From 9bca30eaa1efaf6797c833ba1044accaa6d5c8d4 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 30 Jul 2026 01:03:51 +0000 Subject: [PATCH 2/2] feat(pgpm): expand a reuse proxy into shared + per-tenant modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the classifier-driven partition (pgpm/slice) into apply. A pgpm.apply.json may declare reuse: { sharedSchema, perTenant[] }; the proxy then expands into two ordinary pgpm modules — a shared module (objects the classifier proves tenant-independent, deployed once) and a per-tenant module that requires it (objects reachable from the seeds, materialized per instance). - apply-spec: validate the reuse declaration (shared schema map, seed objects, kind allowlist, per-tenant/shared target coverage). - reuse.ts: partitionModule + single object-routed transpile (shared objects -> shared schema, per-tenant -> tenant schema, cross-boundary refs preserved) + per-tenant CREATE SCHEMA bootstrap + splitBundle + verify + materialize. Deterministic shared module name so multiple tenants dedup to one shared deployment. - addApplyModules: synthesize the shared module entry once and add it to each tenant proxy's requires; resolveEffectiveModulePath returns the correct half by name. - split.ts: perTenantBootstrap support (inject/replace the tenant schema). - two-tenant DB e2e: shared helper deployed once, table per-tenant, tenant-reading fn stays per-tenant, verify passes, revert is reference-safe. --- .../reuse/packages/catalog/catalog.control | 7 + .../catalog/functions/product_slug.sql | 13 + .../schemas/catalog/functions/slugify.sql | 11 + .../catalog/deploy/schemas/catalog/schema.sql | 7 + .../schemas/catalog/tables/products/table.sql | 12 + .../apply/reuse/packages/catalog/pgpm.plan | 8 + .../catalog/functions/product_slug.sql | 7 + .../schemas/catalog/functions/slugify.sql | 7 + .../catalog/revert/schemas/catalog/schema.sql | 7 + .../schemas/catalog/tables/products/table.sql | 7 + .../catalog/functions/product_slug.sql | 7 + .../schemas/catalog/functions/slugify.sql | 7 + .../catalog/verify/schemas/catalog/schema.sql | 7 + .../schemas/catalog/tables/products/table.sql | 7 + .../reuse/packages/tenant-a/pgpm.apply.json | 14 + .../reuse/packages/tenant-b/pgpm.apply.json | 14 + __fixtures__/apply/reuse/pgpm.json | 5 + pgpm/bundle/__tests__/split.test.ts | 77 ++++- pgpm/bundle/src/split.ts | 167 ++++++++-- pgpm/core/__tests__/apply/apply-reuse.test.ts | 241 ++++++++++++++ pgpm/core/src/apply/apply-spec.ts | 79 ++++- pgpm/core/src/apply/index.ts | 1 + pgpm/core/src/apply/materialize.ts | 19 +- pgpm/core/src/apply/reuse.ts | 307 ++++++++++++++++++ pgpm/core/src/apply/types.ts | 46 +++ pgpm/core/src/core/class/pgpm.ts | 32 +- 26 files changed, 1059 insertions(+), 57 deletions(-) create mode 100644 __fixtures__/apply/reuse/packages/catalog/catalog.control create mode 100644 __fixtures__/apply/reuse/packages/catalog/deploy/schemas/catalog/functions/product_slug.sql create mode 100644 __fixtures__/apply/reuse/packages/catalog/deploy/schemas/catalog/functions/slugify.sql create mode 100644 __fixtures__/apply/reuse/packages/catalog/deploy/schemas/catalog/schema.sql create mode 100644 __fixtures__/apply/reuse/packages/catalog/deploy/schemas/catalog/tables/products/table.sql create mode 100644 __fixtures__/apply/reuse/packages/catalog/pgpm.plan create mode 100644 __fixtures__/apply/reuse/packages/catalog/revert/schemas/catalog/functions/product_slug.sql create mode 100644 __fixtures__/apply/reuse/packages/catalog/revert/schemas/catalog/functions/slugify.sql create mode 100644 __fixtures__/apply/reuse/packages/catalog/revert/schemas/catalog/schema.sql create mode 100644 __fixtures__/apply/reuse/packages/catalog/revert/schemas/catalog/tables/products/table.sql create mode 100644 __fixtures__/apply/reuse/packages/catalog/verify/schemas/catalog/functions/product_slug.sql create mode 100644 __fixtures__/apply/reuse/packages/catalog/verify/schemas/catalog/functions/slugify.sql create mode 100644 __fixtures__/apply/reuse/packages/catalog/verify/schemas/catalog/schema.sql create mode 100644 __fixtures__/apply/reuse/packages/catalog/verify/schemas/catalog/tables/products/table.sql create mode 100644 __fixtures__/apply/reuse/packages/tenant-a/pgpm.apply.json create mode 100644 __fixtures__/apply/reuse/packages/tenant-b/pgpm.apply.json create mode 100644 __fixtures__/apply/reuse/pgpm.json create mode 100644 pgpm/core/__tests__/apply/apply-reuse.test.ts create mode 100644 pgpm/core/src/apply/reuse.ts diff --git a/__fixtures__/apply/reuse/packages/catalog/catalog.control b/__fixtures__/apply/reuse/packages/catalog/catalog.control new file mode 100644 index 0000000000..8b5abe1431 --- /dev/null +++ b/__fixtures__/apply/reuse/packages/catalog/catalog.control @@ -0,0 +1,7 @@ +# catalog extension +comment = 'catalog extension' +default_version = '0.0.1' +module_pathname = '$libdir/catalog' +requires = 'plpgsql' +relocatable = false +superuser = false diff --git a/__fixtures__/apply/reuse/packages/catalog/deploy/schemas/catalog/functions/product_slug.sql b/__fixtures__/apply/reuse/packages/catalog/deploy/schemas/catalog/functions/product_slug.sql new file mode 100644 index 0000000000..8953f38369 --- /dev/null +++ b/__fixtures__/apply/reuse/packages/catalog/deploy/schemas/catalog/functions/product_slug.sql @@ -0,0 +1,13 @@ +-- Deploy schemas/catalog/functions/product_slug to pg + +-- requires: schemas/catalog/schema +-- requires: schemas/catalog/functions/slugify +-- requires: schemas/catalog/tables/products/table + +BEGIN; + +CREATE FUNCTION catalog.product_slug(pid uuid) RETURNS text AS $$ + SELECT catalog.slugify(name) FROM catalog.products WHERE id = pid; +$$ LANGUAGE sql STABLE; + +COMMIT; diff --git a/__fixtures__/apply/reuse/packages/catalog/deploy/schemas/catalog/functions/slugify.sql b/__fixtures__/apply/reuse/packages/catalog/deploy/schemas/catalog/functions/slugify.sql new file mode 100644 index 0000000000..73ec693768 --- /dev/null +++ b/__fixtures__/apply/reuse/packages/catalog/deploy/schemas/catalog/functions/slugify.sql @@ -0,0 +1,11 @@ +-- Deploy schemas/catalog/functions/slugify to pg + +-- requires: schemas/catalog/schema + +BEGIN; + +CREATE FUNCTION catalog.slugify(input text) RETURNS text AS $$ + SELECT lower(regexp_replace(input, '\s+', '-', 'g')); +$$ LANGUAGE sql IMMUTABLE; + +COMMIT; diff --git a/__fixtures__/apply/reuse/packages/catalog/deploy/schemas/catalog/schema.sql b/__fixtures__/apply/reuse/packages/catalog/deploy/schemas/catalog/schema.sql new file mode 100644 index 0000000000..9199c223af --- /dev/null +++ b/__fixtures__/apply/reuse/packages/catalog/deploy/schemas/catalog/schema.sql @@ -0,0 +1,7 @@ +-- Deploy schemas/catalog/schema to pg + +BEGIN; + +CREATE SCHEMA catalog; + +COMMIT; diff --git a/__fixtures__/apply/reuse/packages/catalog/deploy/schemas/catalog/tables/products/table.sql b/__fixtures__/apply/reuse/packages/catalog/deploy/schemas/catalog/tables/products/table.sql new file mode 100644 index 0000000000..72996c4d6f --- /dev/null +++ b/__fixtures__/apply/reuse/packages/catalog/deploy/schemas/catalog/tables/products/table.sql @@ -0,0 +1,12 @@ +-- Deploy schemas/catalog/tables/products/table to pg + +-- requires: schemas/catalog/schema + +BEGIN; + +CREATE TABLE catalog.products ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL +); + +COMMIT; diff --git a/__fixtures__/apply/reuse/packages/catalog/pgpm.plan b/__fixtures__/apply/reuse/packages/catalog/pgpm.plan new file mode 100644 index 0000000000..1b432f47ed --- /dev/null +++ b/__fixtures__/apply/reuse/packages/catalog/pgpm.plan @@ -0,0 +1,8 @@ +%syntax-version=1.0.0 +%project=catalog +%uri=catalog + +schemas/catalog/schema 2024-01-01T00:00:00Z Dev # add catalog schema +schemas/catalog/functions/slugify [schemas/catalog/schema] 2024-01-01T00:00:01Z Dev # add slugify helper +schemas/catalog/tables/products/table [schemas/catalog/schema] 2024-01-01T00:00:02Z Dev # add products table +schemas/catalog/functions/product_slug [schemas/catalog/schema schemas/catalog/functions/slugify schemas/catalog/tables/products/table] 2024-01-01T00:00:03Z Dev # add product_slug diff --git a/__fixtures__/apply/reuse/packages/catalog/revert/schemas/catalog/functions/product_slug.sql b/__fixtures__/apply/reuse/packages/catalog/revert/schemas/catalog/functions/product_slug.sql new file mode 100644 index 0000000000..a1a494fcf1 --- /dev/null +++ b/__fixtures__/apply/reuse/packages/catalog/revert/schemas/catalog/functions/product_slug.sql @@ -0,0 +1,7 @@ +-- Revert schemas/catalog/functions/product_slug from pg + +BEGIN; + +DROP FUNCTION catalog.product_slug(uuid); + +COMMIT; diff --git a/__fixtures__/apply/reuse/packages/catalog/revert/schemas/catalog/functions/slugify.sql b/__fixtures__/apply/reuse/packages/catalog/revert/schemas/catalog/functions/slugify.sql new file mode 100644 index 0000000000..52ad601e1e --- /dev/null +++ b/__fixtures__/apply/reuse/packages/catalog/revert/schemas/catalog/functions/slugify.sql @@ -0,0 +1,7 @@ +-- Revert schemas/catalog/functions/slugify from pg + +BEGIN; + +DROP FUNCTION catalog.slugify(text); + +COMMIT; diff --git a/__fixtures__/apply/reuse/packages/catalog/revert/schemas/catalog/schema.sql b/__fixtures__/apply/reuse/packages/catalog/revert/schemas/catalog/schema.sql new file mode 100644 index 0000000000..3b196c9bec --- /dev/null +++ b/__fixtures__/apply/reuse/packages/catalog/revert/schemas/catalog/schema.sql @@ -0,0 +1,7 @@ +-- Revert schemas/catalog/schema from pg + +BEGIN; + +DROP SCHEMA catalog; + +COMMIT; diff --git a/__fixtures__/apply/reuse/packages/catalog/revert/schemas/catalog/tables/products/table.sql b/__fixtures__/apply/reuse/packages/catalog/revert/schemas/catalog/tables/products/table.sql new file mode 100644 index 0000000000..b2a5c6ff29 --- /dev/null +++ b/__fixtures__/apply/reuse/packages/catalog/revert/schemas/catalog/tables/products/table.sql @@ -0,0 +1,7 @@ +-- Revert schemas/catalog/tables/products/table from pg + +BEGIN; + +DROP TABLE catalog.products; + +COMMIT; diff --git a/__fixtures__/apply/reuse/packages/catalog/verify/schemas/catalog/functions/product_slug.sql b/__fixtures__/apply/reuse/packages/catalog/verify/schemas/catalog/functions/product_slug.sql new file mode 100644 index 0000000000..58d72dcee5 --- /dev/null +++ b/__fixtures__/apply/reuse/packages/catalog/verify/schemas/catalog/functions/product_slug.sql @@ -0,0 +1,7 @@ +-- Verify schemas/catalog/functions/product_slug on pg + +BEGIN; + +SELECT catalog.product_slug('00000000-0000-0000-0000-000000000000'::uuid); + +ROLLBACK; diff --git a/__fixtures__/apply/reuse/packages/catalog/verify/schemas/catalog/functions/slugify.sql b/__fixtures__/apply/reuse/packages/catalog/verify/schemas/catalog/functions/slugify.sql new file mode 100644 index 0000000000..c471fd55a7 --- /dev/null +++ b/__fixtures__/apply/reuse/packages/catalog/verify/schemas/catalog/functions/slugify.sql @@ -0,0 +1,7 @@ +-- Verify schemas/catalog/functions/slugify on pg + +BEGIN; + +SELECT catalog.slugify('Hello World'); + +ROLLBACK; diff --git a/__fixtures__/apply/reuse/packages/catalog/verify/schemas/catalog/schema.sql b/__fixtures__/apply/reuse/packages/catalog/verify/schemas/catalog/schema.sql new file mode 100644 index 0000000000..b5aaefeb76 --- /dev/null +++ b/__fixtures__/apply/reuse/packages/catalog/verify/schemas/catalog/schema.sql @@ -0,0 +1,7 @@ +-- Verify schemas/catalog/schema on pg + +BEGIN; + +SELECT pg_catalog.has_schema_privilege('catalog', 'usage'); + +ROLLBACK; diff --git a/__fixtures__/apply/reuse/packages/catalog/verify/schemas/catalog/tables/products/table.sql b/__fixtures__/apply/reuse/packages/catalog/verify/schemas/catalog/tables/products/table.sql new file mode 100644 index 0000000000..2670b2f6b8 --- /dev/null +++ b/__fixtures__/apply/reuse/packages/catalog/verify/schemas/catalog/tables/products/table.sql @@ -0,0 +1,7 @@ +-- Verify schemas/catalog/tables/products/table on pg + +BEGIN; + +SELECT id, name FROM catalog.products WHERE false; + +ROLLBACK; diff --git a/__fixtures__/apply/reuse/packages/tenant-a/pgpm.apply.json b/__fixtures__/apply/reuse/packages/tenant-a/pgpm.apply.json new file mode 100644 index 0000000000..2d01c3ece4 --- /dev/null +++ b/__fixtures__/apply/reuse/packages/tenant-a/pgpm.apply.json @@ -0,0 +1,14 @@ +{ + "source": "catalog", + "schemas": { + "catalog": "tenant_a" + }, + "reuse": { + "sharedSchema": { + "catalog": "catalog_shared" + }, + "perTenant": [ + { "fromSchema": "catalog", "kind": "table", "name": "products" } + ] + } +} diff --git a/__fixtures__/apply/reuse/packages/tenant-b/pgpm.apply.json b/__fixtures__/apply/reuse/packages/tenant-b/pgpm.apply.json new file mode 100644 index 0000000000..04a5378f36 --- /dev/null +++ b/__fixtures__/apply/reuse/packages/tenant-b/pgpm.apply.json @@ -0,0 +1,14 @@ +{ + "source": "catalog", + "schemas": { + "catalog": "tenant_b" + }, + "reuse": { + "sharedSchema": { + "catalog": "catalog_shared" + }, + "perTenant": [ + { "fromSchema": "catalog", "kind": "table", "name": "products" } + ] + } +} diff --git a/__fixtures__/apply/reuse/pgpm.json b/__fixtures__/apply/reuse/pgpm.json new file mode 100644 index 0000000000..e251a6b60d --- /dev/null +++ b/__fixtures__/apply/reuse/pgpm.json @@ -0,0 +1,5 @@ +{ + "packages": [ + "packages/*" + ] +} diff --git a/pgpm/bundle/__tests__/split.test.ts b/pgpm/bundle/__tests__/split.test.ts index fd1a490bc7..15b6b28ae3 100644 --- a/pgpm/bundle/__tests__/split.test.ts +++ b/pgpm/bundle/__tests__/split.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { dirname, join } from 'path'; -import { bundleFromModule, splitBundle, verifyBundle } from '../src'; +import { bundleFromModule, PerTenantBootstrapChange, splitBundle, verifyBundle } from '../src'; let sourceDir: string; @@ -180,4 +180,79 @@ describe('splitBundle', () => { }) ).toThrow(/not in the bundle/); }); + + describe('perTenantBootstrap', () => { + const BOOTSTRAP: PerTenantBootstrapChange = { + name: 'schemas/tenant/schema', + deploy: '-- Deploy schemas/tenant/schema\nBEGIN;\nCREATE SCHEMA IF NOT EXISTS tenant;\nCOMMIT;\n', + revert: '-- Revert schemas/tenant/schema\nBEGIN;\nDROP SCHEMA IF EXISTS tenant;\nCOMMIT;\n', + verify: null, + replacesShared: 'schemas/catalog/schema' + }; + + it('prepends the bootstrap change and re-points the replaced dependency to it', () => { + const src = bundleFromModule(sourceDir); + const { shared, perTenant } = splitBundle(src, { + perTenantChanges: PER_TENANT, + sharedName: 'catalog-shared', + perTenantName: 'catalog-tenant', + perTenantBootstrap: [BOOTSTRAP] + }); + + // deploys first, before the tenant's own changes + expect(perTenant.manifest.deployOrder).toEqual(['schemas/tenant/schema', ...PER_TENANT]); + + // products no longer depends on the shared schema — it depends on its own + const products = perTenant.changes.find( + c => c.name === 'schemas/catalog/tables/products' + )!; + expect(products.dependencies).toEqual(['schemas/tenant/schema']); + + // product_slug: schema dep re-pointed local; slugify stays cross-module + const productSlug = perTenant.changes.find( + c => c.name === 'schemas/catalog/functions/product_slug' + )!; + expect(productSlug.dependencies).toEqual([ + 'schemas/tenant/schema', + 'catalog-shared:schemas/catalog/functions/slugify', + 'schemas/catalog/tables/products' + ]); + expect(productSlug.deploy!.sql).toContain('-- requires: schemas/tenant/schema'); + expect(productSlug.deploy!.sql).not.toContain( + '-- requires: catalog-shared:schemas/catalog/schema' + ); + + // plan carries the bootstrap change + re-pointed deps + expect(perTenant.plan).toContain('schemas/tenant/schema'); + + // the shared module is unchanged — it still owns the source schema + expect(shared.manifest.deployOrder).toContain('schemas/catalog/schema'); + expect(verifyBundle(shared)).toEqual([]); + expect(verifyBundle(perTenant)).toEqual([]); + }); + + it('rejects a bootstrap that collides with an existing change', () => { + const src = bundleFromModule(sourceDir); + expect(() => + splitBundle(src, { + perTenantChanges: PER_TENANT, + sharedName: 'catalog-shared', + perTenantName: 'catalog-tenant', + perTenantBootstrap: [{ ...BOOTSTRAP, name: 'schemas/catalog/tables/products' }] + }) + ).toThrow(/collides with an existing bundle change/); + }); + + it('rejects a bootstrap that replaces a non-shared change', () => { + const src = bundleFromModule(sourceDir); + expect(() => + splitBundle(src, { + perTenantChanges: PER_TENANT, + sharedName: 'catalog-shared', + perTenantName: 'catalog-tenant', + perTenantBootstrap: [{ ...BOOTSTRAP, replacesShared: 'schemas/catalog/tables/products' }] + }) + ).toThrow(/not a shared change/); + }); + }); }); diff --git a/pgpm/bundle/src/split.ts b/pgpm/bundle/src/split.ts index 4a5818cce7..18f0bf2da5 100644 --- a/pgpm/bundle/src/split.ts +++ b/pgpm/bundle/src/split.ts @@ -21,6 +21,46 @@ export interface SplitBundleOptions { sharedName: string; /** Module name for the per-tenant bundle. */ perTenantName: string; + /** + * Extra changes to weave into the per-tenant bundle only, deployed before its + * own changes. Used for infrastructure the shared module owns in *its* schema + * but each tenant must also provision in *its* schema — canonically the + * `CREATE SCHEMA` of the per-tenant target, which a single source + * schema-creation change cannot express (it lands in the shared module). + * + * When a bootstrap declares `replacesShared`, per-tenant dependencies on that + * shared change are re-pointed at the bootstrap (a local dependency) instead + * of becoming a cross-module reference — so a per-tenant object depends on + * *its own* schema, not the shared one. + */ + perTenantBootstrap?: PerTenantBootstrapChange[]; +} + +/** + * A caller-supplied change woven into the per-tenant bundle by + * {@link splitBundle} (see {@link SplitBundleOptions.perTenantBootstrap}). + * The caller owns the SQL (e.g. a `CREATE SCHEMA IF NOT EXISTS` transpiled to + * the per-tenant target); `splitBundle` owns the bundle mechanics (digests, + * plan entry, deploy order, dependency re-pointing). + */ +export interface PerTenantBootstrapChange { + /** Change name/path (e.g. `schemas/tenant_a/schema`). */ + name: string; + /** Local dependencies of the bootstrap change (default: none). */ + dependencies?: string[]; + /** Raw deploy SQL (or null). */ + deploy: string | null; + /** Raw revert SQL (or null). */ + revert: string | null; + /** Raw verify SQL (or null). */ + verify: string | null; + /** + * A shared change (unprefixed name) that per-tenant changes currently depend + * on but should instead depend on this bootstrap. Every per-tenant reference + * to it — dependency arrays, plan deps, and `-- requires:` headers — is + * rewritten to this bootstrap's local name. + */ + replacesShared?: string; } /** @@ -42,6 +82,8 @@ export interface SplitBundleResult { interface CrossModuleRefs { sharedName: string; isShared: (change: string) => boolean; + /** Bootstrap changes to prepend to the per-tenant side. */ + bootstrap: PerTenantBootstrapChange[]; } /** @@ -86,17 +128,52 @@ export function splitBundle( } } + const bootstrap = options.perTenantBootstrap ?? []; + for (const b of bootstrap) { + if (names.has(b.name)) { + throw new Error( + `splitBundle: bootstrap change "${b.name}" collides with an existing bundle change` + ); + } + if (b.replacesShared !== undefined && !isShared(b.replacesShared)) { + throw new Error( + `splitBundle: bootstrap "${b.name}" replaces "${b.replacesShared}", which is not a shared change` + ); + } + } + const shared = buildSubBundle(bundle, isShared, options.sharedName, null); const perTenantBundle = buildSubBundle( bundle, name => perTenant.has(name), options.perTenantName, - { sharedName: options.sharedName, isShared } + { sharedName: options.sharedName, isShared, bootstrap } ); return { shared, perTenant: perTenantBundle }; } +/** Build a {@link BundleChange} from raw bootstrap SQL, computing digests. */ +function bootstrapToChange(b: PerTenantBootstrapChange): BundleChange { + const toScript = (kind: BundleScript['kind'], sql: string | null): BundleScript | null => + sql === null ? null : { kind, sql, digest: hashString(sql) }; + const deploy = toScript('deploy', b.deploy); + const revert = toScript('revert', b.revert); + const verify = toScript('verify', b.verify); + return { + name: b.name, + dependencies: b.dependencies ?? [], + deploy, + revert, + verify, + digest: computeChangeDigest(b.name, { + deploy: deploy?.digest, + revert: revert?.digest, + verify: verify?.digest + }) + }; +} + /** Build one side of the split: the changes matching `include`, as a module. */ function buildSubBundle( bundle: MigrationBundle, @@ -104,33 +181,28 @@ function buildSubBundle( moduleName: string, cross: CrossModuleRefs | null ): MigrationBundle { - // Rename map applied to per-tenant scripts: a bare reference to a shared - // change becomes a cross-module reference `:`. - const crossRename = cross - ? new Map( - bundle.changes - .filter(c => cross.isShared(c.name)) - .map(c => [c.name, `${cross.sharedName}:${c.name}`]) - ) - : null; + // One rename map for both dependency arrays and `-- requires:` headers on the + // per-tenant side: a reference to a shared change becomes a cross-module + // reference `:`, except a change a bootstrap replaces, + // which is re-pointed at the (local) bootstrap instead. + const renameMap = cross ? buildRenameMap(bundle, cross) : null; + + const rewriteScript = (script: BundleScript | null): BundleScript | null => { + if (!script) return null; + if (!renameMap || renameMap.size === 0) return script; + const parsed = parsePgpmHeader(script.sql); + if (renameInHeader(parsed, renameMap) === 0) return script; + const sql = writePgpmScript(parsed); + return { kind: script.kind, sql, digest: hashString(sql) }; + }; const changes: BundleChange[] = bundle.changes .filter(c => include(c.name)) .map(change => { - const dependencies = change.dependencies.map(dep => - cross && cross.isShared(dep) ? `${cross.sharedName}:${dep}` : dep - ); - const rewrite = (script: BundleScript | null): BundleScript | null => { - if (!script) return null; - if (!crossRename || crossRename.size === 0) return script; - const parsed = parsePgpmHeader(script.sql); - if (renameInHeader(parsed, crossRename) === 0) return script; - const sql = writePgpmScript(parsed); - return { kind: script.kind, sql, digest: hashString(sql) }; - }; - const deploy = rewrite(change.deploy); - const revert = rewrite(change.revert); - const verify = rewrite(change.verify); + const dependencies = change.dependencies.map(dep => renameMap?.get(dep) ?? dep); + const deploy = rewriteScript(change.deploy); + const revert = rewriteScript(change.revert); + const verify = rewriteScript(change.verify); const digest = computeChangeDigest(change.name, { deploy: deploy?.digest, revert: revert?.digest, @@ -139,7 +211,11 @@ function buildSubBundle( return { name: change.name, dependencies, deploy, revert, verify, digest }; }); - const plan = buildPlan(bundle.plan, moduleName, include, cross); + // Bootstrap changes deploy before the tenant's own changes. + const bootstrapChanges = (cross?.bootstrap ?? []).map(bootstrapToChange); + const allChanges = [...bootstrapChanges, ...changes]; + + const plan = buildPlan(bundle.plan, moduleName, include, renameMap, bootstrapChanges); let control = bundle.control ? { fileName: `${moduleName}.control`, content: bundle.control.content } @@ -151,7 +227,7 @@ function buildSubBundle( const digest = computeBundleDigest( plan, control?.content ?? null, - changes.map(c => c.digest) + allChanges.map(c => c.digest) ); return { @@ -159,8 +235,8 @@ function buildSubBundle( formatVersion: bundle.manifest.formatVersion, name: moduleName, createdWith: bundle.manifest.createdWith, - changeCount: changes.length, - deployOrder: changes.map(c => c.name), + changeCount: allChanges.length, + deployOrder: allChanges.map(c => c.name), digest, provenance: { ...(bundle.manifest.provenance ?? {}), @@ -169,10 +245,26 @@ function buildSubBundle( }, plan, control, - changes + changes: allChanges }; } +/** + * Build the per-tenant reference-rewrite map: every shared change → its + * cross-module reference, then bootstrap `replacesShared` overrides → the + * bootstrap's local name (so a per-tenant object depends on its own schema). + */ +function buildRenameMap(bundle: MigrationBundle, cross: CrossModuleRefs): Map { + const map = new Map(); + for (const c of bundle.changes) { + if (cross.isShared(c.name)) map.set(c.name, `${cross.sharedName}:${c.name}`); + } + for (const b of cross.bootstrap) { + if (b.replacesShared !== undefined) map.set(b.replacesShared, b.name); + } + return map; +} + /** * Rebuild a plan for one side of the split: keep only the included changes (and * their tags), rewrite the project identity, and cross-reference shared @@ -182,7 +274,8 @@ function buildPlan( planContent: string, moduleName: string, include: (name: string) => boolean, - cross: CrossModuleRefs | null + renameMap: Map | null, + bootstrapChanges: BundleChange[] ): string { const parsed = parsePlanContent(planContent); if (!parsed.data) { @@ -191,23 +284,27 @@ function buildPlan( } const source = parsed.data; + const bootstrapPlan: Change[] = bootstrapChanges.map(b => ({ + name: b.name, + dependencies: b.dependencies + })); + const changes: Change[] = source.changes .filter(c => include(c.name)) .map(c => ({ ...c, - dependencies: (c.dependencies ?? []).map(dep => - cross && cross.isShared(dep) ? `${cross.sharedName}:${dep}` : dep - ) + dependencies: (c.dependencies ?? []).map(dep => renameMap?.get(dep) ?? dep) })); - const kept = new Set(changes.map(c => c.name)); + const allChanges = [...bootstrapPlan, ...changes]; + const kept = new Set(allChanges.map(c => c.name)); const tags = source.tags.filter(t => kept.has(t.change)); const plan: ExtendedPlanFile = { ...source, package: moduleName, uri: moduleName, - changes, + changes: allChanges, tags }; diff --git a/pgpm/core/__tests__/apply/apply-reuse.test.ts b/pgpm/core/__tests__/apply/apply-reuse.test.ts new file mode 100644 index 0000000000..714a3b8cee --- /dev/null +++ b/pgpm/core/__tests__/apply/apply-reuse.test.ts @@ -0,0 +1,241 @@ +import { rmSync } from 'fs'; + +import { + clearApplyMaterializationCache, + materializeReuseModule, + parseApplySpec, + readApplySpec, + resolveSharedModuleName +} from '../../src/apply'; +import { CoreDeployTestFixture } from '../../test-utils/CoreDeployTestFixture'; +import { TestDatabase } from '../../test-utils/TestDatabase'; +import { TestFixture } from '../../test-utils/TestFixture'; + +const at = '/ws/packages/tenant-a/pgpm.apply.json'; + +const baseReuse = { + source: 'catalog', + schemas: { catalog: 'tenant_a' }, + reuse: { + sharedSchema: { catalog: 'catalog_shared' }, + perTenant: [{ fromSchema: 'catalog', kind: 'table', name: 'products' }] + } +}; + +describe('apply spec parsing — reuse', () => { + it('accepts a reuse spec alongside a per-tenant schema map', () => { + const spec = parseApplySpec(JSON.stringify(baseReuse), at); + expect(spec.reuse).toEqual(baseReuse.reuse); + expect(spec.schemas).toEqual({ catalog: 'tenant_a' }); + }); + + it.each([ + [{ ...baseReuse, reuse: [] }, /"reuse" must be an object/], + [{ ...baseReuse, reuse: { perTenant: baseReuse.reuse.perTenant } }, /reuse.sharedSchema/], + [{ ...baseReuse, reuse: { sharedSchema: {}, perTenant: baseReuse.reuse.perTenant } }, /reuse.sharedSchema/], + [{ ...baseReuse, reuse: { sharedSchema: { catalog: 'catalog_shared' } } }, /reuse.perTenant/], + [{ ...baseReuse, reuse: { sharedSchema: { catalog: 'catalog_shared' }, perTenant: [] } }, /reuse.perTenant/], + [ + { ...baseReuse, reuse: { sharedSchema: { catalog: 'catalog_shared' }, perTenant: [{ fromSchema: 'catalog', kind: 'widget', name: 'x' }] } }, + /reuse.perTenant" seed/ + ], + [ + { ...baseReuse, reuse: { sharedSchema: { catalog: 'catalog_shared' }, perTenant: [{ fromSchema: '', kind: 'table', name: 'x' }] } }, + /reuse.perTenant" seed/ + ], + // seed schema with no per-tenant target in "schemas" + [ + { source: 'catalog', schemas: { catalog: 'tenant_a' }, reuse: { sharedSchema: { catalog: 'catalog_shared' }, perTenant: [{ fromSchema: 'other', kind: 'table', name: 'x' }] } }, + /no per-tenant target/ + ], + // reuse without schemas + [ + { source: 'catalog', reuse: baseReuse.reuse }, + /"reuse" requires "schemas"/ + ], + // seed schema missing from sharedSchema + [ + { source: 'catalog', schemas: { catalog: 'tenant_a', other: 'o' }, reuse: { sharedSchema: { catalog: 'catalog_shared' }, perTenant: [{ fromSchema: 'other', kind: 'table', name: 'x' }] } }, + /no shared target/ + ] + ])('rejects invalid reuse specs %#', (spec, err) => { + expect(() => parseApplySpec(JSON.stringify(spec), at)).toThrow(err); + }); + + it('honors an explicit sharedName override; else derives deterministically', () => { + const derived = resolveSharedModuleName(parseApplySpec(JSON.stringify(baseReuse), at) as any); + expect(derived).toMatch(/^catalog-shared-[0-9a-f]{8}$/); + + const override = parseApplySpec( + JSON.stringify({ ...baseReuse, reuse: { ...baseReuse.reuse, sharedName: 'catalog-core' } }), + at + ); + expect(resolveSharedModuleName(override as any)).toBe('catalog-core'); + }); + + it('resolves the same shared name for two tenants over the same source/shared/seeds', () => { + const a = parseApplySpec(JSON.stringify(baseReuse), at) as any; + const b = parseApplySpec( + JSON.stringify({ ...baseReuse, schemas: { catalog: 'tenant_b' } }), + at + ) as any; + expect(resolveSharedModuleName(a)).toBe(resolveSharedModuleName(b)); + }); +}); + +describe('materializeReuseModule — split into shared + per-tenant', () => { + let fixture: TestFixture; + + beforeAll(() => { + fixture = new TestFixture('apply', 'reuse'); + }); + + afterAll(() => fixture.cleanup()); + + it('emits shared helper once and routes the tenant table + dependent fn per tenant', async () => { + const sourceDir = fixture.fixturePath('packages', 'catalog'); + const spec = readApplySpec(fixture.fixturePath('packages', 'tenant-a')) as any; + const result = await materializeReuseModule({ sourceDir, spec }); + try { + const sharedName = result.sharedName; + + // shared module: the schema + tenant-independent helper, in the shared schema + expect(result.shared.bundle.manifest.deployOrder).toEqual([ + 'schemas/catalog_shared/schema', + 'schemas/catalog_shared/functions/slugify' + ]); + const slugify = result.shared.bundle.changes.find( + c => c.name === 'schemas/catalog_shared/functions/slugify' + )!; + expect(slugify.deploy!.sql).toContain('catalog_shared.slugify'); + expect(slugify.deploy!.sql).not.toMatch(/\bcatalog\.slugify/); + + // per-tenant module: local schema bootstrap first, then the table + the + // function that reads it (product_slug reaches the seed → per-tenant) + expect(result.perTenant.bundle.manifest.deployOrder).toEqual([ + 'schemas/tenant_a/schema', + 'schemas/tenant_a/tables/products/table', + 'schemas/tenant_a/functions/product_slug' + ]); + + const productSlug = result.perTenant.bundle.changes.find( + c => c.name === 'schemas/tenant_a/functions/product_slug' + )!; + // definition lands in the tenant schema; reads its own table; calls the + // SHARED helper across the module boundary + expect(productSlug.deploy!.sql).toContain('tenant_a.product_slug'); + expect(productSlug.deploy!.sql).toContain('tenant_a.products'); + expect(productSlug.deploy!.sql).toContain('catalog_shared.slugify'); + + // dependencies: local schema (bootstrap), local table, cross-module helper + expect(productSlug.dependencies).toEqual( + expect.arrayContaining([ + 'schemas/tenant_a/schema', + 'schemas/tenant_a/tables/products/table', + `${sharedName}:schemas/catalog_shared/functions/slugify` + ]) + ); + // it must NOT depend on the shared schema change directly (uses local one) + expect(productSlug.dependencies).not.toContain( + `${sharedName}:schemas/catalog_shared/schema` + ); + + // the per-tenant schema is created idempotently (may pre-exist) + const tenantSchema = result.perTenant.bundle.changes.find( + c => c.name === 'schemas/tenant_a/schema' + )!; + expect(tenantSchema.deploy!.sql).toMatch(/CREATE SCHEMA IF NOT EXISTS tenant_a/i); + } finally { + rmSync(result.shared.outDir, { recursive: true, force: true }); + rmSync(result.perTenant.outDir, { recursive: true, force: true }); + } + }); +}); + +describe('apply reuse deployment (e2e)', () => { + let fixture: CoreDeployTestFixture; + let db: TestDatabase; + + const functionExists = async (schema: string, name: string): Promise => { + const res = await db.query( + `SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = $1 AND p.proname = $2`, + [schema, name] + ); + return res.rows.length > 0; + }; + + beforeAll(() => { + fixture = new CoreDeployTestFixture('apply', 'reuse'); + }); + + afterAll(async () => { + await fixture.cleanup(); + }); + + beforeEach(async () => { + clearApplyMaterializationCache(); + db = await fixture.setupTestDatabase(); + }); + + test('shared helper deployed once, tenant table per-tenant, revert is reference-safe', async () => { + // tenant A: target schema absent — apply creates it + await fixture.deployModule('tenant-a', db.name, ['apply', 'reuse']); + + // tenant B: target schema already exists — apply must not fail on it + await db.query('CREATE SCHEMA tenant_b'); + await fixture.deployModule('tenant-b', db.name, ['apply', 'reuse']); + + // source module is never deployed + expect(await db.exists('schema', 'catalog')).toBe(false); + + // tenant-independent helper deployed once, in the shared schema + expect(await functionExists('catalog_shared', 'slugify')).toBe(true); + // the tenant-dependent function is NOT shared + expect(await functionExists('catalog_shared', 'product_slug')).toBe(false); + + // the table + its dependent function materialize per tenant + expect(await db.exists('table', 'tenant_a.products')).toBe(true); + expect(await db.exists('table', 'tenant_b.products')).toBe(true); + expect(await functionExists('tenant_a', 'product_slug')).toBe(true); + expect(await functionExists('tenant_b', 'product_slug')).toBe(true); + + // the tables are independent; the per-tenant fn reads its own via the shared helper + await db.query(`INSERT INTO tenant_a.products (id, name) VALUES ('11111111-1111-1111-1111-111111111111', 'Hello World')`); + await db.query(`INSERT INTO tenant_b.products (id, name) VALUES ('22222222-2222-2222-2222-222222222222', 'Other Thing')`); + const a = await db.query(`SELECT tenant_a.product_slug('11111111-1111-1111-1111-111111111111') AS s`); + const b = await db.query(`SELECT tenant_b.product_slug('22222222-2222-2222-2222-222222222222') AS s`); + expect(a.rows[0].s).toBe('hello-world'); + expect(b.rows[0].s).toBe('other-thing'); + + // the shared module is deployed once under its own package name + const changes = await db.getDeployedChanges(); + const packages = new Set(changes.map((c: any) => c.package)); + const sharedPkgs = [...packages].filter(p => /^catalog-shared-/.test(String(p))); + expect(sharedPkgs).toHaveLength(1); + const sharedSlugifyDeploys = changes.filter( + (c: any) => c.change_name === 'schemas/catalog_shared/functions/slugify' + ); + expect(sharedSlugifyDeploys).toHaveLength(1); + expect(packages.has('catalog')).toBe(false); + }); + + test('verify passes for both tenants; reverting one leaves shared state for the other', async () => { + await fixture.deployModule('tenant-a', db.name, ['apply', 'reuse']); + await db.query('CREATE SCHEMA tenant_b'); + await fixture.deployModule('tenant-b', db.name, ['apply', 'reuse']); + + await fixture.verifyModule('tenant-a', db.name, ['apply', 'reuse']); + await fixture.verifyModule('tenant-b', db.name, ['apply', 'reuse']); + + // revert the last-deployed tenant; the shared helper survives (still needed + // by tenant A), and tenant A is untouched + await fixture.revertModule('tenant-b', db.name, ['apply', 'reuse']); + expect(await db.exists('table', 'tenant_b.products')).toBe(false); + expect(await functionExists('tenant_b', 'product_slug')).toBe(false); + + expect(await functionExists('catalog_shared', 'slugify')).toBe(true); + expect(await db.exists('table', 'tenant_a.products')).toBe(true); + expect(await functionExists('tenant_a', 'product_slug')).toBe(true); + }); +}); diff --git a/pgpm/core/src/apply/apply-spec.ts b/pgpm/core/src/apply/apply-spec.ts index cc73dd7a0e..a4fcd5059b 100644 --- a/pgpm/core/src/apply/apply-spec.ts +++ b/pgpm/core/src/apply/apply-spec.ts @@ -41,18 +41,18 @@ export function parseApplySpec(content: string, specPath: string): ResolvedApply ); } + const isSchemaMap = (value: unknown): boolean => + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.keys(value as object).length > 0 && + Object.entries(value as object).every( + ([from, to]) => typeof from === 'string' && typeof to === 'string' && !!from && !!to + ); + const hasSchemas = parsed.schemas !== undefined; - if (hasSchemas) { - if ( - typeof parsed.schemas !== 'object' || - Array.isArray(parsed.schemas) || - Object.keys(parsed.schemas).length === 0 || - Object.entries(parsed.schemas).some( - ([from, to]) => typeof from !== 'string' || typeof to !== 'string' || !from || !to - ) - ) { - throw new Error(`${specPath}: "schemas" must be a non-empty string → string map`); - } + if (hasSchemas && !isSchemaMap(parsed.schemas)) { + throw new Error(`${specPath}: "schemas" must be a non-empty string → string map`); } const ROUTE_KINDS = ['table', 'view', 'function', 'procedure', 'type']; @@ -82,6 +82,63 @@ export function parseApplySpec(content: string, specPath: string): ResolvedApply } } + const hasReuse = parsed.reuse !== undefined; + if (hasReuse) { + const reuse = parsed.reuse; + if (typeof reuse !== 'object' || reuse === null || Array.isArray(reuse)) { + throw new Error(`${specPath}: "reuse" must be an object`); + } + if (!isSchemaMap(reuse.sharedSchema)) { + throw new Error( + `${specPath}: "reuse.sharedSchema" must be a non-empty string → string map` + ); + } + if (!Array.isArray(reuse.perTenant) || reuse.perTenant.length === 0) { + throw new Error( + `${specPath}: "reuse.perTenant" must be a non-empty array of seed objects` + ); + } + for (const seed of reuse.perTenant) { + if ( + !seed || + typeof seed !== 'object' || + typeof seed.fromSchema !== 'string' || + !seed.fromSchema || + typeof seed.name !== 'string' || + !seed.name || + !ROUTE_KINDS.includes(seed.kind) + ) { + throw new Error( + `${specPath}: each "reuse.perTenant" seed needs { fromSchema, kind (${ROUTE_KINDS.join( + '|' + )}), name } as non-empty strings` + ); + } + } + if (reuse.sharedName !== undefined && (typeof reuse.sharedName !== 'string' || !reuse.sharedName)) { + throw new Error(`${specPath}: "reuse.sharedName" must be a non-empty string`); + } + if (!hasSchemas) { + throw new Error( + `${specPath}: "reuse" requires "schemas" (the per-tenant source → target schema map)` + ); + } + for (const seed of reuse.perTenant) { + if (!(seed.fromSchema in parsed.schemas)) { + throw new Error( + `${specPath}: "reuse.perTenant" seed schema "${seed.fromSchema}" has no per-tenant ` + + `target in "schemas"` + ); + } + if (!(seed.fromSchema in reuse.sharedSchema)) { + throw new Error( + `${specPath}: "reuse.perTenant" seed schema "${seed.fromSchema}" has no shared ` + + `target in "reuse.sharedSchema"` + ); + } + } + } + if (!hasSchemas && !hasRoute) { throw new Error( `${specPath}: at least one of "schemas" (string → string map) or "route" (object routes) is required` diff --git a/pgpm/core/src/apply/index.ts b/pgpm/core/src/apply/index.ts index ba32875f5e..9c7b92e5df 100644 --- a/pgpm/core/src/apply/index.ts +++ b/pgpm/core/src/apply/index.ts @@ -1,3 +1,4 @@ export * from './materialize'; export * from './apply-spec'; +export * from './reuse'; export * from './types'; diff --git a/pgpm/core/src/apply/materialize.ts b/pgpm/core/src/apply/materialize.ts index 1682381102..f2208c2beb 100644 --- a/pgpm/core/src/apply/materialize.ts +++ b/pgpm/core/src/apply/materialize.ts @@ -13,6 +13,7 @@ import { loadModule, makeSchemaTranspiler, SchemaTransformPass } from '@pgpmjs/t import { ModuleMap } from '../modules/modules'; import { hasApplySpec, readApplySpec } from './apply-spec'; +import { isReuseSpec, materializeReuseModule, resolveSharedModuleName } from './reuse'; import { ResolvedApplySpec } from './types'; export interface MaterializeApplyOptions { @@ -151,7 +152,10 @@ export async function resolveEffectiveModulePath( ): Promise { if (!hasApplySpec(modulePath)) return modulePath; - const cached = materializedCache.get(modulePath); + // A reuse proxy resolves under two names (shared + per-tenant) from one + // directory, so the cache is keyed by the requested module name too. + const cacheKey = `${modulePath}::${moduleName}`; + const cached = materializedCache.get(cacheKey); if (cached) return cached; const spec = readApplySpec(modulePath); @@ -166,9 +170,18 @@ export async function resolveEffectiveModulePath( } const sourceDir = resolve(workspacePath, sourceModule.path); - const { outDir } = await materializeApplyModule({ sourceDir, spec }); - materializedCache.set(modulePath, outDir); + if (isReuseSpec(spec)) { + const result = await materializeReuseModule({ sourceDir, spec }); + materializedCache.set(`${modulePath}::${result.sharedName}`, result.shared.outDir); + materializedCache.set(`${modulePath}::${result.perTenantName}`, result.perTenant.outDir); + const outDir = + moduleName === resolveSharedModuleName(spec) ? result.shared.outDir : result.perTenant.outDir; + return outDir; + } + + const { outDir } = await materializeApplyModule({ sourceDir, spec }); + materializedCache.set(cacheKey, outDir); return outDir; } diff --git a/pgpm/core/src/apply/reuse.ts b/pgpm/core/src/apply/reuse.ts new file mode 100644 index 0000000000..64f007a7f5 --- /dev/null +++ b/pgpm/core/src/apply/reuse.ts @@ -0,0 +1,307 @@ +import { mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { hashString } from '@pgpmjs/ast'; +import { + bundleFromModule, + materializeBundle, + MigrationBundle, + PerTenantBootstrapChange, + splitBundle, + transpileBundle, + verifyBundle +} from '@pgpmjs/bundle'; +import { partitionModule } from '@pgpmjs/slice'; +import { + loadModule, + makeSchemaTranspiler, + SchemaObjectRoute, + SchemaTransformPass +} from '@pgpmjs/transform'; + +import { ResolvedApplySpec } from './types'; + +/** A resolved apply spec that carries a reuse declaration. */ +export type ResolvedReuseSpec = ResolvedApplySpec & { reuse: NonNullable }; + +/** Whether an apply spec is a reuse (shared/per-tenant split) spec. */ +export function isReuseSpec(spec: ResolvedApplySpec): spec is ResolvedReuseSpec { + return spec.reuse !== undefined; +} + +/** pgpm change-path folder → object route kind. */ +const FOLDER_KIND: Record = { + tables: 'table', + views: 'view', + functions: 'function', + procedures: 'procedure', + types: 'type' +}; + +// Mirrors materialize.ts: bare-schema-name literal helpers the AST pass can't +// reach are remapped by a narrow string pre-pass. +const SCHEMA_NAME_LITERAL_FUNCS = ['verify_schema', 'has_schema_privilege']; +const schemaNameLiteralPass: SchemaTransformPass = (content, schemaMapping) => { + const pattern = new RegExp( + `\\b(${SCHEMA_NAME_LITERAL_FUNCS.join('|')})\\s*\\(\\s*'([^']+)'`, + 'gi' + ); + return content.replace(pattern, (match, _fn: string, schema: string) => { + const mapped = schemaMapping.get(schema); + return mapped ? match.replace(`'${schema}'`, `'${mapped}'`) : match; + }); +}; + +interface ParsedObjectChange { + schema: string; + /** `schema` for the bare schema-creation change, else the object folder. */ + folder: string; + name?: string; +} + +/** Parse a `schemas///…` change path into its object identity. */ +function parseObjectChange(change: string): ParsedObjectChange | null { + const parts = change.split('/'); + if (parts[0] !== 'schemas' || parts.length < 2) return null; + const schema = parts[1]; + const folder = parts[2]; + if (folder === undefined || folder === 'schema') return { schema, folder: 'schema' }; + return { schema, folder, name: parts[3] }; +} + +/** + * Deterministic name of the shared module a reuse proxy expands into. Derived + * from the source module, the shared-schema mapping, and the per-tenant seed + * set — the exact inputs that determine the shared partition — so two tenant + * proxies over the same source/shared/seed configuration resolve to *one* + * shared module (deployed once), while any difference isolates them. + */ +export function resolveSharedModuleName(spec: ResolvedReuseSpec): string { + if (spec.reuse.sharedName) return spec.reuse.sharedName; + const canonical = JSON.stringify({ + source: spec.source.module, + sharedSchema: Object.entries(spec.reuse.sharedSchema).sort(([a], [b]) => a.localeCompare(b)), + seeds: spec.reuse.perTenant + .map(s => `${s.fromSchema}.${s.kind}.${s.name}`) + .sort((a, b) => a.localeCompare(b)) + }); + return `${spec.source.module}-shared-${hashString(canonical).slice(0, 8)}`; +} + +export interface MaterializeReuseOptions { + /** Directory of the source module being applied. */ + sourceDir: string; + /** The resolved reuse spec. */ + spec: ResolvedReuseSpec; + /** Deployable directory for the shared module (default: a fresh temp dir). */ + sharedOutDir?: string; + /** Deployable directory for the per-tenant module (default: a fresh temp dir). */ + perTenantOutDir?: string; +} + +export interface MaterializeReuseResult { + sharedName: string; + perTenantName: string; + shared: { bundle: MigrationBundle; outDir: string }; + perTenant: { bundle: MigrationBundle; outDir: string }; +} + +/** + * Expand a reuse proxy into two deployable modules: a shared module (objects the + * classifier proves tenant-independent, deployed once) and a per-tenant module + * (objects reachable from the per-tenant seeds, materialized per instance) that + * `requires` the shared one. + * + * Pipeline over existing primitives: + * `bundleFromModule` → `partitionModule` (classifier) → + * `transpileBundle(makeSchemaTranspiler)` routing shared objects to the shared + * schema and per-tenant objects to the per-tenant schema in *one* pass (so + * cross-boundary references resolve correctly) → `splitBundle` with a per-tenant + * schema bootstrap → `verifyBundle` → `materializeBundle`. + */ +export async function materializeReuseModule( + options: MaterializeReuseOptions +): Promise { + const { sourceDir, spec } = options; + const perTenantName = spec.name!; + const sharedName = resolveSharedModuleName(spec); + const reuse = spec.reuse; + + await loadModule(); + + const source = bundleFromModule(sourceDir); + if (spec.source.bundleDigest && spec.source.bundleDigest !== source.manifest.digest) { + throw new Error( + `Apply spec for "${perTenantName}" pins source bundle digest ${spec.source.bundleDigest}, ` + + `but the installed source "${spec.source.module}" hashes to ${source.manifest.digest}. ` + + `Reinstall the pinned version or update the spec.` + ); + } + + // 1. Classify: which source changes must be per-tenant (a seed or a dependent). + const partition = partitionModule({ + moduleDir: sourceDir, + seedObjects: reuse.perTenant.map(s => ({ schema: s.fromSchema, name: s.name })) + }); + const unknownSeeds = partition.warnings.filter(w => w.kind === 'unknown-seed'); + if (unknownSeeds.length > 0) { + throw new Error( + `Reuse spec for "${perTenantName}": seed object(s) not found in source "${spec.source.module}": ` + + unknownSeeds.map(w => w.change).join(', ') + ); + } + + // 2. Object routes: every per-tenant change's object goes to the per-tenant + // target; everything else defaults (schemaMap) to the shared target. A + // single router keeps cross-boundary references correct (a per-tenant + // function landing in the tenant schema still references the shared helper + // in the shared schema). + const routes: SchemaObjectRoute[] = []; + for (const change of partition.perTenant) { + const obj = parseObjectChange(change); + if (!obj || obj.folder === 'schema' || !obj.name) { + throw new Error( + `Reuse spec for "${perTenantName}": per-tenant change "${change}" is not an object change ` + + `and cannot be routed to a per-tenant schema; refine the seeds so it stays shared, or ` + + `it names an unsupported object kind` + ); + } + const kind = FOLDER_KIND[obj.folder]; + if (!kind) { + throw new Error( + `Reuse spec for "${perTenantName}": per-tenant change "${change}" has unsupported object ` + + `folder "${obj.folder}"` + ); + } + const toSchema = spec.schemas![obj.schema]; + if (!toSchema) { + throw new Error( + `Reuse spec for "${perTenantName}": per-tenant object in schema "${obj.schema}" has no ` + + `per-tenant target in "schemas"` + ); + } + routes.push({ fromSchema: obj.schema, kind, name: obj.name, toSchema }); + } + + const assumeSchemasExist = [ + ...new Set([...Object.values(reuse.sharedSchema), ...Object.values(spec.schemas!)]) + ]; + + const provenance = { + appliedFrom: spec.source.module, + ...(spec.source.package ? { sourcePackage: spec.source.package } : {}), + ...(spec.source.version ? { sourceVersion: spec.source.version } : {}) + }; + + // 3. Transpile once: schema default = shared target, per-object overrides = + // per-tenant target. + const main = makeSchemaTranspiler({ + schemaMap: reuse.sharedSchema, + routes, + transform: { prePasses: [schemaNameLiteralPass], assumeSchemasExist } + }); + const transpiled = transpileBundle(source, { + renameChange: main.renameChange, + transformScript: main.transformScript, + provenance + }); + if (main.result.errors.length > 0) { + const detail = main.result.errors.map(e => `${e.file}: ${e.error}`).join('; '); + throw new Error( + `Reuse transpile of "${spec.source.module}" for "${perTenantName}" failed: ${detail}` + ); + } + + // The partition is in *source* change names; splitBundle works on the + // transpiled bundle, so map the per-tenant set through the same rename. + const perTenantTranspiled = [...partition.perTenant].map(main.renameChange); + + // 4. Per-tenant schema bootstrap: the source's `CREATE SCHEMA` lands in the + // shared module (shared target), but each tenant also needs its own + // schema. Duplicate it — transpiled to the per-tenant target — into the + // per-tenant module, re-pointing per-tenant dependencies off the shared + // schema onto this local one. + const bootstrap = buildSchemaBootstraps(source, spec, main.renameChange, assumeSchemasExist); + + const { shared, perTenant } = splitBundle(transpiled, { + perTenantChanges: perTenantTranspiled, + sharedName, + perTenantName, + perTenantBootstrap: bootstrap + }); + + for (const [label, bundle] of [ + [sharedName, shared], + [perTenantName, perTenant] + ] as const) { + const issues = verifyBundle(bundle); + if (issues.length > 0) { + throw new Error( + `Reuse bundle "${label}" failed integrity verification: ${issues.map(i => i.kind).join(', ')}` + ); + } + } + + const sharedOutDir = + options.sharedOutDir ?? mkdtempSync(join(tmpdir(), `pgpm-apply-${sharedName}-`)); + const perTenantOutDir = + options.perTenantOutDir ?? mkdtempSync(join(tmpdir(), `pgpm-apply-${perTenantName}-`)); + materializeBundle(shared, sharedOutDir); + materializeBundle(perTenant, perTenantOutDir); + + return { + sharedName, + perTenantName, + shared: { bundle: shared, outDir: sharedOutDir }, + perTenant: { bundle: perTenant, outDir: perTenantOutDir } + }; +} + +/** + * Build a per-tenant `CREATE SCHEMA` bootstrap for each source schema that both + * creates a schema and has per-tenant objects. The scripts are the source + * schema-creation change transpiled to the per-tenant target; `replacesShared` + * is the shared-schema change the per-tenant objects would otherwise depend on. + */ +function buildSchemaBootstraps( + source: MigrationBundle, + spec: ResolvedReuseSpec, + renameSharedChange: (name: string) => string, + assumeSchemasExist: string[] +): PerTenantBootstrapChange[] { + const bootstraps: PerTenantBootstrapChange[] = []; + for (const [fromSchema, perTenantTarget] of Object.entries(spec.schemas!)) { + const sharedTarget = spec.reuse.sharedSchema[fromSchema]; + if (!sharedTarget || sharedTarget === perTenantTarget) continue; + + const sourceSchemaChange = `schemas/${fromSchema}/schema`; + const schemaChange = source.changes.find(c => c.name === sourceSchemaChange); + if (!schemaChange) continue; // source doesn't create this schema; nothing to duplicate + + // Transpile just the schema-creation change to the per-tenant target. + const single: MigrationBundle = { + ...source, + manifest: { ...source.manifest, changeCount: 1, deployOrder: [sourceSchemaChange] }, + changes: [schemaChange] + }; + const t = makeSchemaTranspiler({ + schemaMap: { [fromSchema]: perTenantTarget }, + transform: { prePasses: [schemaNameLiteralPass], assumeSchemasExist } + }); + const perTenantSchema = transpileBundle(single, { + renameChange: t.renameChange, + transformScript: t.transformScript + }).changes[0]; + + bootstraps.push({ + name: `schemas/${perTenantTarget}/schema`, + dependencies: [], + deploy: perTenantSchema.deploy?.sql ?? null, + revert: perTenantSchema.revert?.sql ?? null, + verify: perTenantSchema.verify?.sql ?? null, + replacesShared: renameSharedChange(sourceSchemaChange) + }); + } + return bootstraps; +} diff --git a/pgpm/core/src/apply/types.ts b/pgpm/core/src/apply/types.ts index 2c8bf5018f..8f4b81c7dc 100644 --- a/pgpm/core/src/apply/types.ts +++ b/pgpm/core/src/apply/types.ts @@ -53,6 +53,46 @@ export interface ApplyRouteEntry { toSchema: string; } +/** + * A per-tenant seed object in a reuse spec: the objects that must be + * materialized once *per tenant*. `@pgpmjs/slice`'s `partitionModule` starts + * from these seeds and marks every change that transitively reaches one as + * per-tenant; everything else is proven shared and emitted once. Expressed as + * separate properties (no dotted identity strings), matching {@link ApplyRouteEntry}. + */ +export interface ApplyReuseSeed { + /** Source schema the seed object is defined in (e.g. `catalog`). */ + fromSchema: string; + /** Object namespace: `table`/`view` → relation, `procedure` → function. */ + kind: 'table' | 'view' | 'function' | 'procedure' | 'type'; + /** Unqualified object name (e.g. `products`). */ + name: string; +} + +/** + * Reuse declaration: split the source into a *shared* module (deployed once, + * reused by every tenant via ordinary `requires`) and a *per-tenant* module + * (materialized per instance). The classifier proves the split from the + * `perTenant` seeds; a shared object that would depend on a per-tenant one is + * rejected as unsound rather than silently deployed. + */ +export interface ApplyReuseSpec { + /** + * Source schema → *shared* target schema. Shared objects land here; this + * mapping must be stable across tenant instances so the shared module has a + * deterministic identity and deploys exactly once. + */ + sharedSchema: Record; + /** Per-tenant seed objects (non-empty). */ + perTenant: ApplyReuseSeed[]; + /** + * Optional explicit shared-module name. Defaults to a deterministic name + * derived from the source module and the shared-schema mapping, so two tenant + * proxies over the same source + shared mapping resolve to one shared module. + */ + sharedName?: string; +} + /** The parsed, normalized `pgpm.apply.json` spec. */ export interface PgpmApplySpec { /** @@ -73,6 +113,12 @@ export interface PgpmApplySpec { * table into a tenant schema and a function into a shared schema). */ route?: ApplyRouteEntry[]; + /** + * Reuse declaration: split shared vs per-tenant objects (see + * {@link ApplyReuseSpec}). When present, this proxy expands into two modules — + * a shared module and a per-tenant module that `requires` it. + */ + reuse?: ApplyReuseSpec; /** * Runtime requires of the transpiled output (native extensions and other * modules). Defaults to the source module's own requires. diff --git a/pgpm/core/src/core/class/pgpm.ts b/pgpm/core/src/core/class/pgpm.ts index 6e779525ed..6998973dff 100644 --- a/pgpm/core/src/core/class/pgpm.ts +++ b/pgpm/core/src/core/class/pgpm.ts @@ -13,6 +13,7 @@ import yanse from 'yanse'; import { resolveEffectiveModulePath } from '../../apply/materialize'; import { hasApplySpec, readApplySpec } from '../../apply/apply-spec'; +import { isReuseSpec, resolveSharedModuleName } from '../../apply/reuse'; import { APPLY_SPEC_FILE, ResolvedApplySpec } from '../../apply/types'; import { getAvailableExtensions } from '../../extensions/extensions'; import { generatePlan, writePlan, writePlanFile } from '@pgpmjs/ast/files'; @@ -391,13 +392,30 @@ export class PgpmPackage { const name = spec.name!; if (moduleMap[name]) continue; const source = moduleMap[spec.source.module]; - moduleMap[name] = { - path: path - .dirname(path.relative(this.workspacePath!, file)) - .replace(/\\/g, '/'), - requires: spec.requires ?? source?.requires ?? [], - version: spec.version ?? source?.version ?? '0.0.1' - }; + const relPath = path + .dirname(path.relative(this.workspacePath!, file)) + .replace(/\\/g, '/'); + const baseRequires = spec.requires ?? source?.requires ?? []; + const version = spec.version ?? source?.version ?? '0.0.1'; + + if (isReuseSpec(spec)) { + // A reuse proxy expands into two modules that both materialize from + // this one directory: a shared module (deployed once, reused across + // tenants) and the per-tenant module that `requires` it. Multiple + // tenant proxies over the same source/shared/seed config resolve to + // the same deterministic shared name, so it is added at most once. + const sharedName = resolveSharedModuleName(spec); + if (!moduleMap[sharedName]) { + moduleMap[sharedName] = { path: relPath, requires: baseRequires, version }; + } + moduleMap[name] = { + path: relPath, + requires: [...baseRequires, sharedName], + version + }; + } else { + moduleMap[name] = { path: relPath, requires: baseRequires, version }; + } } return moduleMap;