diff --git a/__fixtures__/apply/substitution/packages/auth-provider/auth-provider.control b/__fixtures__/apply/substitution/packages/auth-provider/auth-provider.control new file mode 100644 index 0000000000..6b327b4c5c --- /dev/null +++ b/__fixtures__/apply/substitution/packages/auth-provider/auth-provider.control @@ -0,0 +1,7 @@ +# auth-provider extension +comment = 'auth-provider extension' +default_version = '0.0.1' +module_pathname = '$libdir/auth-provider' +requires = 'plpgsql' +relocatable = false +superuser = false diff --git a/__fixtures__/apply/substitution/packages/auth-provider/deploy/schemas/app_auth/procedures/current_user_id.sql b/__fixtures__/apply/substitution/packages/auth-provider/deploy/schemas/app_auth/procedures/current_user_id.sql new file mode 100644 index 0000000000..baf0bea6af --- /dev/null +++ b/__fixtures__/apply/substitution/packages/auth-provider/deploy/schemas/app_auth/procedures/current_user_id.sql @@ -0,0 +1,11 @@ +-- Deploy schemas/app_auth/procedures/current_user_id to pg + +-- requires: schemas/app_auth/schema + +BEGIN; + +CREATE FUNCTION app_auth.current_user_id() RETURNS uuid AS $$ + SELECT nullif(current_setting('jwt.claims.user_id', true), '')::uuid; +$$ LANGUAGE sql STABLE; + +COMMIT; diff --git a/__fixtures__/apply/substitution/packages/auth-provider/deploy/schemas/app_auth/schema.sql b/__fixtures__/apply/substitution/packages/auth-provider/deploy/schemas/app_auth/schema.sql new file mode 100644 index 0000000000..ab58d2c61d --- /dev/null +++ b/__fixtures__/apply/substitution/packages/auth-provider/deploy/schemas/app_auth/schema.sql @@ -0,0 +1,7 @@ +-- Deploy schemas/app_auth/schema to pg + +BEGIN; + +CREATE SCHEMA app_auth; + +COMMIT; diff --git a/__fixtures__/apply/substitution/packages/auth-provider/deploy/schemas/app_auth/tables/users/table.sql b/__fixtures__/apply/substitution/packages/auth-provider/deploy/schemas/app_auth/tables/users/table.sql new file mode 100644 index 0000000000..7b13a6b4bd --- /dev/null +++ b/__fixtures__/apply/substitution/packages/auth-provider/deploy/schemas/app_auth/tables/users/table.sql @@ -0,0 +1,12 @@ +-- Deploy schemas/app_auth/tables/users/table to pg + +-- requires: schemas/app_auth/schema + +BEGIN; + +CREATE TABLE app_auth.users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + created_at timestamptz NOT NULL DEFAULT now() +); + +COMMIT; diff --git a/__fixtures__/apply/substitution/packages/auth-provider/pgpm.plan b/__fixtures__/apply/substitution/packages/auth-provider/pgpm.plan new file mode 100644 index 0000000000..e7ae28a7d4 --- /dev/null +++ b/__fixtures__/apply/substitution/packages/auth-provider/pgpm.plan @@ -0,0 +1,7 @@ +%syntax-version=1.0.0 +%project=auth-provider +%uri=auth-provider + +schemas/app_auth/schema 2024-01-01T00:00:00Z Dev # add app_auth schema +schemas/app_auth/tables/users/table [schemas/app_auth/schema] 2024-01-01T00:00:01Z Dev # add users table +schemas/app_auth/procedures/current_user_id [schemas/app_auth/schema] 2024-01-01T00:00:02Z Dev # add current_user_id diff --git a/__fixtures__/apply/substitution/packages/auth-provider/revert/schemas/app_auth/procedures/current_user_id.sql b/__fixtures__/apply/substitution/packages/auth-provider/revert/schemas/app_auth/procedures/current_user_id.sql new file mode 100644 index 0000000000..7d537c3737 --- /dev/null +++ b/__fixtures__/apply/substitution/packages/auth-provider/revert/schemas/app_auth/procedures/current_user_id.sql @@ -0,0 +1,7 @@ +-- Revert schemas/app_auth/procedures/current_user_id from pg + +BEGIN; + +DROP FUNCTION app_auth.current_user_id(); + +COMMIT; diff --git a/__fixtures__/apply/substitution/packages/auth-provider/revert/schemas/app_auth/schema.sql b/__fixtures__/apply/substitution/packages/auth-provider/revert/schemas/app_auth/schema.sql new file mode 100644 index 0000000000..ca746a8f7b --- /dev/null +++ b/__fixtures__/apply/substitution/packages/auth-provider/revert/schemas/app_auth/schema.sql @@ -0,0 +1,7 @@ +-- Revert schemas/app_auth/schema from pg + +BEGIN; + +DROP SCHEMA app_auth; + +COMMIT; diff --git a/__fixtures__/apply/substitution/packages/auth-provider/revert/schemas/app_auth/tables/users/table.sql b/__fixtures__/apply/substitution/packages/auth-provider/revert/schemas/app_auth/tables/users/table.sql new file mode 100644 index 0000000000..1fd77c06e2 --- /dev/null +++ b/__fixtures__/apply/substitution/packages/auth-provider/revert/schemas/app_auth/tables/users/table.sql @@ -0,0 +1,7 @@ +-- Revert schemas/app_auth/tables/users/table from pg + +BEGIN; + +DROP TABLE app_auth.users; + +COMMIT; diff --git a/__fixtures__/apply/substitution/packages/auth-provider/verify/schemas/app_auth/procedures/current_user_id.sql b/__fixtures__/apply/substitution/packages/auth-provider/verify/schemas/app_auth/procedures/current_user_id.sql new file mode 100644 index 0000000000..6fd701b05d --- /dev/null +++ b/__fixtures__/apply/substitution/packages/auth-provider/verify/schemas/app_auth/procedures/current_user_id.sql @@ -0,0 +1,7 @@ +-- Verify schemas/app_auth/procedures/current_user_id on pg + +BEGIN; + +SELECT app_auth.current_user_id(); + +ROLLBACK; diff --git a/__fixtures__/apply/substitution/packages/auth-provider/verify/schemas/app_auth/schema.sql b/__fixtures__/apply/substitution/packages/auth-provider/verify/schemas/app_auth/schema.sql new file mode 100644 index 0000000000..6cfeb0f9be --- /dev/null +++ b/__fixtures__/apply/substitution/packages/auth-provider/verify/schemas/app_auth/schema.sql @@ -0,0 +1,7 @@ +-- Verify schemas/app_auth/schema on pg + +BEGIN; + +SELECT pg_catalog.has_schema_privilege('app_auth', 'usage'); + +ROLLBACK; diff --git a/__fixtures__/apply/substitution/packages/auth-provider/verify/schemas/app_auth/tables/users/table.sql b/__fixtures__/apply/substitution/packages/auth-provider/verify/schemas/app_auth/tables/users/table.sql new file mode 100644 index 0000000000..a0f3663553 --- /dev/null +++ b/__fixtures__/apply/substitution/packages/auth-provider/verify/schemas/app_auth/tables/users/table.sql @@ -0,0 +1,7 @@ +-- Verify schemas/app_auth/tables/users/table on pg + +BEGIN; + +SELECT id, created_at FROM app_auth.users WHERE FALSE; + +ROLLBACK; diff --git a/__fixtures__/apply/substitution/packages/crm-app/pgpm.apply.json b/__fixtures__/apply/substitution/packages/crm-app/pgpm.apply.json new file mode 100644 index 0000000000..eed5143cc2 --- /dev/null +++ b/__fixtures__/apply/substitution/packages/crm-app/pgpm.apply.json @@ -0,0 +1,25 @@ +{ + "source": "crm-module", + "schemas": { + "app": "crm" + }, + "exclude": { + "schemas": ["identity"] + }, + "route": [ + { + "fromSchema": "identity", + "kind": "table", + "name": "users", + "toSchema": "app_auth" + }, + { + "fromSchema": "identity", + "kind": "function", + "name": "current_actor", + "toSchema": "app_auth", + "toName": "current_user_id" + } + ], + "requires": ["auth-provider"] +} diff --git a/__fixtures__/apply/substitution/packages/crm-module/crm-module.control b/__fixtures__/apply/substitution/packages/crm-module/crm-module.control new file mode 100644 index 0000000000..7a55bdda56 --- /dev/null +++ b/__fixtures__/apply/substitution/packages/crm-module/crm-module.control @@ -0,0 +1,7 @@ +# crm-module extension +comment = 'crm-module extension' +default_version = '0.0.1' +module_pathname = '$libdir/crm-module' +requires = 'plpgsql' +relocatable = false +superuser = false diff --git a/__fixtures__/apply/substitution/packages/crm-module/deploy/schemas/app/policies/notes_owner.sql b/__fixtures__/apply/substitution/packages/crm-module/deploy/schemas/app/policies/notes_owner.sql new file mode 100644 index 0000000000..df11dc9906 --- /dev/null +++ b/__fixtures__/apply/substitution/packages/crm-module/deploy/schemas/app/policies/notes_owner.sql @@ -0,0 +1,13 @@ +-- Deploy schemas/app/policies/notes_owner to pg + +-- requires: schemas/app/tables/notes/table +-- requires: schemas/identity/procedures/current_actor + +BEGIN; + +ALTER TABLE app.notes ENABLE ROW LEVEL SECURITY; + +CREATE POLICY notes_owner ON app.notes + USING (owner = identity.current_actor()); + +COMMIT; diff --git a/__fixtures__/apply/substitution/packages/crm-module/deploy/schemas/app/schema.sql b/__fixtures__/apply/substitution/packages/crm-module/deploy/schemas/app/schema.sql new file mode 100644 index 0000000000..1e7b0f5a75 --- /dev/null +++ b/__fixtures__/apply/substitution/packages/crm-module/deploy/schemas/app/schema.sql @@ -0,0 +1,7 @@ +-- Deploy schemas/app/schema to pg + +BEGIN; + +CREATE SCHEMA app; + +COMMIT; diff --git a/__fixtures__/apply/substitution/packages/crm-module/deploy/schemas/app/tables/notes/table.sql b/__fixtures__/apply/substitution/packages/crm-module/deploy/schemas/app/tables/notes/table.sql new file mode 100644 index 0000000000..a9de2a46ce --- /dev/null +++ b/__fixtures__/apply/substitution/packages/crm-module/deploy/schemas/app/tables/notes/table.sql @@ -0,0 +1,18 @@ +-- Deploy schemas/app/tables/notes/table to pg + +-- requires: schemas/app/schema +-- requires: schemas/identity/tables/users/table + +BEGIN; + +CREATE TABLE app.notes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + owner uuid NOT NULL, + body text NOT NULL +); + +ALTER TABLE app.notes + ADD CONSTRAINT notes_owner_fkey + FOREIGN KEY (owner) REFERENCES identity.users (id); + +COMMIT; diff --git a/__fixtures__/apply/substitution/packages/crm-module/deploy/schemas/identity/procedures/current_actor.sql b/__fixtures__/apply/substitution/packages/crm-module/deploy/schemas/identity/procedures/current_actor.sql new file mode 100644 index 0000000000..6a20176234 --- /dev/null +++ b/__fixtures__/apply/substitution/packages/crm-module/deploy/schemas/identity/procedures/current_actor.sql @@ -0,0 +1,11 @@ +-- Deploy schemas/identity/procedures/current_actor to pg + +-- requires: schemas/identity/schema + +BEGIN; + +CREATE FUNCTION identity.current_actor() RETURNS uuid AS $$ + SELECT nullif(current_setting('request.claims.sub', true), '')::uuid; +$$ LANGUAGE sql STABLE; + +COMMIT; diff --git a/__fixtures__/apply/substitution/packages/crm-module/deploy/schemas/identity/schema.sql b/__fixtures__/apply/substitution/packages/crm-module/deploy/schemas/identity/schema.sql new file mode 100644 index 0000000000..70c0ae7161 --- /dev/null +++ b/__fixtures__/apply/substitution/packages/crm-module/deploy/schemas/identity/schema.sql @@ -0,0 +1,7 @@ +-- Deploy schemas/identity/schema to pg + +BEGIN; + +CREATE SCHEMA identity; + +COMMIT; diff --git a/__fixtures__/apply/substitution/packages/crm-module/deploy/schemas/identity/tables/users/table.sql b/__fixtures__/apply/substitution/packages/crm-module/deploy/schemas/identity/tables/users/table.sql new file mode 100644 index 0000000000..7216ca155f --- /dev/null +++ b/__fixtures__/apply/substitution/packages/crm-module/deploy/schemas/identity/tables/users/table.sql @@ -0,0 +1,13 @@ +-- Deploy schemas/identity/tables/users/table to pg + +-- requires: schemas/identity/schema + +BEGIN; + +CREATE TABLE identity.users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + secret_token text, + recovery_token text +); + +COMMIT; diff --git a/__fixtures__/apply/substitution/packages/crm-module/pgpm.plan b/__fixtures__/apply/substitution/packages/crm-module/pgpm.plan new file mode 100644 index 0000000000..2ac22edd92 --- /dev/null +++ b/__fixtures__/apply/substitution/packages/crm-module/pgpm.plan @@ -0,0 +1,10 @@ +%syntax-version=1.0.0 +%project=crm-module +%uri=crm-module + +schemas/identity/schema 2024-01-01T00:00:00Z Dev # add identity schema +schemas/identity/tables/users/table [schemas/identity/schema] 2024-01-01T00:00:01Z Dev # add identity users +schemas/identity/procedures/current_actor [schemas/identity/schema] 2024-01-01T00:00:02Z Dev # add current_actor +schemas/app/schema 2024-01-01T00:00:03Z Dev # add app schema +schemas/app/tables/notes/table [schemas/app/schema schemas/identity/tables/users/table] 2024-01-01T00:00:04Z Dev # add notes table +schemas/app/policies/notes_owner [schemas/app/tables/notes/table schemas/identity/procedures/current_actor] 2024-01-01T00:00:05Z Dev # add notes policy diff --git a/__fixtures__/apply/substitution/packages/crm-module/revert/schemas/app/policies/notes_owner.sql b/__fixtures__/apply/substitution/packages/crm-module/revert/schemas/app/policies/notes_owner.sql new file mode 100644 index 0000000000..825a0b34cb --- /dev/null +++ b/__fixtures__/apply/substitution/packages/crm-module/revert/schemas/app/policies/notes_owner.sql @@ -0,0 +1,7 @@ +-- Revert schemas/app/policies/notes_owner from pg + +BEGIN; + +DROP POLICY notes_owner ON app.notes; + +COMMIT; diff --git a/__fixtures__/apply/substitution/packages/crm-module/revert/schemas/app/schema.sql b/__fixtures__/apply/substitution/packages/crm-module/revert/schemas/app/schema.sql new file mode 100644 index 0000000000..6c51e1b27f --- /dev/null +++ b/__fixtures__/apply/substitution/packages/crm-module/revert/schemas/app/schema.sql @@ -0,0 +1,7 @@ +-- Revert schemas/app/schema from pg + +BEGIN; + +DROP SCHEMA app; + +COMMIT; diff --git a/__fixtures__/apply/substitution/packages/crm-module/revert/schemas/app/tables/notes/table.sql b/__fixtures__/apply/substitution/packages/crm-module/revert/schemas/app/tables/notes/table.sql new file mode 100644 index 0000000000..60d26ab957 --- /dev/null +++ b/__fixtures__/apply/substitution/packages/crm-module/revert/schemas/app/tables/notes/table.sql @@ -0,0 +1,7 @@ +-- Revert schemas/app/tables/notes/table from pg + +BEGIN; + +DROP TABLE app.notes; + +COMMIT; diff --git a/__fixtures__/apply/substitution/packages/crm-module/revert/schemas/identity/procedures/current_actor.sql b/__fixtures__/apply/substitution/packages/crm-module/revert/schemas/identity/procedures/current_actor.sql new file mode 100644 index 0000000000..d661d886eb --- /dev/null +++ b/__fixtures__/apply/substitution/packages/crm-module/revert/schemas/identity/procedures/current_actor.sql @@ -0,0 +1,7 @@ +-- Revert schemas/identity/procedures/current_actor from pg + +BEGIN; + +DROP FUNCTION identity.current_actor(); + +COMMIT; diff --git a/__fixtures__/apply/substitution/packages/crm-module/revert/schemas/identity/schema.sql b/__fixtures__/apply/substitution/packages/crm-module/revert/schemas/identity/schema.sql new file mode 100644 index 0000000000..ba8791c444 --- /dev/null +++ b/__fixtures__/apply/substitution/packages/crm-module/revert/schemas/identity/schema.sql @@ -0,0 +1,7 @@ +-- Revert schemas/identity/schema from pg + +BEGIN; + +DROP SCHEMA identity; + +COMMIT; diff --git a/__fixtures__/apply/substitution/packages/crm-module/revert/schemas/identity/tables/users/table.sql b/__fixtures__/apply/substitution/packages/crm-module/revert/schemas/identity/tables/users/table.sql new file mode 100644 index 0000000000..ae745abf83 --- /dev/null +++ b/__fixtures__/apply/substitution/packages/crm-module/revert/schemas/identity/tables/users/table.sql @@ -0,0 +1,7 @@ +-- Revert schemas/identity/tables/users/table from pg + +BEGIN; + +DROP TABLE identity.users; + +COMMIT; diff --git a/__fixtures__/apply/substitution/packages/crm-module/verify/schemas/app/policies/notes_owner.sql b/__fixtures__/apply/substitution/packages/crm-module/verify/schemas/app/policies/notes_owner.sql new file mode 100644 index 0000000000..224f7c6aaa --- /dev/null +++ b/__fixtures__/apply/substitution/packages/crm-module/verify/schemas/app/policies/notes_owner.sql @@ -0,0 +1,7 @@ +-- Verify schemas/app/policies/notes_owner on pg + +BEGIN; + +SELECT owner FROM app.notes WHERE FALSE; + +ROLLBACK; diff --git a/__fixtures__/apply/substitution/packages/crm-module/verify/schemas/app/schema.sql b/__fixtures__/apply/substitution/packages/crm-module/verify/schemas/app/schema.sql new file mode 100644 index 0000000000..234b95ba53 --- /dev/null +++ b/__fixtures__/apply/substitution/packages/crm-module/verify/schemas/app/schema.sql @@ -0,0 +1,7 @@ +-- Verify schemas/app/schema on pg + +BEGIN; + +SELECT pg_catalog.has_schema_privilege('app', 'usage'); + +ROLLBACK; diff --git a/__fixtures__/apply/substitution/packages/crm-module/verify/schemas/app/tables/notes/table.sql b/__fixtures__/apply/substitution/packages/crm-module/verify/schemas/app/tables/notes/table.sql new file mode 100644 index 0000000000..36cbe7f2a8 --- /dev/null +++ b/__fixtures__/apply/substitution/packages/crm-module/verify/schemas/app/tables/notes/table.sql @@ -0,0 +1,7 @@ +-- Verify schemas/app/tables/notes/table on pg + +BEGIN; + +SELECT id, owner, body FROM app.notes WHERE FALSE; + +ROLLBACK; diff --git a/__fixtures__/apply/substitution/packages/crm-module/verify/schemas/identity/procedures/current_actor.sql b/__fixtures__/apply/substitution/packages/crm-module/verify/schemas/identity/procedures/current_actor.sql new file mode 100644 index 0000000000..614b5fc883 --- /dev/null +++ b/__fixtures__/apply/substitution/packages/crm-module/verify/schemas/identity/procedures/current_actor.sql @@ -0,0 +1,7 @@ +-- Verify schemas/identity/procedures/current_actor on pg + +BEGIN; + +SELECT identity.current_actor(); + +ROLLBACK; diff --git a/__fixtures__/apply/substitution/packages/crm-module/verify/schemas/identity/schema.sql b/__fixtures__/apply/substitution/packages/crm-module/verify/schemas/identity/schema.sql new file mode 100644 index 0000000000..78d6928fe7 --- /dev/null +++ b/__fixtures__/apply/substitution/packages/crm-module/verify/schemas/identity/schema.sql @@ -0,0 +1,7 @@ +-- Verify schemas/identity/schema on pg + +BEGIN; + +SELECT pg_catalog.has_schema_privilege('identity', 'usage'); + +ROLLBACK; diff --git a/__fixtures__/apply/substitution/packages/crm-module/verify/schemas/identity/tables/users/table.sql b/__fixtures__/apply/substitution/packages/crm-module/verify/schemas/identity/tables/users/table.sql new file mode 100644 index 0000000000..762d90ab35 --- /dev/null +++ b/__fixtures__/apply/substitution/packages/crm-module/verify/schemas/identity/tables/users/table.sql @@ -0,0 +1,7 @@ +-- Verify schemas/identity/tables/users/table on pg + +BEGIN; + +SELECT id FROM identity.users WHERE FALSE; + +ROLLBACK; diff --git a/__fixtures__/apply/substitution/pgpm.json b/__fixtures__/apply/substitution/pgpm.json new file mode 100644 index 0000000000..e251a6b60d --- /dev/null +++ b/__fixtures__/apply/substitution/pgpm.json @@ -0,0 +1,5 @@ +{ + "packages": [ + "packages/*" + ] +} diff --git a/pgpm/core/__tests__/apply/apply-routing.test.ts b/pgpm/core/__tests__/apply/apply-routing.test.ts index b91b09e023..995be398fe 100644 --- a/pgpm/core/__tests__/apply/apply-routing.test.ts +++ b/pgpm/core/__tests__/apply/apply-routing.test.ts @@ -44,7 +44,7 @@ describe('apply spec parsing — object routes', () => { }); it.each([ - [{ source: 'x' }, /at least one of "schemas", "route", "extensions", or "roles"/], + [{ source: 'x' }, /at least one of "schemas", "route", "extensions", "roles", or "exclude"/], [{ source: 'x', route: [] }, /"route" must be a non-empty array/], [{ source: 'x', route: [{ fromSchema: 'a', kind: 'widget', name: 'n', toSchema: 'b' }] }, /route" entry/], [{ source: 'x', route: [{ fromSchema: 'a', name: 'n', toSchema: 'b' }] }, /route" entry/], diff --git a/pgpm/core/__tests__/apply/apply-substitution.test.ts b/pgpm/core/__tests__/apply/apply-substitution.test.ts new file mode 100644 index 0000000000..87c8ec953f --- /dev/null +++ b/pgpm/core/__tests__/apply/apply-substitution.test.ts @@ -0,0 +1,208 @@ +import { rmSync } from 'fs'; + +import { + clearApplyMaterializationCache, + materializeApplyModule, + parseApplySpec, + readApplySpec +} 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/crm-app/pgpm.apply.json'; + +describe('apply spec parsing — exclude + rebind routes', () => { + it('accepts an exclude block with rebind routes', () => { + const spec = parseApplySpec( + JSON.stringify({ + source: 'crm-module', + schemas: { app: 'crm' }, + exclude: { schemas: ['identity'] }, + route: [ + { fromSchema: 'identity', kind: 'table', name: 'users', toSchema: 'app_auth' }, + { + fromSchema: 'identity', + kind: 'function', + name: 'current_actor', + toSchema: null, + toName: 'current_user_id' + } + ] + }), + at + ); + expect(spec.exclude).toEqual({ schemas: ['identity'] }); + expect(spec.route![1]).toEqual({ + fromSchema: 'identity', + kind: 'function', + name: 'current_actor', + toSchema: null, + toName: 'current_user_id' + }); + }); + + it('accepts a pure rename route (toName only)', () => { + const spec = parseApplySpec( + JSON.stringify({ + source: 'crm-module', + route: [{ fromSchema: 'identity', kind: 'function', name: 'current_actor', toName: 'whoami' }] + }), + at + ); + expect(spec.route![0].toName).toBe('whoami'); + expect(spec.route![0].toSchema).toBeUndefined(); + }); + + it('accepts an exclude-only spec', () => { + const spec = parseApplySpec( + JSON.stringify({ source: 'crm-module', exclude: { schemas: ['identity'] } }), + at + ); + expect(spec.exclude!.schemas).toEqual(['identity']); + }); + + it.each([ + [{ source: 'x', exclude: {} }, /"exclude" must be/], + [{ source: 'x', exclude: { schemas: [] } }, /"exclude" must be/], + [{ source: 'x', exclude: { schemas: ['a', ''] } }, /"exclude" must be/], + [ + { source: 'x', route: [{ fromSchema: 'a', kind: 'table', name: 'n' }] }, + /"toSchema" \(schema \| null\) and\/or "toName"/ + ], + [ + { source: 'x', route: [{ fromSchema: 'a', kind: 'table', name: 'n', toSchema: '' }] }, + /route" entry/ + ], + [ + { source: 'x', route: [{ fromSchema: 'a', kind: 'table', name: 'n', toName: '' }] }, + /route" entry/ + ] + ])('rejects invalid exclude/route specs %#', (spec, err) => { + expect(() => parseApplySpec(JSON.stringify(spec), at)).toThrow(err); + }); +}); + +describe('materializeApplyModule — subsystem substitution', () => { + let fixture: TestFixture; + + beforeAll(() => { + fixture = new TestFixture('apply', 'substitution'); + }); + + afterAll(() => fixture.cleanup()); + + it('drops the excluded subsystem and rebinds survivors onto the provider', async () => { + const sourceDir = fixture.fixturePath('packages', 'crm-module'); + const spec = readApplySpec(fixture.fixturePath('packages', 'crm-app')); + const { bundle, outDir } = await materializeApplyModule({ sourceDir, spec }); + try { + // excluded changes keep their identity as emptied tombstones + const identityUsers = bundle.changes.find( + c => c.name === 'schemas/identity/tables/users/table' + )!; + expect(identityUsers.deploy!.sql).not.toMatch(/CREATE TABLE/i); + expect(identityUsers.revert!.sql).not.toMatch(/DROP TABLE/i); + expect(identityUsers.verify!.sql).not.toMatch(/identity\.users/); + + const identitySchema = bundle.changes.find(c => c.name === 'schemas/identity/schema')!; + expect(identitySchema.deploy!.sql).not.toMatch(/CREATE SCHEMA/i); + expect(identitySchema.verify!.sql).not.toMatch(/has_schema_privilege/i); + + const actor = bundle.changes.find( + c => c.name === 'schemas/identity/procedures/current_actor' + )!; + expect(actor.deploy!.sql).not.toMatch(/CREATE FUNCTION/i); + + // surviving app changes are transpiled and rebound onto the provider + const notes = bundle.changes.find(c => c.name === 'schemas/crm/tables/notes/table')!; + expect(notes.deploy!.sql).toMatch(/CREATE TABLE crm\.notes/i); + expect(notes.deploy!.sql).toMatch(/REFERENCES app_auth\.users/i); + expect(notes.deploy!.sql).not.toMatch(/identity\./); + + const policy = bundle.changes.find(c => c.name === 'schemas/crm/policies/notes_owner')!; + expect(policy.deploy!.sql).toMatch(/app_auth\.current_user_id\s*\(\)/i); + // the rebind rewrote the predicate (the `-- requires:` header still + // names the source change — the tombstone keeps its identity) + expect(policy.deploy!.sql).not.toMatch(/identity\.current_actor/); + } finally { + rmSync(outDir, { recursive: true, force: true }); + } + }); + + it('refuses exclusion when a surviving reference has no rebind target', async () => { + const sourceDir = fixture.fixturePath('packages', 'crm-module'); + const spec = parseApplySpec( + JSON.stringify({ + source: 'crm-module', + name: 'crm-app', + schemas: { app: 'crm' }, + exclude: { schemas: ['identity'] }, + route: [{ fromSchema: 'identity', kind: 'table', name: 'users', toSchema: 'app_auth' }] + }), + at + ); + await expect(materializeApplyModule({ sourceDir, spec })).rejects.toThrow( + /identity\.current_actor.*no route\/rebind target/s + ); + }); +}); + +describe('apply substitution deployment (e2e)', () => { + let fixture: CoreDeployTestFixture; + let db: TestDatabase; + + beforeAll(() => { + fixture = new CoreDeployTestFixture('apply', 'substitution'); + }); + + afterAll(async () => { + await fixture.cleanup(); + }); + + beforeEach(async () => { + clearApplyMaterializationCache(); + db = await fixture.setupTestDatabase(); + }); + + test('deploys the substituted instance against the installed provider', async () => { + await fixture.deployModule('auth-provider', db.name, ['apply', 'substitution']); + await fixture.deployModule('crm-app', db.name, ['apply', 'substitution']); + + // the excluded subsystem never lands + expect(await db.exists('schema', 'identity')).toBe(false); + + // the provider and the substituted app both do + expect(await db.exists('table', 'app_auth.users')).toBe(true); + expect(await db.exists('table', 'crm.notes')).toBe(true); + + // the FK was rebound onto the provider's users table + const user = await db.query( + `INSERT INTO app_auth.users DEFAULT VALUES RETURNING id` + ); + const uid = user.rows[0].id; + await db.query(`INSERT INTO crm.notes (owner, body) VALUES ($1, 'hi')`, [uid]); + await expect( + db.query(`INSERT INTO crm.notes (owner, body) VALUES (gen_random_uuid(), 'nope')`) + ).rejects.toThrow(/foreign key/i); + + // the RLS policy predicate was rebound onto the provider accessor + const policy = await db.query( + `SELECT qual FROM pg_policies WHERE schemaname = 'crm' AND tablename = 'notes'` + ); + expect(policy.rows[0].qual).toContain('current_user_id'); + expect(policy.rows[0].qual).not.toContain('current_actor'); + }); + + test('verify and revert work against the substituted instance', async () => { + await fixture.deployModule('auth-provider', db.name, ['apply', 'substitution']); + await fixture.deployModule('crm-app', db.name, ['apply', 'substitution']); + + await fixture.verifyModule('crm-app', db.name, ['apply', 'substitution']); + + await fixture.revertModule('crm-app', db.name, ['apply', 'substitution']); + expect(await db.exists('table', 'crm.notes')).toBe(false); + // the provider is an independent module and survives the instance revert + expect(await db.exists('table', 'app_auth.users')).toBe(true); + }); +}); diff --git a/pgpm/core/src/apply/apply-spec.ts b/pgpm/core/src/apply/apply-spec.ts index 32a119acbf..3cb0230f33 100644 --- a/pgpm/core/src/apply/apply-spec.ts +++ b/pgpm/core/src/apply/apply-spec.ts @@ -62,6 +62,12 @@ export function parseApplySpec(content: string, specPath: string): ResolvedApply throw new Error(`${specPath}: "route" must be a non-empty array of route entries`); } for (const entry of parsed.route) { + const validToSchema = + entry?.toSchema === undefined || + entry.toSchema === null || + (typeof entry.toSchema === 'string' && !!entry.toSchema); + const validToName = + entry?.toName === undefined || (typeof entry.toName === 'string' && !!entry.toName); if ( !entry || typeof entry !== 'object' || @@ -69,14 +75,15 @@ export function parseApplySpec(content: string, specPath: string): ResolvedApply !entry.fromSchema || typeof entry.name !== 'string' || !entry.name || - typeof entry.toSchema !== 'string' || - !entry.toSchema || + !validToSchema || + !validToName || + (entry.toSchema === undefined && entry.toName === undefined) || !ROUTE_KINDS.includes(entry.kind) ) { throw new Error( `${specPath}: each "route" entry needs { fromSchema, kind (${ROUTE_KINDS.join( '|' - )}), name, toSchema } as non-empty strings` + )}), name } plus "toSchema" (schema | null) and/or "toName"` ); } } @@ -178,9 +185,26 @@ export function parseApplySpec(content: string, specPath: string): ResolvedApply throw new Error(`${specPath}: "roles" must be a non-empty string → string map`); } - if (!hasSchemas && !hasRoute && !hasExtensions && !hasRoles) { + const hasExclude = parsed.exclude !== undefined; + if (hasExclude) { + const ex = parsed.exclude; + if ( + typeof ex !== 'object' || + ex === null || + Array.isArray(ex) || + !Array.isArray(ex.schemas) || + ex.schemas.length === 0 || + ex.schemas.some((s: any) => typeof s !== 'string' || !s) + ) { + throw new Error( + `${specPath}: "exclude" must be { schemas: [non-empty schema names] }` + ); + } + } + + if (!hasSchemas && !hasRoute && !hasExtensions && !hasRoles && !hasExclude) { throw new Error( - `${specPath}: at least one of "schemas", "route", "extensions", or "roles" is required` + `${specPath}: at least one of "schemas", "route", "extensions", "roles", or "exclude" is required` ); } diff --git a/pgpm/core/src/apply/materialize.ts b/pgpm/core/src/apply/materialize.ts index 986499f75a..c94a73dd32 100644 --- a/pgpm/core/src/apply/materialize.ts +++ b/pgpm/core/src/apply/materialize.ts @@ -9,7 +9,8 @@ import { transpileBundle, verifyBundle } from '@pgpmjs/bundle'; -import { loadModule, makeSchemaTranspiler, SchemaTransformPass } from '@pgpmjs/transform'; +import { buildSchemaRouter, loadModule, makeSchemaTranspiler, SchemaTransformPass } from '@pgpmjs/transform'; +import { blankScriptSql, excludeSubsystem, stripSubsystemSql } from '@pgpmjs/slice'; import { ModuleMap } from '../modules/modules'; import { hasApplySpec, readApplySpec } from './apply-spec'; @@ -43,6 +44,66 @@ const schemaNameLiteralPass: SchemaTransformPass = (content, schemaMapping) => { }); }; +/** + * Build the per-script subsystem stripper for an `exclude` spec, verifying + * cascade safety across the *whole* source bundle first: every deploy-script + * reference into an excluded schema (FKs, calls, policy predicates) must have + * a rebind route, or materialization refuses with each unsatisfied reference + * named. Checking bundle-wide (not per script) is what catches cross-change + * dependencies on the excluded subsystem. + */ +function makeSubsystemStripper( + source: MigrationBundle, + spec: ResolvedApplySpec, + instanceName: string +): { strip: (sql: string, change: string) => string; excludedChanges: Set } { + const selector = { schemas: spec.exclude!.schemas }; + const rebinds = buildSchemaRouter({ schemaMap: spec.schemas, routes: spec.route }); + + const fullDeploySql = source.changes + .map(c => c.deploy?.sql ?? '') + .filter(Boolean) + .join('\n'); + const analysis = excludeSubsystem(fullDeploySql, selector, { rebinds }); + + if (analysis.unsatisfied.length > 0) { + const detail = [ + ...new Set( + analysis.unsatisfied.map( + u => `${u.object.schema}.${u.object.name}${u.fk ? ' (foreign key target)' : ''}` + ) + ) + ].join(', '); + throw new Error( + `Cannot exclude schema(s) ${spec.exclude!.schemas.join(', ')} from ` + + `"${spec.source.module}" as "${instanceName}": surviving statements still reference ` + + `${detail} with no route/rebind target. Add "route" entries substituting each ` + + `referenced object, or keep the subsystem.` + ); + } + + // A change whose deploy consists entirely of subsystem statements (plus + // transaction control) is excluded *as a change*: its verify/revert scripts + // target dropped objects the classifier can't always see (bare DROPs, + // catalog probes), so all three scripts are blanked together. + const excludedChanges = new Set(); + for (const change of source.changes) { + if (!change.deploy) continue; + const stripped = stripSubsystemSql(change.deploy.sql, selector); + const survivors = stripped.result.kept.filter( + i => !stripped.dropped.includes(i) && stripped.result.statements[i].nodeTag !== 'TransactionStmt' + ); + if (stripped.dropped.length > 0 && survivors.length === 0) { + excludedChanges.add(change.name); + } + } + + const strip = (sql: string, change: string): string => + excludedChanges.has(change) ? blankScriptSql(sql) : stripSubsystemSql(sql, selector).sql; + + return { strip, excludedChanges }; +} + export interface MaterializeApplyResult { /** The transpiled, content-addressed bundle that was materialized. */ bundle: MigrationBundle; @@ -87,10 +148,16 @@ export async function materializeApplyModule( const targetSchemas = [ ...new Set([ ...Object.values(spec.schemas ?? {}), - ...(spec.route ?? []).map(r => r.toSchema) + ...(spec.route ?? []) + .map(r => r.toSchema) + .filter((s): s is string => typeof s === 'string') ]) ]; + const stripSubsystem = spec.exclude + ? makeSubsystemStripper(source, spec, instanceName) + : undefined; + const { renameChange, transformScript, result } = makeSchemaTranspiler({ schemaMap: spec.schemas, routes: spec.route, @@ -102,9 +169,15 @@ export async function materializeApplyModule( } }); + // Excluded changes keep their source identity (an emptied tombstone) — + // renaming them into the replacement provider's namespace would be a lie. const transpiled = transpileBundle(source, { - renameChange, - transformScript, + renameChange: stripSubsystem + ? (name: string) => (stripSubsystem.excludedChanges.has(name) ? name : renameChange(name)) + : renameChange, + transformScript: stripSubsystem + ? (sql, ctx) => transformScript(stripSubsystem.strip(sql, ctx.change), ctx) + : transformScript, renameModule: instanceName, provenance: { appliedFrom: spec.source.module, diff --git a/pgpm/core/src/apply/profile.ts b/pgpm/core/src/apply/profile.ts index da377d4729..f0b3551499 100644 --- a/pgpm/core/src/apply/profile.ts +++ b/pgpm/core/src/apply/profile.ts @@ -34,7 +34,8 @@ export function resolveEffectiveApplySpec( schemas: spec.schemas, route: spec.route, extensions: spec.extensions, - roles: spec.roles + roles: spec.roles, + exclude: spec.exclude }); return { ...spec, ...merged }; } diff --git a/pgpm/slice/src/exclude.ts b/pgpm/slice/src/exclude.ts index b85222e4aa..a6f47955e3 100644 --- a/pgpm/slice/src/exclude.ts +++ b/pgpm/slice/src/exclude.ts @@ -1,4 +1,5 @@ import { classifyStatements, SchemaRouter, StatementFacts } from '@pgpmjs/transform'; +import { parseSql } from 'plpgsql-parser'; import { SqlObjectRef } from './refs'; @@ -248,3 +249,117 @@ export function excludeSubsystem( statements }; } + +/** + * Blank a script: keep its leading text (pgpm header) and transaction + * statements, drop everything else. Used for the scripts of a change whose + * deploy is entirely inside an excluded subsystem — its verify/revert bodies + * target dropped objects, so they must go with it. + */ +export function blankScriptSql(sql: string): string { + const parsed = parseSql(sql) as { + stmts: Array<{ stmt: Record; stmt_location?: number; stmt_len?: number }>; + }; + const prefix = + parsed.stmts.length > 0 ? sql.slice(0, parsed.stmts[0].stmt_location ?? 0) : sql; + const pieces: string[] = []; + for (const s of parsed.stmts) { + if (!('TransactionStmt' in s.stmt)) continue; + const start = s.stmt_location ?? 0; + const len = s.stmt_len ?? sql.length - start; + pieces.push(sql.slice(start, start + len).trim() + ';'); + } + return prefix + pieces.join('\n\n') + (pieces.length > 0 ? '\n' : ''); +} + +export interface StripSubsystemResult { + /** The surviving SQL, with subsystem statements removed. */ + sql: string; + /** The analysis behind the removal (contract, unsatisfied, warnings). */ + result: ExcludeResult; + /** Every removed statement index (excluded + opaque subsystem-targeted). */ + dropped: number[]; +} + +/** Qualified name (`[schema, name]` items) from a raw parse-tree name list. */ +function qualifiedName(node: unknown): SqlObjectRef | undefined { + const items = (node as { List?: { items?: Array<{ String?: { sval?: string } }> } })?.List + ?.items; + if (!items) return undefined; + const parts = items.map(x => x.String?.sval).filter((s): s is string => typeof s === 'string'); + if (parts.length === 2) return { schema: parts[0], name: parts[1] }; + if (parts.length === 1) return { schema: null, name: parts[0] }; + return undefined; +} + +/** + * Whether an opaque statement (invisible to classification) provably targets + * only subsystem objects, so it can be removed along with them: bare `DROP` + * of subsystem objects, `COMMENT ON` a subsystem object. + */ +function opaqueTargetsSubsystem(stmt: Record, schemas: Set): boolean { + if (stmt.DropStmt?.objects) { + const objects: unknown[] = stmt.DropStmt.objects; + const refs = objects.map(qualifiedName); + return refs.length > 0 && refs.every(r => r !== undefined && inSubsystem(r, schemas)); + } + if (stmt.CommentStmt?.object) { + const ref = qualifiedName(stmt.CommentStmt.object); + return ref !== undefined && inSubsystem(ref, schemas); + } + return false; +} + +/** + * Remove a subsystem's statements from a SQL script, preserving the original + * text of every survivor (no reformat — statements are sliced out of the + * source by parser-reported location). Also removes opaque statements that + * provably target only subsystem objects (`DROP`/`COMMENT ON` them), which + * membership classification alone keeps. + * + * Requires `await loadModule()` first (same as every other sync API here). + * This performs no safety check by itself — callers decide what to do with + * `result.unsatisfied` (typically: refuse before ever writing output). + */ +export function stripSubsystemSql( + sql: string, + selector: SubsystemSelector, + options: { rebinds?: SchemaRouter } = {} +): StripSubsystemResult { + const result = excludeSubsystem(sql, selector, options); + const schemas = new Set(selector.schemas); + + const parsed = parseSql(sql) as { + stmts: Array<{ stmt: Record; stmt_location?: number; stmt_len?: number }>; + }; + if (parsed.stmts.length !== result.statements.length) { + throw new Error( + `stripSubsystemSql: parser saw ${parsed.stmts.length} statements but the classifier saw ` + + `${result.statements.length}; refusing to slice by index` + ); + } + + const drop = new Set(result.excluded); + for (const i of result.kept) { + if (opaqueTargetsSubsystem(parsed.stmts[i].stmt, schemas)) drop.add(i); + } + + // Text before the first statement (pgpm headers, leading comments) is + // preserved verbatim so script identity headers survive the strip. + const prefix = + parsed.stmts.length > 0 ? sql.slice(0, parsed.stmts[0].stmt_location ?? 0) : sql; + + const pieces: string[] = []; + parsed.stmts.forEach((s, i) => { + if (drop.has(i)) return; + const start = s.stmt_location ?? 0; + const len = s.stmt_len ?? sql.length - start; + pieces.push(sql.slice(start, start + len).trim() + ';'); + }); + + return { + sql: prefix + pieces.join('\n\n') + (pieces.length > 0 ? '\n' : ''), + result, + dropped: [...drop].sort((a, b) => a - b) + }; +} diff --git a/pgpm/transform/src/bundle-driver.ts b/pgpm/transform/src/bundle-driver.ts index b7a4c62bbe..8b10e95749 100644 --- a/pgpm/transform/src/bundle-driver.ts +++ b/pgpm/transform/src/bundle-driver.ts @@ -47,8 +47,18 @@ export interface SchemaObjectRoute { kind: 'table' | 'view' | 'function' | 'procedure' | 'type'; /** Unqualified object name (e.g. `accounts`). */ name: string; - /** Target schema this object is routed to (e.g. `reporting`). */ - toSchema: string; + /** + * Target schema this object is routed to (e.g. `reporting`). `null` strips + * qualification (resolve via `search_path`); omitted with `toName` leaves + * the schema to the whole-schema default. + */ + toSchema?: string | null; + /** + * Target object name — rebinds references to a *different* object (e.g. + * point `identity.current_actor()` at `current_user_id()`). At least one of + * `toSchema`/`toName` must be given. + */ + toName?: string; } /** @@ -159,9 +169,20 @@ export function buildSchemaRouter(options: SchemaTranspilerOptions): SchemaRoute ensure(from).schema = to; } for (const route of options.routes ?? []) { + if (route.toSchema === undefined && route.toName === undefined) { + throw new Error( + `Object route for ${route.fromSchema}.${route.name} needs "toSchema" and/or "toName"` + ); + } const bucketKey = ROUTE_KIND_BUCKET[route.kind]; const target = ensure(route.fromSchema); - (target[bucketKey] ??= {})[route.name] = route.toSchema; + (target[bucketKey] ??= {})[route.name] = + route.toName === undefined && typeof route.toSchema === 'string' + ? route.toSchema + : { + ...(route.toSchema !== undefined ? { schema: route.toSchema } : {}), + ...(route.toName !== undefined ? { name: route.toName } : {}) + }; } return new SchemaRouter(spec); } diff --git a/pgpm/types/src/routing.ts b/pgpm/types/src/routing.ts index 89c5e02138..cf8d9d981b 100644 --- a/pgpm/types/src/routing.ts +++ b/pgpm/types/src/routing.ts @@ -29,8 +29,19 @@ export interface PgpmRouteEntry { kind: PgpmRouteKind; /** Unqualified object name (e.g. `accounts`). */ name: string; - /** Target schema the object is routed to (e.g. `reporting`). */ - toSchema: string; + /** + * Target schema the object is routed to (e.g. `reporting`). `null` strips + * the qualification entirely — the reference is emitted unqualified and + * resolves via `search_path`. May be omitted when `toName` is given (pure + * rename, schema untouched or governed by the whole-schema default). + */ + toSchema?: string | null; + /** + * Target object name — *rebinds* references to a different object rather + * than moving the same object (e.g. point `identity.current_actor()` calls + * at `current_user_id()`). At least one of `toSchema`/`toName` is required. + */ + toName?: string; } /** @@ -66,6 +77,18 @@ export interface PgpmExtensionsRouting { /** Role-name translation: source role name → target role name. */ export type PgpmRolesRouting = Record; +/** + * Subsystem exclusion: remove every object owned by these schemas from the + * transpiled output and substitute a replacement provider. Exclusion is only + * safe when every surviving reference into an excluded schema is rebound via + * `route` (or covered by a whole-schema `schemas` mapping); the engine + * refuses otherwise, naming each unsatisfied reference. + */ +export interface PgpmExcludeSpec { + /** Schemas whose objects form the excluded subsystem. */ + schemas: string[]; +} + /** The unified routing profile. Every key is optional and merges per key. */ export interface PgpmRoutingProfile { /** Whole-schema default: source schema → target schema. */ @@ -76,10 +99,12 @@ export interface PgpmRoutingProfile { extensions?: PgpmExtensionsRouting; /** Role-name translation (see {@link PgpmRolesRouting}). */ roles?: PgpmRolesRouting; + /** Subsystem exclusion + substitution (see {@link PgpmExcludeSpec}). */ + exclude?: PgpmExcludeSpec; } /** The routing-profile keys, in a stable order. */ -export const ROUTING_PROFILE_KEYS = ['schemas', 'route', 'extensions', 'roles'] as const; +export const ROUTING_PROFILE_KEYS = ['schemas', 'route', 'extensions', 'roles', 'exclude'] as const; /** * Merge routing profiles per key: for each of `schemas`/`route`/`extensions`/