feat(pgpm): reuse — expand a proxy into shared + per-tenant modules - #1516
Merged
Conversation
…nt modules 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 (<sharedName>:<change>) 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.
Contributor
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Makes a source pgpm package reusable across tenants while emitting objects the classifier proves are tenant-independent once. Builds on the
splitBundleprimitive (originally this PR) by wiring the partitioner (pgpm/slice) into apply.A
pgpm.apply.jsonmay now declare a reuse split:{ "source": "catalog", "schemas": { "catalog": "tenant_a" }, "reuse": { "sharedSchema": { "catalog": "catalog_shared" }, "perTenant": [{ "fromSchema": "catalog", "kind": "table", "name": "products" }] } }The proxy expands into two ordinary pgpm modules — no new deploy magic, just modules +
requires:catalog_shared.slugify), deployed once;tenant_a.products+tenant_a.product_slug) thatrequiresthe shared one.How it works (
apply/reuse.ts)Cross-boundary references stay correct because a single
SchemaRouterknows both destinations: the per-tenant function is defined intenant_ayet referencestenant_a.products(local) andcatalog_shared.slugify(cross-modulerequires).Deterministic shared identity / dedup
resolveSharedModuleName(spec)hashes{ source, sharedSchema, seeds }→"<source>-shared-<hash8>"(overridable viareuse.sharedName). Two tenant proxies over the same source/shared/seed config resolve to the same shared module, soaddApplyModulesadds it once and both tenantsrequireit → deployed once, and reverting one tenant leaves the shared state (still needed by the other) intact.resolveEffectiveModulePathreturns the shared or per-tenant materialization by requested name.Validation (
apply-spec.ts)reusemust be an object with a non-emptysharedSchemastring→string map and a non-emptyperTenantseed array ({ fromSchema, kind, name }, kind ∈ table|view|function|procedure|type). Every seed schema must have a target in bothschemas(per-tenant) andreuse.sharedSchema(shared). Existingschemas/route/requiresbehavior is unchanged.Tests
apply-reuse.test.ts:materializeReuseModule(shared helper emitted once; table + dependent fn routed per tenant; cross-module dep on the shared helper; local schema bootstrap; idempotentCREATE SCHEMA IF NOT EXISTS);pgsql-testharness: tenant A schema absent, tenant B schema pre-existing; shared helper deployed once; table + tenant-reading fn materialize per tenant; the fn depending on the tenant table is not shared; verify passes for both; reverting the newest tenant leaves the shared helper + the other tenant intact.split.test.ts:perTenantBootstrapcoverage (schema injected/prepended, dependency + SQL-header rewrite, collision + non-shared-replace rejection).Full suites green: core (388), bundle (44), slice (40); core typecheck + build clean.
Planning: constructive-planning#1298.
Link to Devin session: https://app.devin.ai/sessions/025fb88043964fdbb335ac5e39df2478
Requested by: @pyramation