Skip to content

feat(pgpm): reuse — expand a proxy into shared + per-tenant modules - #1516

Merged
pyramation merged 2 commits into
mainfrom
feat/pgpm-apply-shared-modules
Jul 30, 2026
Merged

feat(pgpm): reuse — expand a proxy into shared + per-tenant modules#1516
pyramation merged 2 commits into
mainfrom
feat/pgpm-apply-shared-modules

Conversation

@pyramation

@pyramation pyramation commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Makes a source pgpm package reusable across tenants while emitting objects the classifier proves are tenant-independent once. Builds on the splitBundle primitive (originally this PR) by wiring the partitioner (pgpm/slice) into apply.

A pgpm.apply.json may 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:

  • a shared module (objects reachable from no seed, e.g. catalog_shared.slugify), deployed once;
  • a per-tenant module (a seed or anything transitively reaching one, e.g. tenant_a.products + tenant_a.product_slug) that requires the shared one.

How it works (apply/reuse.ts)

bundleFromModule(source)
  → partitionModule({ seedObjects })          // classifier: which changes are per-tenant
  → makeSchemaTranspiler({                     // ONE object-routed transpile:
      schemaMap: reuse.sharedSchema,           //   default → shared schema
      routes: perTenantChanges → tenant schema //   per-object override → tenant schema
    })                                         //   so product_slug lands in tenant_a but
                                               //   still calls catalog_shared.slugify
  → splitBundle(transpiled, {
      perTenantChanges,
      sharedName, perTenantName,
      perTenantBootstrap: [tenant CREATE SCHEMA]  // each tenant owns its own schema;
    })                                            // per-tenant deps re-point off shared schema
  → verifyBundle + materializeBundle (both halves)

Cross-boundary references stay correct because a single SchemaRouter knows both destinations: the per-tenant function is defined in tenant_a yet references tenant_a.products (local) and catalog_shared.slugify (cross-module requires).

Deterministic shared identity / dedup

resolveSharedModuleName(spec) hashes { source, sharedSchema, seeds }"<source>-shared-<hash8>" (overridable via reuse.sharedName). Two tenant proxies over the same source/shared/seed config resolve to the same shared module, so addApplyModules adds it once and both tenants require it → deployed once, and reverting one tenant leaves the shared state (still needed by the other) intact. resolveEffectiveModulePath returns the shared or per-tenant materialization by requested name.

Validation (apply-spec.ts)

reuse must be an object with a non-empty sharedSchema string→string map and a non-empty perTenant seed array ({ fromSchema, kind, name }, kind ∈ table|view|function|procedure|type). Every seed schema must have a target in both schemas (per-tenant) and reuse.sharedSchema (shared). Existing schemas/route/requires behavior is unchanged.

Tests

  • apply-reuse.test.ts:
    • spec parsing/validation (valid + malformed reuse, deterministic + overridden shared name, cross-tenant name equality);
    • fs-level materializeReuseModule (shared helper emitted once; table + dependent fn routed per tenant; cross-module dep on the shared helper; local schema bootstrap; idempotent CREATE SCHEMA IF NOT EXISTS);
    • two-tenant DB e2e via the pgpm/pgsql-test harness: 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: perTenantBootstrap coverage (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

…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.
@pyramation pyramation self-assigned this Jul 30, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

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.
@devin-ai-integration devin-ai-integration Bot changed the title feat(bundle): splitBundle — partition a bundle into shared + per-tenant modules feat(pgpm): reuse — expand a proxy into shared + per-tenant modules Jul 30, 2026
@pyramation
pyramation merged commit 5aed6b7 into main Jul 30, 2026
16 checks passed
@pyramation
pyramation deleted the feat/pgpm-apply-shared-modules branch July 30, 2026 01:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant